Bali, Indonesia
Insights

Reinstalled WordPress Core After a Hack, but the Malware Is Still in wp-content

October 30, 2026
0:00
A common first move after a WordPress hack is to click Reinstall Now under Dashboard > Updates, or to pull a fresh copy of core with WP-CLI, and call the site clean. For a day or two everything looks fine. Then the spam redirects, the injected JavaScript, or the unknown administrator account comes back. The assumption is that the infection lives in WordPress core. Usually it doesn't, and a core reinstall is designed to leave the places attackers prefer untouched.
WordPress core is the set of files in the official package: the wp-admin and wp-includes directories plus the PHP files in the site root, such as wp-login.php, wp-settings.php, and index.php. Reinstalling from the dashboard or running wp core download --force overwrites those known files with clean copies. Two details explain why that falls short. Overwriting is not the same as cleaning. The reinstall writes the files that belong to the package. It does not empty the directories first. A backdoor dropped as an extra file, such as a randomly named PHP file inside wp-includes, survives because no file in the package has that name. It keeps working because the web server will still execute it when someone requests its URL. Several locations are excluded on purpose so a reinstall doesn't destroy the site: wp-config.php, the whole wp-content directory, .htaccess, and the database. That is the right design for routine updates. It also happens to list exactly where persistent malware tends to sit. At the support desk, this is the part that trips people up most. The official reinstall ran and the core files look clean, so the owner concludes the server or the host must be reinfecting the site. More often the infection never left their own account.
Plugins and themes in wp-content are the obvious place. Attackers either modify files inside a legitimate plugin or add a fake plugin folder with a plausible name. A theme's functions.php is another favorite because it runs on every page load. Deactivating a plugin or switching themes doesn't make its files harmless. A PHP file inside an inactive plugin or theme can usually still be requested directly over HTTP. A webshell (a PHP script that lets the attacker run commands through the browser) doesn't need WordPress to load it. The uploads directory, wp-content/uploads, is meant for media. A PHP file there deserves a close look. Some plugins do write legitimate PHP files into uploads, so check what owns a file before deleting it. Uploads is also one of the few directories the web server can always write to, which is why attackers like it. Must-use plugins live in wp-content/mu-plugins. WordPress loads every top-level PHP file in that folder automatically on every request. They appear under a separate Must-Use tab rather than the main plugin list, and they can't be deactivated from the dashboard. That makes the folder an ideal hiding spot, and many site owners have never opened it. Drop-ins are special files placed directly in wp-content, such as advanced-cache.php, object-cache.php, and db.php. Core loads them very early, before regular plugins, under conditions that depend on the file. A tampered drop-in runs before any security plugin has a chance to react. Configuration files sit outside core entirely. An extra include line in wp-config.php can pull in a payload stored somewhere unrelated. Rewrite rules in .htaccess can send search engine or mobile visitors to spam. A .user.ini or php.ini with auto_prepend_file makes PHP run a chosen file before every script on the site. If injected redirects are part of the picture, auditing .htaccess files for injected redirects covers that side in more depth. The database is where many people stop looking, and a file-level reinstall can't touch it at all. Common findings include:
  • Administrator accounts nobody created
  • Script tags injected into posts or widget content
  • A changed siteurl or home value
  • Scheduled cron events that call attacker code
  • Encoded payloads stored in wp_options
That last one ties into a familiar performance problem. Options marked as autoloaded are read on every request. When a small loader planted in a plugin decodes and runs a large blob from an autoloaded option, the site gets slower and stays infected. An unexpectedly large autoloaded option with a meaningless name is worth inspecting even if you only came looking for the cause of a slow admin.
Reinfection usually has one of two causes, and often both are present. The first is persistence that was missed: one of the locations above, a rogue admin account, or a cron event that rewrites the infected file every hour. If an infected file reappears shortly after you delete it, look for whatever is recreating it rather than deleting it again. The second is an entry point that is still open: an outdated plugin with a known vulnerability, a reused admin password, leaked SFTP or control panel credentials, or a second site under the same hosting account that is still infected and has write access to the first. A clean file set doesn't help if the door that let the attacker in is still open. There is also a confusing case where a backdoor you already deleted keeps answering requests. That is usually PHP OPcache serving the old compiled code, explained in why deleted webshells still run until OPcache is cleared.
Before changing anything, take a full copy of the current files and database and store it outside the web root. You will want it to compare files and to trace how the attacker got in. The commands below only read data. Run them from the WordPress root as the site's own user, not as root. Start with checksums. They tell you what differs from the official packages:
BASH
# Compare core files with official checksums
# (newer WP-CLI versions also flag extra files in core directories)
wp core verify-checksums

# Compare wordpress.org plugins with their published checksums
wp plugin verify-checksums --all
Next, look at the locations checksums don't cover:
BASH
# PHP files in uploads are usually suspicious
find wp-content/uploads -type f -name '*.php'

# Must-use plugins and drop-ins load without appearing in the normal plugin list
ls -la wp-content/mu-plugins/
ls -la wp-content/ | grep -E 'advanced-cache|object-cache|db\.php'

# Per-directory PHP config that prepends a file to every request
grep -rn 'auto_prepend_file' --include='.user.ini' --include='php.ini' --include='.htaccess' .
Finally, check the database. WP-CLI boots WordPress to answer these queries, which means it also executes site code. The skip flags reduce how much of that code runs. wp-config.php still loads either way.
BASH
# Administrator accounts: look for ones nobody recognizes
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered --skip-plugins --skip-themes

# Scheduled events: look for hooks that no installed plugin explains
wp cron event list --skip-plugins --skip-themes

# Largest autoloaded options: unexpected large blobs deserve a look
wp option list --autoload=on --fields=option_name,size_bytes --orderby=size_bytes --order=desc --skip-plugins --skip-themes | head -n 20
Don't rely on file modification times. Attackers often reset timestamps to match neighboring files, so a file that looks old can still be new.
The order matters. Removing malware while the entry point is still open just restarts the cycle.
  1. Keep the isolated copy of the infected site from the previous step for comparison and log review.
  2. Find and close the entry point. Update or remove vulnerable plugins and themes, and check access logs for requests to unfamiliar PHP files.
  3. Replace core, every plugin, and every theme with fresh copies from their original sources instead of editing infected files in place. Delete plugins and themes you don't use.
  4. Inspect what can't simply be replaced: uploads, mu-plugins, drop-ins, wp-config.php, .htaccess, and any .user.ini.
  5. Clean the database: remove unknown admin accounts, fix injected content, and delete cron events and options that no legitimate plugin owns.
  6. Rotate every credential: WordPress passwords, the database password, SFTP and control panel logins, and API keys. Also regenerate the salts in wp-config.php, which logs out every existing session.
  7. Clear OPcache or restart PHP, depending on your stack, so no stale compiled code keeps running.
  8. Check the same locations again over the following days. A file that reappears points to persistence you haven't found yet.
If there are other sites under the same hosting account, give them the same treatment. One infected neighbor is enough to undo the whole cleanup. Treat a core reinstall as one step in the cleanup, not the cleanup itself. It proves that wp-admin, wp-includes, and the root core files are clean. It says nothing about wp-content, the configuration files, or the database. Check those locations, replace what you can from clean sources, close the entry point, and rotate credentials. Doing all of that is what keeps the site clean.