TechEarl

File Upload Extension Bypass: Why Blacklists Always Lose

Ishan Karunaratne⏱️ 12 min readUpdated
Share thisCopied
File upload extension bypass via .phar

Extension blacklists are the validation pattern I still find most often in upload code, and the one that almost never holds. A blocklist asks the developer to enumerate every extension the server will execute. The server's list is set by Apache, mod_php, the distro package, any custom handler ops added last year, and any handler the next deploy will add. The developer's list is whatever they typed in 2018. The two diverge the moment they are written, and the gap is where the bypass lives.

This is the variant deep dive that sits under the file upload vulnerabilities hub. I cover why the blocklist shape fails structurally, the forgotten-extension catalogue that still produces RCE in 2026, the case-flip variants and which platforms actually care about them, a working chain against the upload-basic lab, the allowlist that holds up, and three real CVEs that put extension-blacklist bypasses on the public record.

In short: why extension blacklists fail

An extension blacklist is a closed-set rejector working against an open-set problem. The validator lists php, phtml, php3, php4, ships, and then the surrounding world keeps growing. PHP adds PHAR. Distro packages ship Apache configs that hand .pht and .phtml to mod_php by default. A team enables .php7 for a legacy module and forgets to update the upload validator. Every one of those changes is invisible to the application, but every one of them turns a previously-safe upload form into RCE. An allowlist (['jpg', 'jpeg', 'png', 'gif', 'webp']) inverts the shape: the developer enumerates what the application needs, and rejects everything else. The closed set is the legitimate one, the open set is the rejected one, and changes in the surrounding world cannot expand the accepted surface. That single inversion is why allowlists hold and blacklists do not.

The closed-set vs open-set asymmetry

Validation rules come in two shapes. A blacklist names what is bad and accepts everything else. An allowlist names what is good and rejects everything else. Both look symmetric on paper. They are not.

The set of file extensions the server is willing to execute is open. It depends on:

  • The Apache build (mod_php, mod_cgi, mod_perl, mod_python, mod_wsgi).
  • The PHP package (some distros ship PHAR enabled, some do not).
  • Any AddHandler or AddType line in any .htaccess between the upload directory and the document root.
  • Any custom handler ops added for an unrelated tool.
  • Any future module that gets enabled after the validator was written.

The set the application needs is closed. A profile-picture uploader needs jpg, jpeg, png, gif, webp. A document import needs pdf, docx, xlsx. The developer owns the closed set and does not own the open one. Validating against the set the developer owns is the only structurally sound choice. Validating against the set the developer does not own is asking ops to never touch the server config, which is not a property you can ship.

This is the same shape as SQL injection defences (parameterise the values you control, never sanitise the values you do not) and XSS defences (escape on output for the context you know about, never blacklist <script>). The pattern repeats because the underlying mistake repeats.

The forgotten-extension catalogue

These are the extensions I have seen blacklists miss and an attacker actually reach. Each one is a real handler that exists in some default Apache or PHP package on some distribution, with no extra config.

.phar

PHP archive. The PHAR extension was added in PHP 5.3 (2009) and bundled in core ever since. The PHP docs describe .phar as a packaging format equivalent to JAR; the PHAR stream wrapper can also execute archive contents when included. Whether .phar is mapped to mod_php at the web server layer depends on the Apache config; many shared-hosting builds and distribution packages add the mapping by default. When they do, a .phar upload that the blacklist missed is straight RCE. This is the variant the lab demonstrates below.

.pht

Short alias for .phtml. Ships in some PHP packages as a default handler. Many blacklists block phtml and miss the three-character alias.

.phtml

The original PHP/FI extension, predates .php. Still mapped by default in older Apache configs and some current Debian and Ubuntu PHP packages. Usually remembered by the blacklist, sometimes not.

.php3, .php4, .php5, .php7

Legacy version-pinned extensions. Common when a host runs multiple PHP versions side by side and uses the extension to pick which interpreter to invoke. The validator that lists php, php3, php4 and stops there is missing php5 and php7. I have seen all four still mapped on production systems in 2026.

.phps

Source-view extension. By default, when mapped, returns the source of the file syntax-highlighted rather than executing it. Useful to an attacker as an information disclosure (leak .phps of an existing PHP file in the same directory and read it back), occasionally executable depending on local config.

The pattern: the blacklist names what its author remembered. The handler list names what the server config materially does. The two are not synchronised, ever.

Case-flip variants and which platforms care

The other bypass that gets cited everywhere is case-flipping the extension: upload shell.phP or shell.PHP against a validator that blacklists the lowercase form. This works in some environments and not in others, and the difference is worth being honest about.

Stock Apache with mod_php on Debian-based images matches handler extensions case-sensitively. The directive <FilesMatch \.php$> uses a literal regex, so shell.phP does not match the handler block, the file is served as plain text, and the case-flip does not produce execution. The bypass against this environment is the forgotten-extension variant above, not case-flipping.

Where case-flips do work:

  • IIS on Windows. Case-insensitive by default across the stack (filesystem, handler matching, URL routing). shell.phP and shell.PHP route to the same handler as shell.php.
  • Case-insensitive filesystems on Apache. macOS HFS+/APFS (default mode) and Windows NTFS are case-insensitive. An Apache build running on either can resolve shell.PHP to a file written as shell.php, depending on how the request is dispatched.
  • Apache configs using case-insensitive regexes. A handler block written as <FilesMatch (?i)\.php$> or <FilesMatch \.[Pp][Hh][Pp]$> matches the case-flip variant. Both forms exist in the wild, particularly in older shared-hosting configs.
  • AddType and AddHandler lines. Apache's mod_mime matches extension arguments case-insensitively across the 2.x line, so any AddType or AddHandler rule that names .php also matches .PHP, .Php, and every other case variant.

The point is that case-flip is platform-dependent. On a Linux server running stock mod_php it does not bypass. On Windows IIS it almost always does. A blacklist that lowercases the input before comparison (strtolower(...)) closes the case-flip variant entirely, on every platform, but it does nothing about the forgotten-extension variant. The two are independent.

Walk a working chain (lab)

The upload-basic lab in the techearl-labs companion repo has an endpoint that implements exactly this validation pattern. Bring it up:

bash
docker compose up upload-basic

The lab listens on http://localhost:8083. The blacklist endpoint is /upload-blacklist.php, which blocks php, phtml, php3, php4 and stores accepted uploads under /uploads/blacklist/. The lab's Apache config explicitly maps .phar to mod_php, simulating the realistic misconfiguration where PHAR support was enabled for a tool that needed it and the upload validator was never updated to match.

The webshell:

php
<?php echo shell_exec($_GET['c'] ?? 'id'); ?>

Save it as shell.php in the working directory.

Forgotten extension: .phar succeeds

bash
curl -F 'file=@shell.php;filename=shell.phar' http://localhost:8083/upload-blacklist.php
curl 'http://localhost:8083/uploads/blacklist/shell.phar?c=id'

The ;filename=shell.phar segment is how curl rewrites the multipart filename so the file lands on disk as shell.phar instead of shell.php. The validator extracts the trailing extension as phar, checks the blocklist, finds nothing, and writes the file through. Apache routes the request to mod_php on the way back out (.phar is in the handler map), the interpreter runs the file, and the second curl prints the output of id from inside the container.

Two HTTP requests, one bypass, no clever tooling.

Case-flip: .phP fails against stock Apache

bash
curl -F 'file=@shell.php;filename=shell.phP' http://localhost:8083/upload-blacklist.php
curl 'http://localhost:8083/uploads/blacklist/shell.phP?c=id'

The upload succeeds (the blacklist is case-sensitive, sees phP, finds nothing). The second request returns the raw bytes of the webshell as plain text. Apache's handler block in the lab matches \.php$ literally; shell.phP does not match, so mod_php is not invoked, and the file is served by the default static-file handler.

The lab is using a Debian-based PHP image, which is the most common deployment shape for the Apache + mod_php stack. On that shape the case-flip is a tutorial-favourite bypass that does not actually produce execution. The forgotten-extension variant is the one that holds up against the real world.

Lab takeaway

Same validator, two attempted bypasses. The forgotten extension produces RCE because the server config genuinely treats .phar as PHP. The case-flip does not, because the server config genuinely matches .php case-sensitively. The bypass that lands is the one where the server config and the application's validator have diverged in a way the attacker can exploit. The bypass that does not land is the one where the server config closes the gap the validator left open, by accident.

The lesson is that the server config is the validator that matters. The application's blacklist is theatre wrapped around it. Allowlist on the application side, and assert the server config you depend on, and the two stop drifting apart.

The allowlist that actually works

The replacement validator is four properties stacked. None of them is novel; the combination is what holds.

php
$allowed_ext = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
$allowed_mime = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];

$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowed_ext, true)) {
    die('Extension not allowed');
}

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['file']['tmp_name']);
if (!in_array($mime, $allowed_mime, true)) {
    die('Content does not match allowed types');
}

$stored = bin2hex(random_bytes(16)) . '.' . $ext;
move_uploaded_file($_FILES['file']['tmp_name'], '/var/uploads/' . $stored);
  1. Extension allowlist. Trailing extension only, lowercased, against a closed set. Future server-side handler changes cannot expand what the application accepts.
  2. Magic-byte check. libmagic (finfo in PHP, python-magic in Python, file-type in Node; mime-types is not enough, it reads the extension not the body) confirms the body actually matches its declared type. A .jpg whose bytes are PHP fails this check.
  3. Randomised filename. The attacker no longer controls the filename. Path traversal in the name is dead, overwriting index.php is dead, double-extension games are dead.
  4. Storage outside the web root. /var/uploads/ is not under any path Apache or Nginx serves. Even a perfectly-named .php file in this directory cannot be requested directly. Files are served back through a controller that sets Content-Disposition: attachment and X-Content-Type-Options: nosniff.

Any one of these alone has gaps. The four together close every variant in this article. The full defence stack (metadata stripping, ClamAV scanning, dropping execute permissions on the upload directory, serving from a separate isolated domain) is covered in the file upload vulnerabilities parent article.

Real-world incidents

Three CVEs where extension-blacklist bypasses (or the absence of an allowlist) produced public, exploited vulnerabilities.

CVE-2018-9206 (Blueimp jQuery File Upload)

Affected all versions of Blueimp's jQuery-File-Upload up to and including 9.22.0. The server-side example PHP handler shipped with the library accepted any uploaded file and stored it inside the web root. The library's defence relied on a default .htaccess that Apache versions from 2.3.9 onward no longer honoured the way the library assumed. CVSS 3.1 score 9.8 CRITICAL. The fix added explicit content-type checks server-side; the harder problem was that the library had been forked thousands of times across GitHub, and each fork inherited the broken assumption. This is the canonical example of a blacklist that depended on the surrounding server config and broke when the surrounding server config moved.

CVE-2020-35489 (Contact Form 7 for WordPress)

Affected Contact Form 7 versions before 5.3.2. The file-upload field stripped certain characters from filenames but did not stop double-extension attacks like shell.php.jpg. Attackers uploaded files with a real image extension trailing a PHP extension; on hosts whose Apache config used AddHandler (which matches .php anywhere in the filename rather than as the trailing extension), the file executed as PHP. The result was unauthenticated RCE on every WordPress install with the plugin enabled, a contact form that accepted attachments, and that handler shape. CVSS 3.1 score 10.0 CRITICAL (scope-changed vector). The plugin had over 5 million active installs at the time of disclosure. The patch added stricter filename sanitisation; the structural fix is an extension allowlist enforced on the trailing extension only.

CVE-2022-22963 (Spring Cloud Function), adjacent

Worth a note for shape. Not strictly an extension bypass but the same family: the validator on the framework side trusted attacker-controlled routing input that downstream code interpreted as a SpEL expression and evaluated. The mechanism (untrusted input crossing a trust boundary into an interpreter) is identical to the upload-extension family; the lesson is that "we filtered the obvious bad strings" is a blacklist by another name. CVSS 3.1 score 9.8 CRITICAL.

FAQ

Where to go next

  • Back up to file upload vulnerabilities for the full taxonomy of validation patterns and the defence stack in depth.
  • The sibling deep dive on MIME-type bypasses covers what happens when the validator trusts Content-Type instead of the extension, and why magic-byte sniffing alone is still not enough.
  • The sibling on double-extension and AddHandler covers the Apache handler trap where a correct extension check still produces RCE because the server interprets the filename differently from the application.
  • For the wider map of related classes, see the web application security vulnerabilities hub.

Sources

Authoritative references this article was fact-checked against.

Tagsfile-uploadextension-bypassweb-securityphar

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

fuxploider Cheat Sheet: Every Flag I Actually Use

A field-tested fuxploider reference: target shaping, true/false response detection, extension fuzzing, cookies and headers, proxying, threading, and what to do once a webshell uploads. Grounded in the real argparse surface.