August 28, 2026
Web Shell Upload via Extension Blacklist Bypass
PortSwigger Web Security Academy โ Write-up
By Menna Ahmed
5 min read
Lab: Web Shell Upload via Extension Blacklist Bypass
Category: File Upload Vulnerabilities
Difficulty: Practitioner
Introduction
File upload features are everywhere avatars, documents, attachments and they're one of the fastest ways for an application to hand an attacker code execution if the server-side validation is weak.
This lab blacklists the obvious extension (.php), but a blacklist can only block what its author thought of. In this write-up I'll walk through how I found an extension that wasn't on that list โ one that Apache still happily executed as PHP and used it to read /home/carlos/secret.
There's more than one way to solve this lab. The "intended" PortSwigger solution abuses an .htaccess upload to remap a brand-new extension to the PHP handler. I went a different route: fuzzing the extension itself. Both are worth knowing, and I'll touch on why mine worked toward the end.
Credentials provided: wiener:peter
Step 1: Mapping the Upload Feature
After logging in as wiener, the account page exposes an avatar upload field.
Before trying to break anything, I wanted to understand how a normal upload behaves: where the file ends up, what the server checks, and how it's served back. The account page shows a file input under Avatar, tied to an Upload button that posts to /my-account/avatar.
Step 2: Confirming the Blacklist
The first, obvious test is uploading a plain PHP web shell and seeing what happens.
shell.php:
<?php
echo file_get_contents('/home/carlos/secret');
?><?php
echo file_get_contents('/home/carlos/secret');
?>The server rejected it outright.
So .php is explicitly blacklisted. That's not a surprise it's the first extension anyone would think to block. The interesting question is what else the server would execute as PHP that the blacklist author didn't think to list.
Step 3: A Near Miss .pHp5
Blacklists that key off a fixed extension string are usually easy to sidestep with case changes or alternate PHP extensions (.php3, .php4, .php5, .phtml, .phtโฆ). I tried mixing both approaches at once and renamed the shell to shell.pHp5.
This time the upload was accepted no rejection message, no error. The blacklist clearly wasn't matching this extension.
But accepted isn't the same as executed. Opening the uploaded file directly:
The browser returned the raw PHP source, not its output. The <?php ... ?> code was displayed as plain text instead of running.
This is a useful (and easy to miss) distinction: bypassing the filename filter only gets you a file on disk the server still has to be configured to hand that specific extension to the PHP interpreter, or all you've uploaded is a text file with a confusing name. In this case, .pHp5 wasn't mapped to any handler Apache would execute, so it was served as static content.
Step 4: .phar Accepted and Executed
Rather than trying to modify the server's configuration (the .htaccess route), I then tested an alternate PHP-related extension that wasn't covered by the blacklist. In this lab environment, .phar was accepted by the upload filter and, more importantly, was mapped to a handler that executed the uploaded PHP code.
I renamed the same payload to shell.phar and uploaded it.
The server accepted it without complaint.
This time, opening the file actually executed it. The response contained the contents of /home/carlos/secret instead of the source code confirmation that the PHP interpreter had run the file rather than just serving it.
Step 5: Submitting the Solution
I copied the value returned by the shell and submitted it through the lab's solution dialog.
Lab solved.
The Attack Chain
Normal image upload
โ
Discover /my-account/avatar and /files/avatars/
โ
Upload shell.php โ blocked ("php files are not allowed")
โ
Upload shell.pHp5 โ accepted, but served as plain text (not executed)
โ
Upload shell.phar โ accepted AND executed as PHP
โ
GET /files/avatars/shell.phar
โ
Read /home/carlos/secretNormal image upload
โ
Discover /my-account/avatar and /files/avatars/
โ
Upload shell.php โ blocked ("php files are not allowed")
โ
Upload shell.pHp5 โ accepted, but served as plain text (not executed)
โ
Upload shell.phar โ accepted AND executed as PHP
โ
GET /files/avatars/shell.phar
โ
Read /home/carlos/secretWhy This Worked
The upload validation relied on a blacklist that failed to account for all extensions and filename variations that could potentially interact with server-side handlers.
It didn't account for:
- Case variation
.pHp5Slipped past the filter, even though it didn't get executed in this case - Alternate PHP-family extensions that a properly configured server may still hand to the PHP interpreter
.pharbeing a good example, since it's PHP's own packaging format and is frequently left mapped to the same handler as.php
The core lesson from the .pHp5 attempt is important on its own: getting a file past the filter and getting a file executed are two separate problems. A blacklist bypass is only dangerous if the resulting extension is also wired up to run server-side code. Testing each candidate extension by actually requesting it, not just checking whether the upload was "accepted" is what separates a real finding from a false lead.
It's also worth noting this isn't the only bypass. PortSwigger's own intended solution uploads a malicious .htaccess file (AddType application/x-httpd-php .l33t) to make Apache treat a brand-new, attacker-chosen extension as PHP. That route works regardless of what extensions the server happens to already support mine relied on the server already recognizing .phar. Both land on the same root cause: the application trusted a blacklist to anticipate every extension the underlying server could ever be persuaded to execute.
Impact
An attacker able to upload and execute arbitrary server-side code can typically:
- Achieve Remote Code Execution (RCE)
- Read application source code and configuration files
- Access environment variables, credentials, and secrets
- Pivot further into the underlying host or connected systems
Here, the shell executed with the privileges of the web application user and was sufficient to read another user's private file.
Remediation
Blacklisting dangerous extensions is fragile because it requires the defender to predict every extension the server might ever execute, including ones introduced by case changes, legacy PHP handler mappings, or (as in the .htaccess variant) configuration files the application shouldn't accept at all. A more robust upload implementation should:
- Use an allowlist, not a blacklist only permit known-good extensions/MIME types (e.g.
.jpg,.png), and reject everything else by default. - Validate actual file content, not just the filename or
Content-Typeheader (e.g. verify image files are genuinely images). - Generate the stored filename server-side rather than trusting user input, removing any control over the extension entirely.
- Store uploads outside the web root, or in a location with script execution disabled, and serve them through a handler that never interprets them as code.
- Reject configuration files such as
.htaccess,.htpasswd, orweb.configoutright, since these can redefine how a directory is handled.
Key Takeaways
- A blacklist is only as good as the list of things its author remembered to block.
- "Upload accepted" and "file executes" are different milestones always confirm execution, not just acceptance.
- Case variations and legacy/alternate extensions (
.php5,.phtml,.phar, etc.) are cheap to test and can reveal gaps a blacklist author didn't anticipate. - The same vulnerability class can have more than one valid exploitation path knowing the "textbook" solution doesn't mean it's the only one worth understanding.