Working out what you have
You found PHP files in wp-content/uploads
A few of them belong there and were written by plugins you installed. Everything else is a shell or a dropper that arrived through a hole in an upload handler. One listing and three checks separate the two, and none of the checks involve running the file.
What a PHP file under uploads means
wp-content/uploads is the one directory on a WordPress site
that the web server has to keep writable, because that is where your
media goes. It is also served straight to the public. A directory that
accepts writes and executes what it is given is the most valuable place
on the filesystem to an attacker, and it is the first place a shell lands.
The advice you will find everywhere is that any PHP file under uploads is
malware. That is wrong often enough to cause damage. Plugins that keep a
data directory there write their own files into it, and a reader who
deletes every hit from a find command spends the evening
putting plugin files back and still has the shell.
What core writes
Nothing. On WordPress 7.0.4 the uploads root and the dated year and month
directories beneath it contain no index.php. The 28-byte
files reading <?php // Silence is golden. that core does
ship sit at wp-content/index.php,
wp-content/plugins/index.php and
wp-content/themes/index.php, and nowhere under uploads.
So every PHP file you find under uploads was put there by something other than WordPress. That something is either a plugin you installed or a person you did not invite.
What plugins write
A plugin that stores data under uploads usually drops a guard file beside it, so that a server with directory listing turned on shows nothing. These are the files that make the crude rule fail. Four real examples, all read off disk on 18 August 2026.
| Path | Written by | Size | Contents |
|---|---|---|---|
uploads/wpforms/index.php | WPForms Lite 2.0.0.5 | 99 bytes | Two header() calls sending 404, nothing else |
uploads/wpforms/.htaccess | WPForms Lite 2.0.0.5 | 737 bytes | SetHandler none and RemoveHandler, between BEGIN and END markers |
uploads/<plugin>/index.php | Many plugins | 28 bytes | The same "Silence is golden" line core uses elsewhere |
uploads/wp-file-manager-pro/fm_backup/.htaccess | WP File Manager 8.0.4 | 66 bytes | Denies .zip and .gz only |
Counts vary more than you would expect. On the same day, a production site running fifteen plugins had zero PHP files anywhere under uploads. Two development installs had six and seven, and every one of those was a guard file or a generated configuration cache. Both pictures are normal. What is not normal is a PHP file with a body: a guard file is under a hundred bytes and does nothing.
Legitimate does not mean effective. The WPForms guard uses
SetHandler none, and on Apache 2.4.67 with PHP-FPM that
stops execution and then serves the file's source verbatim with a 200.
Requesting the directory returned the raw text of their own
index.php, so its 404 header never fired. A guard file is
evidence a developer thought about the problem, not proof the directory
is closed.
List them, then confirm one
Start from the command line if you have SSH, and from your host's file manager if you do not. Everything here works either way, though the listing is much faster in a shell.
Every PHP file, oldest first
find wp-content/uploads -type f -name "*.php" -printf "%T+ %8s %p\n" | sort Sorting by modification time is what makes the output readable. A plugin's guard files were all written in the same second, during the same install, and they cluster. A file dropped by an attacker sits on its own, usually months away from everything around it, and it is the row your eye lands on. The clean production site above printed nothing at all, which is the fastest possible answer.
Two cautions on the timestamps. An attacker with shell access can set
them to whatever they like, so a plausible date is weak evidence in the
file's favour while an implausible one is strong evidence against. And
the extension is not the whole story: a payload can sit in a file named
.jpg, so once you have a suspect timestamp, widen the same
command by dropping -name "*.php" and read what else was
written in that minute.
Check one: read it
Open the file in a text editor. Placing it is enough; you do not have to
follow what it does. Under a hundred bytes with one comment or a pair of
header() calls is a guard file.
Readable PHP that returns an array of configuration is a plugin's
generated cache, and the array will name the plugin. Anything dense,
encoded, or built out of string concatenation is neither, and the
guide to reading eval
and base64_decode in a PHP file turns that back into something you
can judge without executing it.
Check two: does anything include it?
A legitimate data file under uploads was written by code that also reads it, so the plugin that owns it will name it somewhere. Search for the filename across the whole install.
grep -rn --include="*.php" "the-filename-without-extension" wp-content/ wp-includes/ wp-admin/ A hit inside a plugin directory tells you which plugin owns the file and usually shows you the line that builds the path. No hit anywhere means nothing on your site loads it, which for a data file is the wrong answer, because a data file nobody reads has no reason to exist. A shell does not need to be included by anything: it is reached directly over HTTP.
Check three: does the directory run PHP at all?
This is the check that decides how urgent the rest of the page is, and it is the one people get wrong by requesting the suspicious file in a browser. Do not do that. Fetching a shell over HTTP executes it on your server, and whatever it does on a request, it does on yours.
Test with a file you wrote instead. Put a single harmless line in the uploads root, ask for it, then delete it.
printf '%s' '<?php echo "php-runs-here";' > wp-content/uploads/exec-test.php
curl -s https://example.com/wp-content/uploads/exec-test.php
rm wp-content/uploads/exec-test.php
On an unhardened install that prints php-runs-here with a
200, which is what it printed on the test server. That means any PHP file
in that directory is a working entry point for anyone who knows its name.
A 403, or the literal text <?php echo "php-runs-here";
coming back instead of the marker, means the directory is closed or half
closed, and the last section explains the difference.
Delete the test file. It is harmless, and it is also a file you do not want to be explaining to the next person who reads this directory.
The shapes, the names and the markers
Most of what lands in uploads falls into two shapes, and they want different responses from you.
A full shell is a file manager with a web interface. It lists directories, edits files, uploads more files, runs shell commands and often connects to the database. It is usually large, frequently packed into one encoded blob, and it almost always sits behind a password so that whoever finds it second cannot use it.
A dropper is small on purpose. Its whole job is to write
the next file, so its body is often a single move_uploaded_file
on $_FILES, or a file_put_contents of whatever
arrived in $_POST. Droppers survive cleanups because they are
short enough to hide in a directory nobody reads and boring enough not to
look like anything.
Named families and the strings that identify them
Sucuri's write-up of web shells, published in March 2026, names WSO, c99,
r57 and b374k as the families that keep turning up, and flags
popen and shell_exec as the giveaway for the
simpler ones. WSO is the same code that gets called FilesMan, and its
signature entry lists the strings that identify it:
-
$default_action = 'FilesMan';and$default_use_ajax = true;, near the top of the file. -
$auth_passholding an MD5 hash, which is the password gate. -
$default_charset = 'Windows-1251';, a leftover from where the family came from and a string no WordPress plugin has any reason to contain. -
A
preg_matchagainst$_SERVER['HTTP_USER_AGENT']that returns nothing to search engine crawlers, so the shell stays out of the index.
b374k is the other one you will meet by name, because it ships as a
packer: the author's build tool wraps the shell in
base64_encode and then one of gzdeflate,
gzencode or gzcompress, so two copies of the
same shell share no readable text. Sucuri documented that packer in 2020
and the packed shape has held since. Grepping for the family names finds
the unpacked copies only, which is why the shape of the file matters more
than any single string in it.
Three markers that older guides still tell you to grep for are dead on a
modern server. On PHP 8.2.31, create_function() no longer
exists, preg_replace with the /e modifier
returns null, and assert() given a string does nothing at
all. Finding one of those in a file tells you the file is old. It does
not tell you it currently runs.
The names they use
Filenames are chosen to survive a glance at a directory listing. The two patterns worth knowing:
- Impersonating core. Files called
wp-load.php,wp-cron.php,wp-mail.phporwp-settings.phpturning up under uploads. Those names are real, and they belong at the root of the install, never in a media directory. A core filename in the wrong directory is one of the few signals that needs no further investigation. - Random and forgettable. Eight hex characters, or a plausible
word plus digits. The wp2shell campaign, which two vendors documented
independently in July 2026, created plugin directories named
wp-content/plugins/wp2shell_<hex>, with observed valueswp2shell_6a5566a6,wp2shell_79b06a80andwp2shell_360866a8. It staged the plugin as a.zipunderwp-content/uploads/first, which is why an unexplained archive there deserves the same attention as an unexplained script.
The .htaccess that comes with them
Read every .htaccess under uploads, not only the PHP files.
An attacker who can write to the directory can also write the rules that
govern it, and the standard move is to make a harmless extension execute:
AddType application/x-httpd-php .jpg
AddHandler application/x-httpd-php .gif
SetHandler application/x-httpd-php
Any of those lines in an uploads directory turns an image into a script.
The same file often carries a Require all granted or an
Allow from all to undo hardening someone added earlier.
Wordfence recorded attackers uploading malicious .htaccess
files alongside the payload when the Ninja Forms File Upload flaw was
being exploited.
A double extension such as shell.php.jpg is the companion
trick, and it is worth knowing that it does not always work. On Apache
2.4.67 with PHP-FPM, a file with that name was served as plain text and
never executed, because the FPM handler is bound to names ending in
.php. It executes on servers still using
AddHandler application/x-httpd-php .php, and it executes
anywhere the attacker's own .htaccess made it. Treat it as
conditional rather than automatic, and delete it either way.
Removing one by hand
A shell has no original underneath it. Nothing in the file needs preserving, so this is a deletion rather than a cleanup, and the order matters: everything you want to learn from the file has to be collected before it goes.
- Copy it off the server first. Somewhere outside the web root, or onto your own machine. It is the only record of what was done to your site, and you may want it when you work out how it arrived.
- Write down its modification time. That timestamp is the search key for the rest of the infection. Anything written in the same minute arrived in the same event.
- Find everything written alongside it. Run the sweep across the
whole install rather than only uploads, because the drop point and the
persistence are rarely in the same directory.
bash find . -name "*.php" -newermt "-7 days" -printf "%T+ %p\n" | sort - Read every .htaccess on the way.
find wp-content/uploads -name ".htaccess"lists them. Delete any rule that maps a non-PHP extension to a PHP handler. Leave the plugin-written ones alone once you have matched them against the plugin that owns the directory. - Confirm nothing includes it, then delete it. The grep from the previous section, run one more time. A file nothing includes and nothing owns can go. If something does include it, you are looking at a modified plugin file rather than a dropped one, and that needs cleaning instead of removing.
- Check what it left behind. A shell that ran for any length of time may have created an account to come back through. The guide to an administrator account you did not create covers finding the ones the user list hides, which is the common case.
- Rotate credentials, then re-scan. WordPress administrators, FTP and SFTP, the hosting control panel, and the database user. Scanning before the passwords change measures how quickly the attacker can put the file back.
If the same file reappears within hours, the malware came back after the cleanup works through the persistence mechanisms in the order they usually fire. If you are not yet certain the file is malicious, the three checks that tell a real detection from a false flag settle it before you delete anything.
Stop the directory executing PHP
Deleting the file closes nothing. Two things have to change: the hole the file arrived through, and the property of the directory that made it worth uploading.
The hole is almost always an upload handler
A PHP file under uploads points at a specific class of bug rather than at
a stolen password, and the reason is in core. WordPress does not list
application/x-httpd-php among the types the media library
accepts, and the capability that skips that check,
unfiltered_upload, is granted only when
ALLOW_UNFILTERED_UPLOADS is defined and true in
wp-config.php, and on multisite only to a super admin. So a
stolen administrator password does not produce this symptom. It produces
a new plugin directory or an edited theme file, because those are the
tools an administrator has.
What does produce it is a plugin or theme that handles uploads itself and
trusts the filename. Patchstack's write-up of the class points at the two
places these live: functions hooked to wp_ajax_nopriv_,
which unauthenticated visitors can call, and REST endpoints reading
WP_REST_Request::get_file_params() without a permission
callback that means anything.
A worked example, because the shape repeats. Ninja Forms File Upload
carried an arbitrary file upload flaw tracked as CVE-2026-0740, scored
9.8, affecting every version up to and including 3.3.26 across roughly
50,000 installs. It was reported on 8 January 2026 and fully patched in
3.3.27 on 19 March 2026. Wordfence recorded attackers using it to upload
PHP webshells disguised as PDF and image files, minimal uploaders, and
malicious .htaccess files. Every part of that list is
something you would find under uploads.
So: update every plugin and theme, and delete the ones you are not using rather than leaving them deactivated. A deactivated plugin's files are still on disk and its endpoints can still be reachable.
Apache
Create wp-content/uploads/.htaccess with this, or add it to
the file that is already there.
<FilesMatch "\.(?i:php|phar|phtml|php[0-9])(\.|$)">
Require all denied
</FilesMatch>
Measured on Apache 2.4.67 with PHP-FPM 8.2.31, against a marker file that
prints php-runs-here. Without the rule the marker returned
200 and ran. With it, exec-test.php, a copy of it renamed to
end in .PHP, and a double-extension
exec-test.php.jpg all returned 403, and a plain
.txt file still returned 200. The trailing
(\.|$) is what catches the double extension, and the
(?i:...) is what catches the uppercase one.
Require all denied needs Apache 2.4. If you are on something
older, or you want one file that works on both, the two-branch form is
what plugin authors ship:
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Deny from all
</IfModule>
Only the first branch was exercised on the test server, since Apache 2.4
loads mod_authz_core. The second is there for 2.2 and is
quoted from a plugin that ships it, not from a measurement.
Do not use php_flag engine off. It means something under
mod_php and nothing under PHP-FPM, where the surrounding
<IfModule mod_php.c> block is skipped because the
module is not loaded. On the test server that snippet left the marker
file returning 200 and executing. SetHandler none with
RemoveHandler is better and still imperfect: it stopped
execution and then served the file's source as the response body, which
hands an attacker the contents of anything PHP in that directory.
nginx
nginx never reads .htaccess. This goes in the server block,
above the location that passes PHP to FPM, because nginx
picks the first matching regex location.
location ~* /wp-content/uploads/.*\.(?:php|phar|phtml|php[0-9])(?:\.|$) {
deny all;
}
Measured on nginx 1.31.3 with PHP 8.2.33-FPM. With the block in place,
a .php and a .php.jpg under uploads both
returned 403, a .txt returned 200, and the site's own root
index.php kept working. Reload with
nginx -t && nginx -s reload so a typo fails at the
test rather than at the restart.
The Apache snippet is not a fallback here. Dropping that same
.htaccess into the uploads directory of the nginx test
server left the marker file returning 200 and executing, and a request
for /wp-content/uploads/.htaccess returned the rule text
with a 200. nginx ignores the file and then serves it, which publishes a
description of the hardening you believe you have without applying any
of it.
Test it, do not trust it
Apache reads .htaccess only where the server configuration
allows it, and some hosts turn that off. LiteSpeed and OpenLiteSpeed read
it; nginx does not. Managed hosts sometimes apply their own rules above
yours. None of that is visible from inside WordPress, so use the marker
file from the earlier section: write it, request it, confirm the 403,
delete it. Thirty seconds of that beats any amount of reading about what
your host is supposed to do.
One structural caveat. Uploads has to stay writable, so an attacker who
still has write access can delete or replace your .htaccess
as easily as they dropped the shell. Where you have access to the server
configuration, put the rule there instead, and leave the
.htaccess as the second layer. Where you do not, the rule is
still worth having, and it is still not a substitute for closing the hole
the file came through.
Questions
- Is every PHP file in wp-content/uploads malware?
- No, and acting as if it were will break a working site. Plugins that keep a data directory under uploads drop their own guard files into it, usually an empty index.php. On three installs checked on 18 August 2026 the counts were zero, six and seven, and every file on the two installs that had any was a guard file or a generated config cache. The useful rule is that each file has to be explainable, not that each file has to be gone.
- Does WordPress itself put an index.php in the uploads folder?
- No. On WordPress 7.0.4 the uploads root and the dated year and month directories under it contain no index.php at all. The 28-byte files reading "Silence is golden" that core does ship live at wp-content/index.php, wp-content/plugins/index.php and wp-content/themes/index.php. Anything of that shape under uploads was written by a plugin, not by core.
- How did a PHP file get uploaded if only administrators can upload?
- Almost always through a plugin or theme upload handler rather than the media library. WordPress does not list application/x-httpd-php in its allowed upload types, and the capability that skips that check is only granted when ALLOW_UNFILTERED_UPLOADS is defined in wp-config.php. So even a stolen administrator password does not get a .php through the media library. It gets the plugin installer and the file editor instead, and those write into wp-content/plugins and wp-content/themes.
- Can I just open the file in my browser to see what it does?
- No. Requesting the URL executes the file on your server, which is the one thing you are trying to avoid. Read it in a text editor. If you need to know whether the directory runs PHP at all, write a one-line file of your own that prints a marker, request that, and delete it afterwards.
- Will blocking PHP in uploads break my site?
- It breaks anything that was serving PHP from that directory. On the installs checked for this page nothing was: every PHP file under uploads was a guard file or a generated cache that no request ever reaches. The category worth checking on your own site is anything that writes PHP under uploads at runtime, such as a cache layer or a page builder. Apply the rule, then load the front page, the admin and any form that handles file uploads before you walk away.
- I put the rule in .htaccess and PHP still runs. Why?
- Three common reasons. Your server is nginx, which never reads .htaccess. Your host runs Apache with AllowOverride set so that the file is ignored. Or the rule you used was php_flag engine off, which only does anything on mod_php and is skipped entirely under PHP-FPM. Test with a marker file rather than assuming, and if .htaccess turns out to be inert, the rule has to go in the server configuration.