2026年11月12日

2026年11月12日

Find and Remove WordPress Backdoors with grep and WP-CLI

This article is a localized edition of the Japanese original. Read the Japanese original

A backdoor is a hidden entrance an attacker plants so they can get back in later. Even if you delete defaced pages or spam, a remaining backdoor means the same damage returns after a while. This article explains how to find backdoors left on a WordPress site, remove them, and keep the attacker from getting in the same way again. The commands assume you connect to a Linux server over SSH and can use WP-CLI. The content is current as of October 2026.

Before you start: order of work and cautions

  • Save a full copy of the current files and database. Before deleting anything, make a copy you can use to investigate the cause or restore what you deleted by mistake. Keep the copy outside the public web directory
  • If you have a clean backup from before the compromise, restoring it is faster and more dependable. However, unless the backup predates the intrusion, you restore the backdoor along with it. Even after restoring, still do the checks and re-entry prevention in this article
  • Check the contents of each file you find before deleting it. The searches below only list "suspicious" items, and legitimate plugins and themes will also match
  • If you suspect the attacker gained root on the server (suspicious processes, modified system files, unfamiliar SSH keys, and so on), cleaning WordPress is not enough. Follow initial response steps when a server has been hacked
# Run in the WordPress directory (e.g. /var/www/html)
cd /var/www/html
tar czf ~/evidence-files-$(date +%Y%m%d).tar.gz .
wp db export ~/evidence-db-$(date +%Y%m%d).sql

Step 1: Verify core and plugin files against the official checksums

WP-CLI's verify-checksums compares your files with the checksums published by WordPress.org. It finds modified files and files that should not be there.

# WordPress core (wp-admin and wp-includes)
wp core verify-checksums

# Plugins distributed on WordPress.org
wp plugin verify-checksums --all

Here is how to read the results.

Output Meaning
Success: WordPress installation verifies against checksums. Core has not been modified
Warning: File doesn't verify against checksum: wp-includes/… The contents differ from the official file. Suspect tampering
Warning: File should not exist: wp-includes/… A file that is not in the official package. A common place for backdoors

Premium plugins and themes, wp-content/uploads, and wp-config.php are not covered by this check. Examine them in the next step.

Step 2: Look for suspicious PHP files

PHP inside uploads

wp-content/uploads holds images and other media and normally needs no PHP files. Check any PHP here first.

find wp-content/uploads -type f \( -name '*.php' -o -name '*.phtml' -o -name '*.phar' \)

Some plugins place an empty index.php or similar to prevent directory listings. Short files whose content is something like <?php // Silence is golden. are usually legitimate.

Recently changed PHP files

find . -type f -name '*.php' -ctime -14 -printf '%CY-%Cm-%Cd %CH:%CM %p\n' | sort

-ctime -14 lists files whose attributes changed within the last 14 days. Attackers sometimes set a file's modification time (mtime) back to an old date, but ctime cannot be reset by ordinary means. Plugin updates also change it, so compare against the dates you updated.

Search for commonly used code patterns

# Running obfuscated code (code hidden with base64 and the like, executed with eval)
grep -rlE --include='*.php' 'eval\s*\(\s*(base64_decode|gzinflate|gzuncompress|str_rot13)\s*\(' .

# Executing values sent from outside as is (typical of web shells)
grep -rlE --include='*.php' '(eval|assert|system|exec|shell_exec|passthru|popen|proc_open)\s*\(\s*\$_(POST|GET|REQUEST|COOKIE|SERVER)' .

# Executable files with non-.php extensions in the public directory, and PHP files starting with a dot
find . -type f \( -name '*.phtml' -o -name '*.phar' -o -name '.*.php' \)

These only list files that match typical patterns; legitimate plugins may match, and cleverly obfuscated backdoors may not. Open each file you find and check the following.

  • Does it decode and execute a long unreadable string (base64 or similar)?
  • Is it in a place that belongs to no plugin or theme, or does its name imitate a legitimate file (a name similar to wp-config-sample.php, for example)?
  • Does a file with the same name exist in the official package?

Entry points that load code

Also check the entry points used to load the backdoor itself.

# Plugins loaded without activation (mu-plugins) and drop-ins
wp plugin list --status=must-use
wp plugin list --status=dropin
ls -la wp-content/mu-plugins/

# Whether PHP settings load another file before every request
find . -name '.user.ini' -o -name 'php.ini'
grep -rn 'auto_prepend_file\|auto_append_file' . --include='.user.ini' --include='php.ini' --include='.htaccess'

Also open wp-config.php and every .htaccess, and check for unfamiliar include or require lines and redirect rules.

Step 3: Find web shells in the access log

Web shells are usually operated through POST requests, so count the files that receive POSTs.

# Apache (for Nginx, /var/log/nginx/access.log)
grep '"POST ' /var/log/apache2/access.log \
  | grep -vE 'wp-login\.php|wp-admin/admin-ajax\.php|wp-cron\.php|xmlrpc\.php|wp-json' \
  | awk '{print $7}' | sort | uniq -c | sort -rn | head -20

If you see POSTs to unfamiliar PHP files under wp-content/uploads/ or deep inside a plugin, examine those files. You can also trace in the log what else the same IP address accessed.

Step 4: Check the database and users

# List administrators (look for unknown or recently registered users)
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered

# Registered cron events (look for unfamiliar hook names)
wp cron event list --fields=hook,next_run_relative,recurrence

# Check whether the options table contains executable code
wp db query "SELECT option_name, LEFT(option_value, 120) FROM $(wp db prefix)options WHERE option_value LIKE '%base64_decode%' OR option_value LIKE '%eval(%' LIMIT 20"

The table name prefix (wp_ and so on) differs by site, so the query gets it from wp db prefix. Legitimate plugins sometimes store encrypted values or code fragments in options, so use option_name to identify which plugin an entry belongs to before deciding.

For an administrator you do not recognize, move their posts to your own user and then delete them (7 is the ID of the user to delete and 1 is the ID of the user who takes over the posts, as an example).

wp user delete 7 --reassign=1

Step 5: Remove and reinstall

Delete the backdoor files

Delete the backdoor files you examined and confirmed. Do not forget the entry points (files in mu-plugins, .user.ini, lines added to .htaccess).

Overwrite WordPress core

Overwrite WordPress core with the official files. --skip-content leaves wp-content untouched. If you use the Japanese edition, also add --locale=ja.

wp core download --force --skip-content --version=$(wp core version)
wp core verify-checksums

Overwriting does not remove extra files, so delete the files reported as File should not exist by hand.

Reinstall plugins and themes

Delete plugins and themes you do not use, and reinstall the ones you keep from the official source. Plugins from WordPress.org can be overwritten with wp plugin install plugin-slug --force. For premium ones, download the latest version from the vendor and reinstall it.

Scan again

Run Steps 1 to 4 again and confirm nothing is found.

Step 6: Block PHP execution in uploads

Make sure PHP placed in the upload directory cannot run.

On Apache, add the following to wp-content/uploads/.htaccess.

<FilesMatch "\.(php|phtml|phar)$">
    Require all denied
</FilesMatch>

Order Deny,Allow and Deny from all are Apache 2.2 syntax. On Apache 2.4, use Require all denied. Some servers do not allow Require in .htaccess and return a 500 error, so after adding it, confirm that site images still display and that the URL of a PHP file inside uploads returns 403.

On Nginx, put it before the location ~ \.php$ block that handles PHP, because regex location blocks are evaluated from top to bottom.

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

By default, WordPress does not allow PHP files to be uploaded as media. This setting guards against files placed through another route, such as a plugin vulnerability.

Step 7: Change keys and passwords

Assume the attacker holds passwords and logged-in sessions, and change everything.

# Regenerate the authentication keys (salts) in wp-config.php. Everyone is logged out
wp config shuffle-salts

# Destroy sessions for all users, including administrators
wp user list --field=ID | xargs -n 1 wp user session destroy --all

Also change the following.

  • The WordPress passwords of all administrators (change them on each user's edit screen under Users)
  • The database password (after changing it, also update DB_PASSWORD in wp-config.php)
  • Passwords for the server control panel, FTP/SFTP and SSH. Also check ~/.ssh/authorized_keys for unfamiliar keys
  • Whether the server's cron (crontab -l, /etc/cron.d/) has unfamiliar jobs

Finally, update WordPress core, plugins and themes to the latest versions. The entry point is often a vulnerability in an outdated plugin or a leaked password, and unless you close it, the same thing happens again.

Summary

  • Before deleting anything, save a full copy of the files and database. If you have a clean backup, restoring it is fastest
  • Use wp core verify-checksums and wp plugin verify-checksums --all to find files that differ from the official ones and extra files
  • Look for PHP in uploads, PHP whose ctime changed recently, and patterns such as eval(base64_decode( or executing $_POST, then check their contents
  • Also check the loading entry points: mu-plugins, auto_prepend_file in .user.ini, .htaccess, and wp-config.php
  • Check administrators, cron and options, and delete unneeded administrators
  • Overwrite core with wp core download --force --skip-content and reinstall plugins from official sources
  • Block PHP execution in uploads, and lock the attacker out with wp config shuffle-salts, destroyed sessions and new passwords everywhere

Related articles:

Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)

お気軽にご相談ください

お見積りへ お問い合わせへ
▲