Sitemap

HTB Academy — File Upload Attacks: Bypassing a Blacklist Filter

3 min readAug 14, 2026

--

The app in front of me let users upload a profile image. Simple enough, except the upload box only accepted image files — or so the front end claimed.

I fired up mitmproxy and watched the traffic as I uploaded a normal image. There it was: a multipart POST request carrying the file straight to the server. If the client was the only thing checking file type, I could just intercept this request, swap the file, and resend it.

I asked a generative AI to turn that captured request into a Bash script built around curl, so I could fire off POST requests with any filename and content I wanted, without touching the browser again.

First test: rename my payload to shell.php and send it. Response: “Extension not allowed.” So there was server-side validation after all — a blacklist, rejecting the obvious extensions.

That meant one thing: find an extension the blacklist forgot.

I looped the script through a list of PHP-adjacent extensions — .php, .php3, .php5, .phtml, .pHp, .pH, and more — watching the response for each one. Some came back “Extension not allowed.” Others came back “File successfully uploaded.”

Progress — but not victory. Uploading was only half the problem.

Every file that made it past the blacklist got uploaded to /image_uploads/. So I visited each one directly: domain/image_uploads/shell.pHp, domain/image_uploads/shell.pH. Every time, the browser just handed back the raw source code of my file. The server was happily storing my PHP, but it had no idea what to do with a .pHp or .pH file — it just served it as plain text. No execution, no shell.

Get Nam Do’s stories in your inbox

Join Medium for free to get updates from this writer.

So the real question wasn’t “what bypasses the blacklist” — it was “what bypasses the blacklist AND still gets handed to the PHP interpreter.” Those are two different filters, and I’d only beaten one of them.

I kept working down the extension list with the AI-generated script, checking off each one against both conditions: accepted on upload, then fetched back to see whether it ran or just echoed itself as text.

Then I tried .phar.

Upload response: “File successfully uploaded.” So far, nothing new. But when I opened domain/image_uploads/shell.phar in the browser, nothing echoed back. No source code dump. The server had actually executed it.

My payload was one line:

<?php system($_GET[‘cmd’]);?>

I hit domain/image_uploads/shell.phar?cmd=cat%20/flag.txt and the response came back with the flag in plain text. From a locked-down upload form to arbitrary command execution, all because .phar slipped past a blacklist that only knew to look for .php.

Why it worked

Here’s the thing about that blacklist — it was only ever looking for the extensions everyone already knows about. .phar never made the list, probably because nobody thinks of it as “a script.” It’s actually a PHP Archive, basically PHP’s answer to a .jar file in Java: a way to bundle a bunch of PHP code into one package. But under the hood, PHP’s handler still treats it as executable code, no different from a plain .php file. So the filter waved it through, and the interpreter ran it anyway. That mismatch — what the blacklist thinks is dangerous versus what the server will actually execute — is exactly the gap I walked through.

--

--