COMP6841 Something Awesome

z5418112 (Sam Zheng)

No Threshold

By: dhmosfunk

8 hours

Hard
Python
ACL
SQLi

Introduction and Reconnaissance

This was a Python Flask app, which was a simple shop website of sorts. It also contained a login page, but that's really about it. This was also quite a challenging challenge, which multiple curveballs thrown at me, and the amount of time I needed to spend to circumvent them all. However, unlike the POP Restaurant challenge, this one had a more straight forward path to the flag.

The main page of the website

The main page of the website

Looking at the source code of the website, it looks quite simple. Within the conf folder, I notice that this website was using HAProxy as its proxy server. We also have a SQLite database, which contains a users table with a single users value of admin. Password is randomly generated, as known from the entrypoint.sh script.

Docker entrypoint script

Docker entrypoint script

Next up, looking throughout the pages - we have two public pages, and two private pages. The public pages correspond to the shop and the login page, while the private pages correspond to a dashboard and a verify 2FA page. Looking inside the dashboard.html:

<body>
<div class="container">
<div class="content">
Welcome, here is your flag: <b> {{ flag }} </b>
</div>
</div>
</body>

Well we know what we need to do now - somehow get to the dashboard page to get the flag. Also, given that the flag is not actually stored as a file this time, but as a variable within a Python class, we know that we need to somehow get to the dashboard page to get the flag.

Looking inside the blueprints folder, it seems this is where most of the backend code is stored. I do notice that there is a decorator function that is acting as the main middleware for the website, redirecting users to the login page if they are not logged in. The login page is also utilising a library called uwsgi to handle login and 2FA authentication.

Finally, looking at the login.py file, it seems that the login page is vulnerable to our old friend SQLi:

user = query_db(
f"SELECT username, password FROM users WHERE username = '{username}' AND password = '{password}'",
one=True,
)

The query is vulnerable to SQLi, as the username and password are directly concatenated into the query. This means that we can potentially bypass the login page by using SQLi, to gain access to the admin dashboard. It does seem though, that once we login we are redirected to a 2FA page and have to go through that as well.

Planning

So my overall plan is to get to the login page, bypass the login page and sign in as an admin, and then brute force the 2FA code to get to the dashboard page, given that the 2FA code is just a 4 digit number, we only have 10000 options to try. Doesn't sound too bad?

Execution

This is when I got thrown my first curveball.

Login page (or where it should be)

Login page (or where it should be)

The login page was not seemingly publicly accessible, as I was getting a 403 Forbidden. This perplexed me, given that after reading through the source code multiple times, I couldn't see any rate limiting or IP blocking. I was stuck for an admittedly long time, until I remembered that we had HAProxy as the proxy server. Looking inside the configuration file, I found the following line:

http-request deny if { path_beg /auth/login }

Yeah, that would probably be doing it. To get some ideas of how we could bypass this, I looked online for tricks to bypassing 403s as configured by proxies. Of course, HackTricks came to the rescue, with a list of ways to bypass 403s.

There were some interesting methods, such as fuzzing HTTP methods, fuzzing the headers, but in the context of this particular proxy configuration, the one that caught my eye was path fuzzing. They suggested that we could try to bypass the 403 by for example changing one character to using the Unicode equivalent. To my surprise, this actually worked and I was able to access the login page! In my case, I changed the last 'n' in login to the equivalent %6e.

Login page

Login page

We're in! Now, knowing that the login page is vulnerable to SQLi, and knowing the structure of the query, I tried to bypass the login page by using the following payload:

SQLi payload

SQLi payload

The password is irrelevant, as the SQLi should ideally bypass the password field. This would turn the query to:

SELECT username, password FROM users WHERE username = 'admin' or '1'='1' AND password = 'a';

Because of the order of operations in this case, the or is enough to evaluate this statement to true, even if the password is not in fact 'a', which it isn't.

Ah crap, I forgot about the proxy configuration. Of course, the original coding of the login page was to send the POST request to the /auth/login route, which would be blocked by the proxy. I needed a way to bypass this. I initially considered using the Developer tools built into the browser to change the route, but realised this might have been quite tedious. Instead, I launched Burp Suite and used the interceptor to intercept and edit the request before it was sent.

Using Burp suite to intercept and edit requests

Using Burp suite to intercept and edit requests

Yes! We got through the login page with Burp Suite. Now, we are at the 2FA page, as I had initially suspected.

Brute forcing the 2FA code

As previously mentioned, the 2FA code is a 4 digit number, which means there are only 10000 possibilities - we could very easily brute force this with a script. So, I wrote a simple Golang script to brute force the 2FA code:

Running this script (testing locally), I was hit with another curveball.

Rate limiting

Rate limiting

Of course it wouldn't be that easy. The 2FA page was rate limited, and I was only able to make 20 requests every minute. Taking a look at the HAProxy configuration responsible for this however, I found that the rate limiting was based on the IP address of the client, as determined by the X-Forwarded-For header. This meant that I could potentially bypass the rate limiting by spoofing the IP address in the header.

# Parse the X-Forwarded-For header value if it exists. If it doesn't exist, add the client's IP address to the X-Forwarded-For header.
http-request add-header X-Forwarded-For %[src] if !{ req.hdr(X-Forwarded-For) -m found }
# Apply rate limit on the /auth/verify-2fa route.
acl is_auth_verify_2fa path_beg,url_dec /auth/verify-2fa
# Checks for valid IPv4 address in X-Forwarded-For header and denies request if malformed IPv4 is found. (Application accepts IP addresses in the range from 0.0.0.0 to 255.255.255.255.)
acl valid_ipv4 req.hdr(X-Forwarded-For) -m reg ^([01]?[0-9][0-9]?|2[0-4][0-9]|25[0-5]).([01]?[0-9][0-9]?|2[0-4][0-9]|25[0-5]).([01]?[0-9][0-9]?|2[0-4][0-9]|25[0-5]).([01]?[0-9][0-9]?|2[0-4][0-9]|25[0-5])$
http-request deny deny_status 400 if is_auth_verify_2fa !valid_ipv4
# Crate a stick-table to track the number of requests from a single IP address. (1min expire)
stick-table type ip size 100k expire 60s store http_req_rate(60s)
# Deny users that make more than 20 requests in a small timeframe.
http-request track-sc0 hdr(X-Forwarded-For) if is_auth_verify_2fa
http-request deny deny_status 429 if is_auth_verify_2fa { sc_http_req_rate(0) gt 20 }

There is some input validation on the IP address, but it is only checking for a valid IPv4 address, which means that we could potentially bypass this by using a valid IPv4 address in the header, as long as I generate a different IP correctly.

I modified my script to fix this issue, and ran it again.

Running this script again, I was disappointed to find that the new script still didn't work. It would always stop at seemingly random code (which changes on every run), and when it stops, it would go back to the 403 Forbidden page.

I thought about this for a while, going back to the source code to figure out what was going on.

@verify2fa_bp.route("/verify-2fa", methods=["GET", "POST"])
@requires_2fa
def verify():
if request.method == "POST":
code = request.form.get("2fa-code")
if not code:
return render_template("private/verify2fa.html", error_message="2FA code is empty!"), 400
stored_code = uwsgi.cache_get("2fa-code").decode("utf-8")
if code == stored_code:
uwsgi.cache_del("2fa-code")
session["authenticated"] = True
return redirect("/dashboard")
else:
return render_template("private/verify2fa.html", error_message="Invalid 2FA Code!"), 400
return render_template("private/verify2fa.html")

Then I saw it - the way that successful authentication was being set was a simple boolean in the session storage. My current script didn't persist cookies or any session data, so it would successfully authenticate, but then the next request to redirect to the dashboard page would take me back to the login page, because the session data was not there - which ultimately led me back to the 403 Forbidden page due to the proxy configuration. I needed to persist the session data.

The only difference to the script this time is the addition of a cookie jar to persist the session data. Running the script and praying:

Flag obtained!

Flag obtained!

Reflection and Learnings

Last Writeup

Insomnia

Next Writeup

DoxPit