The wp-content/uploads folder is the one place in a WordPress installation where the site needs to be able to write freely: images, PDFs and plugin-generated files all end up there. That is exactly why it is also the favorite spot for webshells, small PHP files that, when requested from a browser, let an attacker run commands, upload more files and browse the server.
Why uploads are the target
A file upload vulnerability in a plugin, a misconfigured contact form or a compromised author account is enough to write a file into uploads. If the server also executes PHP files in that folder, the uploaded file immediately becomes an entry point. Webshells often have names that blend in, such as cache.php, thumb.php or a random name inside a year/month folder, and sometimes a forged modification date to make them look old.
The uploads folder of a normal WordPress site should not contain any PHP files. Exceptions are rare, involve only a few plugins, and should be checked one by one.
Finding executable files
The search has to cover every extension the server might interpret as PHP, not just .php:
find wp-content/uploads -type f \( -iname '*.php*' -o -iname '*.phtml' \
-o -iname '*.phar' -o -iname '.htaccess' -o -iname '.user.ini' \)
The list should also include .htaccess and .user.ini files: an attacker creates them to re-enable PHP execution or to load a file on every request through auto_prepend_file. And a file with an image extension is not necessarily harmless: it may contain PHP code that another file includes.
Blocking PHP execution
With Apache, and with LiteSpeed, which reads its .htaccess files, all you need is an .htaccess file inside the uploads folder:
# wp-content/uploads/.htaccess (Apache 2.4 and LiteSpeed)
<FilesMatch "\.(?i:php[0-9]?|phtml|phar)$">
Require all denied
</FilesMatch>
With NGINX the rule goes in the virtual host configuration, and it must appear before the generic block that passes PHP files to PHP-FPM, because NGINX uses the first regular expression that matches:
location ~* ^/wp-content/uploads/.*\.(php[0-9]?|phtml|phar)$ {
deny all;
}
After the change, it is worth actually testing the result: create a test PHP file in uploads, request it from the browser and check that the response is a 403 error and not the execution of the code. Then delete the test file.
What the block does not do
Blocking direct execution is an important measure, but it has precise limits. It does not delete webshells that are already there, which still have to be found and removed. It does not stop a PHP file in another folder from including a file hidden in uploads, because the inclusion happens inside PHP and never goes through the web server. And if the rule lives in an .htaccess that the site user can modify, an attacker who already has write access can remove it: for this reason, where possible, it is better to put it in the server configuration.
The block should therefore be seen as one more layer, alongside plugin updates, correct permissions and file monitoring. If you find a suspicious file and do not know what it does, do not open it in the browser: write to us and we will analyze it without running it.