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:
| |
So: Apache + PHP 8.1. The page itself is a simple 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:
| |
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”:
| |
The server responded with a beautiful error message:
| |
This is gold. It tells us the backend does roughly:
| |
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:
| |
| |
Two critical facts:
- The file is stored at
uploads/<original-filename>— the user-supplied filename is preserved. - 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:
| |
| |
Success again. The gallery only lists *.gif (so pwn.php doesn’t show up there), but let’s check directly:
| |
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:
| |
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):
| |
Output:
| |
And a hexdump of the result shows the payload sitting right at the top of the file, inside the color table:
| |
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:
| |
The server happily decodes our GIF, re-encodes it (preserving the palette = preserving our PHP), and writes it to uploads/shell.php. Fire it:
| |
| |
(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:
| |
| |
🐱
Root cause & remediation
The app did one thing right (re-encoding image contents to kill embedded payloads) and several things wrong:
- 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. - No extension allow-list on the saved path. Even with re-encoding,
imagegif("uploads/shell.php")writes executable PHP into the web root. uploads/is executable by the web server. Upload directories should be served as static files only. In Apache:Better still, store uploads outside the web root and stream them through a controlled handler.1 2 3 4<Directory /var/www/html/uploads> php_admin_flag engine off # or: RemoveHandler .php / SetHandler default-handler </Directory>- 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 giveninstantly 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.