— Article — № 001

001 —Security

Legacy PHP reinfection: the .htaccess wall that holds

A client's WordPress kept getting reinfected every few days. Cleaning files wasn't enough. Here's the .htaccess pattern that finally stopped it.

Brass padlock on paper card with wax seal, tarred rope coil, red ribbon on scarred oak workbench.
Hero · staged still№ 001

The client called on a Tuesday. Their WordPress had been cleaned on Sunday — all files replaced from a known-good tarball, admin passwords rotated, wp-config salts regenerated, the usual. By Tuesday afternoon, Google Safe Browsing had flagged the domain again. Same payload: a Japanese SEO spam injection rewriting wp_options.siteurl for unauthenticated visitors with a specific User-Agent.

This was the third cleanup in two weeks. The owner wanted to know whether they should just rebuild the whole thing on a "modern stack." That's almost never the answer, and it wasn't here. The answer was that the cleanup was treating symptoms while the entry door stayed open.

The reinfection loop

Most reinfections on a legacy site follow the same shape. You clean the visible mess — the injected header.php, the rogue wp-includes/class-wp-xyz.php, the base64 blobs. You miss one file. That file is a dropper: a small, innocent-looking uploader that accepts a POST and writes whatever it's told to write. A week later the attacker revisits it and reconstructs the whole kit in forty seconds.

In this case the dropper lived at wp-content/uploads/2021/08/.cache.php. It was 1.4 KB. It had been uploaded eight months before the first visible infection, through a vulnerable version of a contact form plugin that the site had stopped using but never deleted. The plugin's folder was still on disk. The vulnerable endpoint was still reachable.

Here is the access log entry that rebuilt the infection on Monday, 19:04 UTC:

203.0.113.88 - - [19/Apr/2026:19:04:11 +0000] "POST /wp-content/uploads/2021/08/.cache.php HTTP/1.1" 200 412 "-" "Mozilla/5.0"

A POST to a PHP file inside uploads/. That request should not exist. There is no legitimate reason any file under wp-content/uploads needs to execute PHP, ever. Same goes for wp-content/cache, most of wp-includes if your theme doesn't do weird things, and the entirety of any abandoned plugin folder.

Finding every dropper before you close the door

Before you write a single line of Apache config, you need to know what's actually on disk. Cleaning files you can see is not enough; you need to find the ones you can't.

find wp-content/uploads -type f \( -name "*.php" -o -name "*.phtml" -o -name "*.php5" -o -name "*.phar" \) 2>/dev/null
find wp-content/uploads -type f -name "*.*.php*" 2>/dev/null
find . -type f -name "*.php" -newer wp-config.php -not -path "./wp-content/plugins/*" 2>/dev/null

The third command is the one people skip. It lists every PHP file newer than wp-config.php, which on most sites hasn't been touched in months. Anything that shows up is either a legitimate plugin update you can verify against the repo, or it's the thing you're looking for.

On this site the first command returned nine files. Two were the dropper and a secondary shell. Seven were older injections that had been dormant since the last cleanup because the attacker was rotating through them.

The wall

Once the tree is clean, you stop PHP from executing where PHP has no business executing. This is done in Apache with a small .htaccess placed inside the uploads directory itself:

# wp-content/uploads/.htaccess
# Block PHP execution in the uploads tree.

<FilesMatch "\.(php|phtml|php3|php4|php5|php7|php8|phar|pht|inc)$">
    Require all denied
</FilesMatch>

# Also block handler-based execution (some hosts route via AddHandler).
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteRule \.(php|phtml|phar|pht)$ - [F,L,NC]
</IfModule>

# Deny if someone tries a double extension like image.php.jpg.
<FilesMatch "\.ph(p[3457]?|tml|ar|t)\.">
    Require all denied
</FilesMatch>

Three layers because hosts differ. FilesMatch with Require all denied is the primary block on modern Apache (2.4+). The RewriteRule catches handler-based execution on shared hosts where AddHandler application/x-httpd-php has been declared higher in the chain and a plain Deny doesn't interrupt it. The double-extension matcher handles the classic payload.php.jpg trick where the server executes on the first matched extension.

Drop the same file into wp-content/cache/.htaccess, and into any plugin folder you've disabled but can't yet delete. For Drupal, the equivalent locations are sites/default/files/ and sites/*/files/. For Magento 1, it's media/ and var/. For Joomla, images/ and tmp/.

Verify the wall works before you close the ticket:

echo '<?php echo "executed"; ?>' > wp-content/uploads/_test.php
curl -i https://example.com/wp-content/uploads/_test.php
# Expect: HTTP/1.1 403 Forbidden
rm wp-content/uploads/_test.php

If you get 200 OK with the word executed, your directive didn't take. Check whether AllowOverride permits FileInfo and Limit in the vhost, or push the block into the vhost directly with a <Directory> stanza.

What the wall doesn't do

It doesn't fix the vulnerability that let the dropper in. It makes the dropper useless, which is often enough — attackers move on to softer targets when their toolkit stops reconstructing itself. But the underlying hole is still there until you delete the abandoned plugin, update the living ones, and audit whatever custom upload handler the site's previous developer wrote in 2017.

It also doesn't help against infections that come in through the database — SQL-injected admin users, wp_options rewrites, stored XSS in post content. Those need a different walk: audit wp_users, audit wp_usermeta for wp_capabilities entries you didn't create, diff wp_options.siteurl and wp_options.home against what they should be.

After the wall

On this client's site, the .htaccess went in Tuesday evening. The next reconstruction attempt arrived Wednesday at 02:17 UTC. The access log shows eleven POST requests to four different dropper paths, all returning 403. By Friday the attacker had stopped. The site has been clean since.

The cleanup playbook we wrote for this one ended up being the template we use for every reinfection case now: find every PHP file under user-writable paths, diff everything newer than wp-config.php, apply the wall, verify with curl, then go hunt the original vulnerability at leisure.

When we built Pier we kept running into the friction of doing these audits over SFTP in one window, running find over SSH in another, poking the MySQL editor in a third, and losing track of what we'd changed when the client called back. The way we ended up handling it was by keeping the file tree, the database, and a full version history of every edit in one window, so a five-minute incident walkthrough stays a five-minute incident walkthrough.

Do this today

Open an SSH or SFTP session to your oldest client site. Run the first find command above against wp-content/uploads. Whatever it returns is the conversation you're having next.

— Questions —

Will blocking PHP in wp-content/uploads break WordPress?

No. Core WordPress does not execute PHP from the uploads directory. The only things that might break are misconfigured caching plugins or custom code that writes executable PHP into uploads, which is itself a red flag.

Does this work on Nginx?

Nginx ignores .htaccess. The equivalent is a location block in your server config: location ~* /wp-content/uploads/.*\.php$ { deny all; } placed inside the server block, then reload.

What if my host uses LiteSpeed?

LiteSpeed honours .htaccess directives compatible with Apache, including FilesMatch and Require all denied, so the same file works. The RewriteRule fallback is also supported.

How do I find the original vulnerability after cleanup?

Check access logs for the first POST to the dropper path. The referer and preceding requests usually identify the vulnerable endpoint — often an old plugin's AJAX handler or an unrestricted upload form.

Is this enough, or do I still need a WAF?

The wall stops execution of dropped files, which breaks the reinfection loop. A WAF prevents the drop in the first place. They address different stages; for a high-value site you want both.