№ I · 2026-05-05 → 2026-05-11 · authorized assessment · two rounds
RCE via extension blacklist + Win32 trailing-dot normalization
A vulnerability found during an authorized assessment of a friend's small PHP web app — plus the follow-up a week later, when the operator reported a fix, the retest showed the fix was incomplete, and a second round of remediation had to ship. Target anonymized; the bug class and the operator-side story are the interesting parts.
Summary
A file upload form on a Windows-hosted PHP app accepted arbitrary file extensions through a one-character bypass, leading to unauthenticated remote code execution. The bug is a class — not a specific vendor product — and exists wherever a PHP application uses extension blacklisting on Windows. Reported privately to the operator with remediation guidance.
Context
A friend asked me to look at the upload feature on a small hobby site he runs. Stack: PHP 7.x on Apache 2.4.x via XAMPP, Windows host. I had written authorization; findings were disclosed privately. The bug below is what I'm sharing publicly.
The bug
The upload endpoint validated user-supplied filenames against a blacklist of dangerous
extensions (.php, .PHP, .phtml, and similar). The
validator looked roughly like:
$ext = pathinfo($filename, PATHINFO_EXTENSION);
if (in_array(strtolower($ext), $blacklist)) {
reject('The file type is not accepted.');
}
move_uploaded_file($tmp, $dest_dir . '/' . $filename);
Looks reasonable. It is not. A filename like shell.php. — note the trailing
dot — bypasses the validator entirely, lands on disk as shell.php, and is
executed by Apache.
Why it works
The disagreement is between two parsers that both look at the same string but answer different questions about it:
-
pathinfo($filename, PATHINFO_EXTENSION)asks "what comes after the last dot?". Forshell.php.the answer is the empty string — nothing follows the trailing dot. The blacklist sees no extension and lets the upload through. - The Windows file save call (PHP's
move_uploaded_file, or any underlyingCreateFileW) goes through Win32 path normalization, which strips trailing dots and spaces from filenames.shell.php.becomesshell.phpon disk. This is documented Windows behavior and has been around since the NT days. - Apache's
AddHandlerthen matches the file's now-actual.phpextension and routes it to the PHP interpreter, executing whatever the attacker uploaded.
The validator looked at one thing; the OS saved a different thing. Whenever two layers of a pipeline interpret data differently, that gap is where bugs live.
Repro
echo '<?php echo "RCE_PROOF"; ?>' > shell.php
curl -X POST -F "files[]=@shell.php;filename=shell.php." \
https://target/upload-endpoint
# server saves it as "shell.php"
curl https://target/uploads/shell.php
# ⇒ "RCE_PROOF" Roughly thirty seconds end to end. No authentication, no captcha, no rate limit.
Impact
Standard RCE impact: full read/write/execute as the Apache user, plus any privilege
escalation paths from there. In context, that meant an attacker could read every file the
web user could read (configs, database files, other application data), modify any web
content on the box, drop persistent backdoors, and use the server as a pivot. The XAMPP
default permissions made most of D:\htdocs\ writable, so persistence was
trivial.
I confirmed execution with a benign payload that printed the server's hostname and PHP version back to the request, then cleaned up the test file via the application's delete endpoint (which, separately, was also unauthenticated).
Remediation
- Use a whitelist, not a blacklist. Allow only the extensions the feature actually
needs (
.jpg .jpeg .png .gif .mp4 .pdf, etc.) and reject everything else. Whitelists fail closed; blacklists fail open and you can't enumerate every dangerous extension on every OS forever. - Normalize the filename before validation, not after. Strip leading and trailing dots and spaces, reject multiple consecutive dots, reject control characters and any non-printable bytes. Validate the normalized form, not the raw input.
- Don't store uploads in a web-executable directory. The strongest fix is
structural: store outside the web root, serve via a handler that sets
Content-Type: application/octet-streamandContent-Disposition: attachment. Even a perfect upload is harmless if the storage path can't execute anything. - Defense in depth via Apache. In the upload directory's
.htaccess:<FilesMatch "\.(ph(p|tml|p[3457s]?|t|ar)|html?|cgi|pl|py|jsp|asp|exe|bat|sh)\.?$"> SetHandler default-handler ForceType text/plain Require all denied </FilesMatch> - Rename uploads to UUIDs server-side. Predictable filenames are their own attack surface (collision-overwrite, search-engine indexing).
Round II — the fix was incomplete
A week later the operator reported the upload was patched and asked for a retest.
The new validator blocked the obvious extensions — .php, .phtml,
.php5, .phps, mixed-case variants — but the trailing-dot bypass
from round one still walked straight through. Filename shell.php. was
accepted, written to disk as shell.php, executed by Apache. Same bug, same
path, two hours of "fix" later.
Five extension variants were tested in this round. One executed cleanly (the original
trailing-dot trick). Three more landed in the public upload directory as static files —
.phar, .pht, .php.txt — accepted by the validator
but not mapped to the PHP handler on this particular Apache config, so latent rather than
immediate. The remaining variants were rejected. The fix had patched the symptoms, not
the parser disagreement.
Convincing the operator
Reporting "you still have RCE" via JSON markers and curl transcripts did not move the operator — he had patched the bug himself and didn't believe a second test could contradict that. Demonstration was required. With written go-ahead I uploaded a small read/write helper via the bypass, used it to fetch the application's homepage source (sha1 pinned for later restore), injected a clearly-labeled banner into the page, and let the operator refresh in his own browser. Original content was restored byte-identical from local backup, the helper was deleted via the same DELETE endpoint that had created it, and the file count returned to its starting state. Total wall time from first upload to verified restore: under five minutes.
The methodology lesson: build reversibility into the demo before you build the demo. Hash-pin the original artifact, keep the local backup outside the target's reach, and have the restore command queued before you fire the modification. If the operator's belief is the blocker, plan a visible-artifact step into your engagement, scoped and reversible by construction.
Side findings (surfaced during retest)
- Unauthenticated DELETE. The same upload service exposed
DELETE ?file=<name>with no authentication. Any visitor could wipe every uploaded file in a one-line loop. - Directory listing + writable index. The upload path was browseable
(
Options +Indexes) and a second, unrelated attacker had already exploited this by uploading their ownindex.html— Apache happily served it as theDirectoryIndexin place of the real listing, defacing the page for every visitor browsing the upload area. Different attacker, same root cause: a public, world-writable, statically-served upload directory with no override onDirectoryIndex. This vector closes by accident when the.htaccesshardening above lands. - Prior compromise on a sibling path. A neighboring upload route on the same domain carried an old "is hacked" defacement string from an earlier, unrelated incident. Unrelated to my engagement but visible on first reconnaissance — worth flagging because it confirmed the operator was not monitoring the surface.
- Verbose errors in production. Image-processing failures from the
intervention/imagelibrary leaked the full Windows path (D:\htdocs\…\vendor\intervention\image\…), the vendor library name, and enough of the deployment layout to draw a map.display_errors = Onin production turns every malformed request into reconnaissance.
Remediation, round II
The first-round remediation was advice-by-email: use a whitelist, normalize before
validation, don't store in a web-executable directory. All correct, all unshipped a
week later. The second round shipped artifacts instead: a small zip containing one
.htaccess and a plain-language README, designed to drop into the upload
directory over FTP without touching the application code.
The .htaccess disables the PHP engine in the upload directory
(php_flag engine off), removes script handlers
(RemoveHandler / RemoveType), denies HTTP access to any executable-extension
file via case-insensitive FilesMatch, disables DirectoryIndex
(closing the static-index defacement vector flagged above), and turns off directory listing
(Options -Indexes). This is defense in depth, not a fix: the upload
handler still passes .php. through. It just can no longer execute anywhere
it lands. The code-level fix — whitelist plus rtrim($name, ". ") plus
authentication on DELETE — is the next round, and is now the smaller of the two changes
because the explosive radius has been contained.
Lessons that don't fit in a CVE
- Telling someone they have RCE is the easy part. Convincing them that the fix they sincerely believe they shipped didn't actually work — that's the engagement. Plan a reversible visible-artifact demo into the methodology before you need it.
- Defense in depth is what ships first. An operator with FTP and a Windows host
can deploy a hardened
.htaccessin two minutes; the code change may take weeks. Optimize the first deliverable for the smallest reversible change that closes the attack path, not the cleanest code review. - Watch the neighborhood. If one upload route on a domain is broken, the others probably are too — same code, same operator, same blind spots. Two of three upload routes on this domain carried visible attacker artifacts from prior incidents on first look.
Notes
-
The bug class generalizes: anywhere a PHP app uses extension blacklisting on Windows,
look for the trailing-dot bypass. Cousins include
\x00truncation on older PHPs,::$DATANTFS alternate data stream, and mixed-case combined with trailing dots. -
This is the same shape as the classic IIS
;.jpgsemicolon bypass, the Apachemod_mimedouble-extension issue, and the Nginx\0.phptrick. The pattern recurs because file-validator code and OS-file-save code rarely talk to each other. - Disclosure: vulnerability reported privately to the operator with full remediation guidance. Public writeup contains no information sufficient to identify or exploit the original target.