№ IV · MMXXVI projects

№ 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:

  1. pathinfo($filename, PATHINFO_EXTENSION) asks "what comes after the last dot?". For shell.php. the answer is the empty string — nothing follows the trailing dot. The blacklist sees no extension and lets the upload through.
  2. The Windows file save call (PHP's move_uploaded_file, or any underlying CreateFileW) goes through Win32 path normalization, which strips trailing dots and spaces from filenames. shell.php. becomes shell.php on disk. This is documented Windows behavior and has been around since the NT days.
  3. Apache's AddHandler then matches the file's now-actual .php extension 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

  1. 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.
  2. 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.
  3. 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-stream and Content-Disposition: attachment. Even a perfect upload is harmless if the storage path can't execute anything.
  4. 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>
  5. 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)

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

  1. 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.
  2. Defense in depth is what ships first. An operator with FTP and a Windows host can deploy a hardened .htaccess in 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.
  3. 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