How to Clean a Self-Healing Malware Infection from Multiple WordPress Sites on a Shared cPanel Account
A complete, step-by-step guide: every phase explained, the commands you need, common mistakes to avoid, and what to watch out for.
- When multiple WordPress sites share one cPanel hosting account, a single malware infection can spread to all of them simultaneously.
- Simply deleting infected files is useless. This type of malware kit stores its payload in the database, in a cron job, in shell startup files, and in hidden dotfiles — any single surviving copy can re-infect every other site.
- What actually works: neutralise every loader on every site in one pass, delete the database seed, remove the persistence outside WordPress, delete fake admins, rotate every credential, and then run a second sweep for hidden leftovers.
- The original entry point is often impossible to determine with certainty. Logs typically show heavy brute-force traffic and a few unexplained successful logins, making stolen or guessed credentials the most likely culprit.
- Hosting support may decline to clean the account and instead offer a paid security plan. This guide walks through everything that can be done by hand from cPanel's Terminal.
Environment and Assumptions
- Shared cPanel hosting (CloudLinux-style jailed shell), PHP 8.2 / 8.3 via cPanel MultiPHP.
- cPanel Terminal available, and WP-CLI (
wp) installed. - All sites live under
~/public_html/<site>/(addon domains). - Placeholders used in this guide:
<site>= a site folder,<db>/<user>/<pass>= database details from that site'swp-config.php.
If you don't have a shell, getting SSH or Terminal enabled is step zero. Cleaning an infection like this without one is close to impossible.
The Symptoms
Here are the most common signs that this type of malware kit is active:
- 1"There has been a critical error on this website" on one or more sites.
- 2Theme files changing on their own.
- 3Strange hidden files (
.sc_*,.gk_*), fake plugins (likelucid-toolkit-lab), and roguemu-pluginscontaining malware that keep coming back minutes after deletion on every site. - 4Zero-byte files with random names created every few minutes.
- 5The hosting scanner (e.g. Monarx) shows "No action required, all threats remediated" while listing tens of thousands of detected files — and new payload zip files keep appearing.
Ground Rules Before You Touch Anything
- Take a full backup first (cPanel > Backup: home directory plus all databases). It is infected, so it is for reference and rollback only. Never restore it.
- Never
rma suspicious file.mvit. Make a quarantine folder outside the web root:
If a move turns out to be wrong, you can undo it.mkdir -p ~/quarantine_manual mv <suspicious-file> ~/quarantine_manual/ - Read before you act. Run every command in read-only mode first (a
find/grepthat only lists), then act. - Don't take the sites offline unless absolutely necessary. Cleaning with the sites live works if you neutralise the loaders in one pass (Phase 3).
- Put long scripts in a file, not in one pasted blob. Pasting a long command into the browser terminal can silently mangle it (a lost space between
-oand-namemeans afindcan silently match nothing):
Note that thecat > ~/script.sh <<'EOF' ...script... EOF bash ~/script.shcat > ... <<'EOF'line only creates the file. Nothing runs until you runbash ~/script.sh.
Diagnose
1.1 Read the Real Error
Turn on logging in wp-config.php (above the "stop editing" line):
PHPdefine('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);
Then read wp-content/debug.log (or the error_log in the site folder). Two common error types in infected sites:
- A theme calling a function that no longer exists (theme files have been altered or removed).
Cannot redeclare <random-function>() (previously declared in wp-content/advanced-cache.php)from a plugin that was never installed.
A randomly named function declared in two places means code has been planted. That is the moment to stop treating it as a plugin bug and start treating it as a hack.
1.2 Look at the Suspicious File (Read It, Don't Run It)
Bashhead -c 3000 wp-content/advanced-cache.php
advanced-cache.php should only exist if you run a caching plugin. An infected version is typically ~44 KB of obfuscated PHP — function names built from string pieces and hex escapes ("\x66\x75\x6e..."). The structure typically reveals:
- It is loaded on every request (WordPress loads this drop-in early when
WP_CACHEis true). - It checks whether a fake plugin (
lucid-toolkit-lab, a name that doesn't exist in any real plugin directory) is present inmu-plugins/orplugins/. - If it isn't, it rebuilds it from four different sources: a hidden folder in
wp-content, server shared memory, a random-hex.zipfile, and a row in the databaseoptionstable. - It also injects blocks (
SC_TH_BEGIN ... SC_TH_END) into every theme'sfunctions.php, and creates small timestamp/lock files (.gk_*,.sd_*,.wr_*,.rd_*,.q_*,.pv_*) to coordinate itself.
This explains why "files keep coming back": deleting files cannot work while the thing that recreates them is still alive.
1.3 Decode Anything That Looks Encoded, Safely
Malware often hides a command in base64. Print it, never pipe it to a shell:
Bashecho '<the long base64 string>' | base64 -d
Measure the Scope
2.1 Search the Whole Account for the Kit's Markers
Bashcd ~
grep -rlE "SC_ADV_BEGIN|SC_TH_BEGIN|SC_DD_BEGIN|SC_TH_LD_|lucid-toolkit-lab" . --include=*.php 2>/dev/null | head -100
find ~ -name "wp-config.php" 2>/dev/null | wc -l # how many WordPress installs
A typical initial result: 27+ infected PHP files across 15+ locations, in themes' functions.php, advanced-cache.php, db.php and fake plugin folders.
2.2 Per-Site Count (and the Proof That Sites Re-Infect Each Other)
Bashgrep -rlE "SC_ADV_BEGIN|SC_TH_BEGIN|SC_DD_BEGIN|SC_TH_LD_|lucid-toolkit-lab" ~ --include=*.php 2>/dev/null \
| grep -v wflogs | awk -F/ '{print $5}' | sort | uniq -c | sort -rn
Run this twice a few minutes apart. If the counts change (one site goes from 4 infected files to 2, another from 2 to 4), that is proof that infected sites are writing into clean ones. You cannot clean this one site at a time.
wflogs/config-synced.php is Wordfence's own file and matches some patterns. It is a false positive. Leave it alone.
2.3 Don't Trust the Scanner Dashboard
Scanners that quarantine files are not killing the dropper. Even if the dashboard says "remediated", new payload zips may still be created minutes later. Always verify manually.
Neutralise Every Loader on Every Site in One Pass
3.1 Common First Mistake: Process Limit
A cleanup script that deletes advanced-cache.php, the fake plugin, and hidden files may reduce the infection significantly — but running three passes in one shell loop will hit the host's process limit:
clean2.sh: fork: retry: No child processes
Shared hosting limits the number of processes. Avoid nested for loops that launch find or grep hundreds of times. Prefer a few find ... -exec / xargs commands.
3.2 The Script That Works (clean3.sh)
Bashcat > ~/clean3.sh <<'EOF'
#!/bin/bash
cd ~/public_html
# hidden dropper folders
find . -path "*/wp-content/*" -name ".sc_*" -prune -exec rm -rf {} + 2>/dev/null
# hidden marker/lock files and the fake plugin
for p in ".gk_*" ".kk_*" ".swm_*" ".bt_*" ".sd_*" ".wr_*" ".rd_*" ".q_*" ".pv_*" ".gl_*" "lucid-toolkit-lab*"; do
find . -path "*/wp-content/*" -name "$p" -prune -exec rm -rf {} + 2>/dev/null
done
# random-hex zip payloads
find . -regextype posix-extended -regex '.*/wp-content/(.*/)?[0-9a-f]{8}\.zip' -delete 2>/dev/null
# loaders: only if they carry the kit's marker
grep -rlE "SC_ADV_BEGIN" . --include=advanced-cache.php --include=db.php 2>/dev/null | xargs -r rm -f
# injected code in themes' functions.php
grep -rlE "SC_TH_BEGIN|SC_DD_BEGIN|SC_TH_LD_" . --include=functions.php 2>/dev/null \
| xargs -r perl -0pi -e 's{/\* SC_(TH|DD)_BEGIN:.*?/\* SC_\1_END:\S+ \*/}{}gs; s{//SC_TH_LD_[a-f0-9_]+\r?\n[^\n]*\z}{}'
echo files-done
EOF
bash ~/clean3.sh
Important notes:
- Only delete
advanced-cache.php/db.phpif they contain the marker. Legitimate caching plugins and Elementor both ship harmless files with these names. An earlyfind -name db.phplisting will be full of false alarms. - After the
perledit, runphp -l <file>on eachfunctions.phpto confirm it still parses. If a theme only works because of the injected code, replace the theme from its official source. - In
wp-config.php, setdefine('WP_CACHE', false);so no cache drop-in is loaded while you clean:Bash
sed -i -E "s/define\( *['\"']WP_CACHE['\"'], *true *\)/define('WP_CACHE', false)/I" <site>/wp-config.php - Back up files you modify before touching them. Keep a copy of every infected file in a folder outside the web root.
3.3 If Files Still Reappear After This Pass
That means a seed is still alive somewhere. The next step is to find it.
Find the Seed — the Database
The dropper's code typically references a query against the options table. Here is how to find those rows.
4.1 Per-Site, Read-Only: Which options Rows Are Huge or Look Wrong?
Get the DB credentials from wp-config.php without printing them:
Bashcfg=<site>/wp-config.php
g(){ grep -E "define\( *['\"']$1['\"']" "$cfg" | head -1 | sed -E "s/.*,\s*['\"']([^'\"']*)['\"].*/\1/"; }
db=$(g DB_NAME); us=$(g DB_USER); pw=$(g DB_PASSWORD); ho=$(g DB_HOST)
px=$(grep -E '^\$table_prefix' "$cfg" | sed -E "s/.*=\s*['\"']([^'\"']*)['\"].*/\1/")
MYSQL_PWD="$pw" mysql -h "$ho" -u "$us" -N -e \
"SELECT option_name, LENGTH(option_value) FROM \`$db\`.\`${px}options\` WHERE LENGTH(option_value)>100000 OR option_name LIKE 'sc\_%' ORDER BY 2 DESC"
Use MYSQL_PWD rather than -p<password> so the password doesn't appear in the process list.
What infected sites typically show:
- A large row (~150 KB to 600 KB) with a random 10 or 12-character hex name.
- Several
sc_*rows used as bookkeeping (sc_payload_persistent,sc_own_manifest,sc_persist_manifest,sc_canary,sc_c2_cache,sc_last_c2,sc_last_rpc,sc_fetch_logand so on). The namessc_c2_cache/sc_last_c2suggest the kit talks to a remote command-and-control server — treat all site credentials as exposed. - Random "word_word" named rows, ~100-110 KB (for example
abcde_fghij).
Legit large rows to leave alone: page-builder and library caches (elementor_remote_info_library, astra_*, rs-templates, rs-library, w3tc_minify, fw_google_fonts, font-awesome-releases), anything with _transient_ in the name, two_cache_structure_*, limit_login_logged, extend_builder_theme*. When in doubt, search the option name before deleting.
4.2 Common Mistake: Regex Too Narrow
A first sweep that looks for names matching only ^[0-9a-f]{10}$ may miss 12-character hex names and word_word names that also carry the payload. Widen the pattern to catch all variations.
4.3 Back Up the Rows, Then Delete Them
Bashcat > ~/dbclean.sh <<'EOF'
#!/bin/bash
cd ~/public_html
mkdir -p ~/db_backup
for cfg in $(find . -name wp-config.php 2>/dev/null); do
g(){ grep -E "define\( *['\"']$1['\"']" "$cfg" | head -1 | sed -E "s/.*,\s*['\"']([^'\"']*)['\"].*/\1/"; }
db=$(g DB_NAME); us=$(g DB_USER); pw=$(g DB_PASSWORD); ho=$(g DB_HOST)
for t in $(MYSQL_PWD="$pw" mysql -h "$ho" -u "$us" -N -e "SHOW TABLES FROM \`$db\` LIKE '%options'" 2>/dev/null); do
W="option_name LIKE 'sc\_%' OR (option_name REGEXP '^[0-9a-f]{12}$' AND LENGTH(option_value)>100000) OR (option_name REGEXP '^[a-z]{4,6}_[a-z]{4,8}$' AND LENGTH(option_value) BETWEEN 100000 AND 110000)"
n=$(MYSQL_PWD="$pw" mysql -h "$ho" -u "$us" -N -e "SELECT COUNT(*) FROM \`$db\`.\`$t\` WHERE $W" 2>/dev/null)
[ "${n:-0}" -gt 0 ] || continue
f=~/db_backup/$(echo "$cfg$t" | tr -c 'a-zA-Z0-9' _).sql
MYSQL_PWD="$pw" mysqldump -h "$ho" -u "$us" --no-create-info --where="$W" "$db" "$t" > "$f" 2>/dev/null
MYSQL_PWD="$pw" mysql -h "$ho" -u "$us" -e "DELETE FROM \`$db\`.\`$t\` WHERE $W" 2>/dev/null
echo "$cfg $t deleted $n"
sleep 1
done
done
echo db-clean-done
EOF
bash ~/dbclean.sh
Two details that matter:
SHOW TABLES ... LIKE '%options'is important because some databases have several options tables from old installs under different prefixes. The payload may be in one of them, not necessarily the live one.- Re-running the script later should print only
db-clean-done. If it prints adeleted Nline again, something on that site is still re-creating the rows.
After this step, files should stop coming back.
Look Outside WordPress
Files and database may be clean, but one more foothold is common: persistence at the shell level.
5.1 Cron, Startup Files, Processes, Shared Memory
Bashcrontab -l
grep -nE "base64|curl|wget|brt|SEED" ~/.bashrc ~/.profile ~/.bash_profile 2>/dev/null
ipcs -m # shared memory segments
ps -u $USER -o pid,etime,args # running processes
ls -la ~/.config ~/.config/*/ 2>/dev/null
What is typically found:
- A cron line and a
.bashrcline, both with a long base64 string and a comment mimicking "seed PRNG" (to look like a system line). When decoded (print only — never run it), it launches a remote shell tool disguised as a system monitor, run under a fake process name from~/.config/<monitoring-tool-name>/. This gives the attacker a command line on the account with no WordPress involved. - Check shared memory (
ipcs -m) — any segments belonging to your user should be investigated.
Remove only the lines you have identified:
Bashcrontab -l | grep -v -E "<marker1>|<marker2>" | crontab - # then run crontab -l again
cp ~/.bashrc ~/.bashrc.bak
sed -i '/<marker>/d' ~/.bashrc
cat ~/.bashrc ~/.bash_profile # make sure only normal lines remain
5.2 ~/.ssh Should Only Contain Keys
Bashls -la ~/.ssh
PHP files inside .ssh are never legitimate. If found, move them to quarantine. Also look for authorized_keys entries and key pairs belonging to former developers or contractors — old access means old risk. Remove keys that are no longer needed:
Bashmkdir -p -m 700 ~/quarantine_manual/ssh
mv ~/.ssh/authorized_keys ~/.ssh/<old-key> ~/.ssh/<old-key>.pub ~/quarantine_manual/ssh/
5.3 Check .htaccess Files
Look for blocks like this:
Apache<IfModule mod_php.c>
php_value
</IfModule>
A php_value with no value does nothing (and can cause a 500 error). These are likely leftovers of a php_value line whose value was stripped by an earlier incomplete cleanup. Remove only the empty blocks:
Bashcp <site>/.htaccess ~/<site>.htaccess.bak
perl -0pi -e 's{<IfModule mod_php\d?\.c>\s*php_value\s*</IfModule>\s*}{}g' <site>/.htaccess
grep -rnE "auto_prepend_file|auto_append_file" ~/public_html --include=.htaccess --include=.user.ini --include=php.ini
Fake Users
Malware kits almost always leave a hidden administrator account behind.
6.1 List Every Administrator on Every Site
Bashcat > ~/admins.sh <<'EOF'
#!/bin/bash
cd ~/public_html
for cfg in $(find . -name wp-config.php 2>/dev/null); do
g(){ grep -E "define\( *['\"']$1['\"']" "$cfg" | head -1 | sed -E "s/.*,\s*['\"']([^'\"']*)['\"].*/\1/"; }
db=$(g DB_NAME); us=$(g DB_USER); pw=$(g DB_PASSWORD); ho=$(g DB_HOST)
px=$(grep -E '^\$table_prefix' "$cfg" | head -1 | sed -E "s/.*=\s*['\"']([^'\"']*)['\"].*/\1/")
echo "== $cfg"
MYSQL_PWD="$pw" mysql -h "$ho" -u "$us" -N -e "SELECT u.ID, u.user_login, u.user_email, u.user_registered FROM \`$db\`.\`${px}users\` u JOIN \`$db\`.\`${px}usermeta\` m ON m.user_id=u.ID WHERE m.meta_key='${px}capabilities' AND m.meta_value LIKE '%administrator%'" 2>&1
sleep 1
done
echo admins-done
EOF
bash ~/admins.sh
6.2 What a Fake Admin Looks Like
- Random suffixes:
adm_<hex>,admin_<hex>,administrator_<hex>,backup_<hex>,dev_<hex>,support_<hex>,editor_<hex>. - Plausible-but-wrong names and emails such as
[email protected],wordfenceuser@...,[email protected],[email protected]. - Multiple unrelated names with throwaway emails registered in batches — these are often created automatically by the kit's own routine.
- A heavily infected site may have 10+ fake administrators at once.
Compare user_registered dates to the date the infection likely started, and investigate every account you don't recognise.
6.3 Delete Them, With a Backup
Bash# per site, inspect first
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered --path=<site> --skip-plugins --skip-themes
# then delete (reassign content to a real admin)
wp user delete <ID> <ID> --reassign=<real-admin-ID> --yes --path=<site> --skip-plugins --skip-themes
You can also delete accounts by ID directly in SQL after backing up the users and usermeta rows with mysqldump --where. If doing it in SQL, delete usermeta rows first, then users.
Do not auto-delete accounts that look unfamiliar but could be real people (shared admin emails, test users, clients). Leave those and ask first.
Rotate Every Credential (in This Order, and Only Now)
Rotating passwords before the backdoors are gone is pointless, because the malware can read wp-config.php. Only after Phases 3-6 are complete:
7.1 Salt Keys (Logs Everyone Out)
Bashcat > ~/salts.sh <<'EOF'
#!/bin/bash
cd ~/public_html
mkdir -p ~/wpconfig_backup
for cfg in $(find . -name wp-config.php 2>/dev/null); do
cp -n "$cfg" ~/wpconfig_backup/$(echo "$cfg" | tr -c 'a-zA-Z0-9' _).bak
for k in AUTH_KEY SECURE_AUTH_KEY LOGGED_IN_KEY NONCE_KEY AUTH_SALT SECURE_AUTH_SALT LOGGED_IN_SALT NONCE_SALT; do
v=$(head -c 96 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 64)
sed -i -E "s/(define\( *['\"']${k}['\"']\s*,\s*)['\"'][^'\"']*['\"]]/\1'${v}'/" "$cfg"
done
php -l "$cfg" >/dev/null 2>&1 && echo "ok $cfg" || echo "SYNTAX ERROR $cfg"
done
echo salts-done
EOF
bash ~/salts.sh
(wp config shuffle-salts is the WP-CLI equivalent.) Watch out: using perl -pi -e with $ENV{K}[...] in the regex will fail — Perl reads it as an array index and refuses to compile. Use sed or wp config shuffle-salts instead.
7.2 WordPress Admin Passwords
Bashcat > ~/wppass.sh <<'EOF'
#!/bin/bash
cd ~/public_html
for cfg in $(find . -name wp-config.php 2>/dev/null); do
g(){ grep -E "define\( *['\"']$1['\"']" "$cfg" | head -1 | sed -E "s/.*,\s*['\"']([^'\"']*)['\"].*/\1/"; }
db=$(g DB_NAME); us=$(g DB_USER); pw=$(g DB_PASSWORD); ho=$(g DB_HOST)
px=$(grep -E '^\$table_prefix' "$cfg" | head -1 | sed -E "s/.*=\s*['\"']([^'\"']*)['\"].*/\1/")
MYSQL_PWD="$pw" mysql -h "$ho" -u "$us" -N -e "SELECT u.ID, u.user_login FROM \`$db\`.\`${px}users\` u JOIN \`$db\`.\`${px}usermeta\` m ON m.user_id=u.ID WHERE m.meta_key='${px}capabilities' AND m.meta_value LIKE '%administrator%'" 2>/dev/null </dev/null | while read id login; do
[ -n "$id" ] || continue
np=$(head -c 96 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 16)
MYSQL_PWD="$pw" mysql -h "$ho" -u "$us" -e "UPDATE \`$db\`.\`${px}users\` SET user_pass=MD5('$np') WHERE ID=$id; DELETE FROM \`$db\`.\`${px}usermeta\` WHERE user_id=$id AND meta_key='session_tokens'" 2>/dev/null </dev/null
echo "$cfg | $login | $np"
done
done
echo passwords-done
EOF
bash ~/wppass.sh
Important: this prints the new passwords on screen and saves them nowhere. Copy the output straight into a password manager, and never paste it into a chat or a ticket. WordPress re-hashes an MD5 password into a strong hash the first time that user logs in. Send each affected person their new password through a secure channel.
7.3 Database Passwords (the Step That's Easy to Get Wrong)
Several sites may share one DB user, so change the password once per user, then update every wp-config.php that uses it, then test the login. Do one test site first.
Bashcat > ~/dbpass.sh <<'EOF'
#!/bin/bash
cd ~/public_html
mkdir -p ~/wpconfig_backup
g(){ grep -E "define\( *['\"']$1['\"']" "$2" | head -1 | sed -E "s/.*,\s*['\"']([^'\"']*)['\"].*/\1/"; }
declare -A done_u
if [ -n "$1" ]; then list="$1/wp-config.php"; else list=$(find . -maxdepth 2 -name wp-config.php | sort); fi
for cfg in $list; do
us=$(g DB_USER "$cfg"); ho=$(g DB_HOST "$cfg")
[ -n "$us" ] || { echo "SKIP (no user) $cfg"; continue; }
[ -n "${done_u[$us]}" ] && continue
done_u[$us]=1
np=$(head -c 96 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24)
if ! uapi --output=json Mysql set_password user="$us" password="$np" | grep -Eq '"status" *: *1'; then
echo "FAILED uapi: $us ($cfg), nothing changed"; continue
fi
for c in $(find . -maxdepth 2 -name wp-config.php); do
[ "$(g DB_USER "$c")" = "$us" ] || continue
cp -n "$c" ~/wpconfig_backup/$(echo "$c" | tr -c 'a-zA-Z0-9' _).pre-dbpass
sed -i -E "s/(define\( *['\"']DB_PASSWORD['\"']\s*,\s*)['\"'][^'\"']*['\"]]/\1'${np}'/" "$c"
php -l "$c" >/dev/null 2>&1 && s=ok || s=SYNTAX-ERROR
echo "$s $c (user $us)"
done
MYSQL_PWD="$np" mysql -h "$ho" -u "$us" -N -e "SELECT 'login-test-ok'" 2>&1 | tail -1
sleep 1
done
echo dbpass-done
EOF
bash ~/dbpass.sh <test-site> # one site first
bash ~/dbpass.sh # then everything
The passwords never reach the screen — they go straight into wp-config.php. Afterwards, open several sites in a browser to check for "Error establishing a database connection". If a non-WordPress app on the account (for example a Next.js or Node project) uses the same DB user, update its .env by hand.
7.4 By Hand in cPanel
- Change the cPanel password.
- FTP Accounts: delete any you don't recognise, change the rest.
- SSH Access > Manage SSH Keys: delete any key that isn't yours.
- Turn on 2FA for cPanel, and for hosting and email accounts.
The Second Sweep (the One That Finds What the First Missed)
A clean first scan is not the end. All marker searches may report "clean", but a second sweep with different patterns often finds more.
8.1 Hidden Leftovers
Bashcd ~/public_html
find . -path "*/wp-content/*" \( -name ".*.php" -o -name ".gx_*" -o -name ".sc_*" -o -name ".wp-object-cache-*" \) ! -name ".php-cs-fixer*" 2>/dev/null
Common findings:
- Dot-prefixed PHP files with random hex names in
wp-content- typically 6 bytes: just<?php. Harmless on their own, but created by the kit. Move to quarantine. - Small hidden marker files in
wp-content/cache/- each contains only the kit's version string. First-pass delete patterns often miss these. .php-cs-fixer.dist.phpinside plugins: a developer tool config, normal. Exclude it from searches to avoid noise.
8.2 File Manager and Database-Admin Tools
Bashfind . \( -iname "adminer*.php" -o -iname "*filemanager*.php" -o -iname "wpcachecheck.php" \) 2>/dev/null
for d in ./ */; do [ -f "${d}wp-config.php" ] || continue
r=$(wp plugin list --path="$d" --skip-plugins --skip-themes --fields=name,version,status --format=csv 2>/dev/null | grep -E "file-manager|filester|adminer")
[ -n "$r" ] && echo "== $d" && echo "$r"; sleep 1; done
Plugins like wp-file-manager, file-manager-advanced (which bundles Adminer) and filester are high-risk. Even inactive plugins' PHP files can be requested directly by URL — deactivating is not enough. Unless actively used, remove the plugin folders:
Bashmv <site>/wp-content/plugins/wp-file-manager ~/quarantine_manual/
If you do use one, keep it updated and delete it when you're done with it.
8.3 PHP Inside uploads/
Bashfind . -path "*/uploads/*" -name "*.php" 2>/dev/null | head -50
find . -path "*/uploads/*" -name "*.php" -print0 | xargs -0 grep -lE 'eval\(|base64_decode|gzinflate|shell_exec|passthru|FilesMan|_POST\[|_REQUEST\[' 2>/dev/null
Expect only empty index.php files and framework helpers. Open files before deciding to delete — some are legitimately placed by themes years ago with no suspicious functions.
8.4 Auth-Cookie Backdoors
A common backdoor logs an attacker in without a password by calling wp_set_auth_cookie(). A search for eval/base64 alone won't find it:
Bashgrep -rlE "wp_set_auth_cookie" --include=*.php wp-content */wp-content 2>/dev/null | grep -v "/plugins/"
8.5 Verify WordPress Core and Plugins Against Official Checksums
Bashfor d in ./ */; do [ -f "${d}wp-config.php" ] || continue
r=$(wp core verify-checksums --path="$d" --skip-plugins --skip-themes 2>&1 | grep -v -E "^Success|error_log")
[ -n "$r" ] && echo "== $d" && echo "$r" | head -5; sleep 1; done
for d in ./ */; do [ -f "${d}wp-config.php" ] || continue
r=$(wp plugin verify-checksums --all --path="$d" --skip-plugins --skip-themes 2>&1 | grep -E "added|does not match")
[ -n "$r" ] && echo "== $d" && echo "$r" | head -8; sleep 1; done
File should not exist: .../error_log warnings are PHP's own log files, not malware. Premium plugins have no public checksums, so they aren't covered. To be sure about core, re-download it first:
Bashwp core download --force --skip-content --path=<site>
8.6 False Positives from Core Re-Extraction
If find -mtime -3 flags thousands of core files on every site, it's likely because they were all re-extracted recently (by a tool or by hand). That check is too noisy to be useful. Use verify-checksums for core, and find -newer <a file you touched when you finished cleaning> for everything else.
Free Up Inodes Before You Update Anything
When the cPanel statistics panel shows something like 963,058 / 1,000,000 files (96.31%) with plenty of disk space, updates can fail halfway and break sites — a single WordPress core update extracts ~3,300 files.
Bashcd ~
for d in ~/* ~/.[a-zA-Z]*; do [ -e "$d" ] && echo "$(find "$d" 2>/dev/null | wc -l) $d"; done | sort -rn | head -12
Common inode eaters: the sites themselves (~498,000 files), an old extracted backup folder (whose .tar.gz also exists separately), and the scanner's quarantine (potentially 29 GB+). Delete the extracted backup only after confirming the archive contains it:
Basht=<backup-folder-name>
tar tzf ~/$t.tar.gz | head -1 | grep -q "^$t" && rm -rf ~/$t
Delete the scanner quarantine in the background rather than trying to compress it (compressing tens of GBs takes far too long):
Bashnohup bash -c "rm -rf ~/.quarantine; echo done > ~/cleanup-finished.txt" >/dev/null 2>&1 &
cat ~/cleanup-finished.txt; find ~ 2>/dev/null | wc -l
PHP Version and the PHP2Shell Vulnerability
10.1 Check What's Installed and Which Sites Use Which Version
Bashfor p in /opt/cpanel/ea-php*/root/usr/bin/php /opt/alt/php*/usr/bin/php; do [ -x "$p" ] && echo "$p: $($p -r 'echo PHP_VERSION;')"; done
uapi LangPHP php_get_vhost_versions | grep -E "vhost:|version:"
Two critical remote-code-execution bugs (PHP2Shell, CVE-2026-109618 and CVE-2026-109619) were fixed in PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12. Make sure every domain is running a patched build. Older branches (7.4, 8.0, 8.1) are commonly still installed on shared hosts and would be vulnerable — check and move sites in cPanel > MultiPHP Manager, one or two at a time, and verify each in a browser.
10.2 Note on Timing
PHP2Shell was discovered in August 2026 and made public on 20-21 October. The earliest reported exploit attempts began on 17 October. If an infection predates that window, PHP2Shell was not the cause — but it is a new risk that must still be patched regardless.
Hardening
- Turn off the theme and plugin editors in every
wp-config.php:PHP
Check it is present anddefine('DISALLOW_FILE_EDIT', true);true, not just that the word appears:Bash
grep -L "DISALLOW_FILE_EDIT', *true" wp-config.php */wp-config.php - Block XML-RPC (a target of thousands of POST requests per day):
Apache
Then test it. Logs can show XML-RPC responses of 409 and 406 (blocked) but also hundreds of<Files xmlrpc.php> Require all denied </Files>200s on some sites. A "block" that is present in.htaccessis not the same as a block that actually works. - Update everything: WordPress core, plugins, themes, with a database backup first. Delete anything unused or nulled. Premium plugins (page builders, slider plugins) don't update through WP-CLI and need their license in wp-admin.
Bash
After the updates, rerun the verification from Phase 8.5, because new plugin files will look "changed".cat > ~/wpupdate.sh <<'EOF' #!/bin/bash cd ~/public_html mkdir -p ~/update_backup if [ -n "$1" ]; then list="$1"; else list=". $(ls -d */ | tr -d /)"; fi for d in $list; do [ -f "$d/wp-config.php" ] || continue [ "$d" = "." ] && n=root || n=$d echo "== $n" wp db export ~/update_backup/$n-$(date +%F).sql --path="$d" --skip-plugins --skip-themes 2>&1 | tail -1 wp core update --path="$d" --skip-plugins --skip-themes 2>&1 | tail -2 wp plugin update --all --path="$d" --skip-plugins --skip-themes 2>&1 | tail -4 wp theme update --all --path="$d" --skip-plugins --skip-themes 2>&1 | tail -3 sleep 2 done echo update-done EOF bash ~/wpupdate.sh <test-site> # one site first, check it in the browser bash ~/wpupdate.sh # then the rest - List inactive plugins and delete the ones you don't need:
Bash
for d in . */; do [ -f "$d/wp-config.php" ] || continue; echo "== $d"; wp plugin list --status=inactive --field=name --path="$d" --skip-plugins --skip-themes | head -20; done - Put Cloudflare (or another WAF) in front of the sites, turn on 2FA for every administrator, and delete old backups, Adminer copies and
wp-config.php.bakfiles from the web root.
Verify Later, Not Just Now
Run this right after cleaning, again the next day, and again a week later:
Bashcd ~/public_html
grep -rlE "SC_ADV_BEGIN|SC_TH_BEGIN|SC_DD_BEGIN|SC_TH_LD_|lucid-toolkit-lab" . --include=*.php 2>/dev/null | grep -v wflogs
find . -path "*/wp-content/*" \( -name ".*.php" -o -name ".gx_*" -o -name ".sc_*" -o -name advanced-cache.php -o -name db.php \) ! -name ".php-cs-fixer*" 2>/dev/null
crontab -l
bash ~/dbclean.sh # should print only db-clean-done
bash ~/admins.sh # review the admin list
Plus the two verify-checksums loops from Phase 8.5. If everything is empty, open every site in a browser. Hours of "clean" is a good sign. It isn't proof.
Looking for the Entry Point
Reading access logs is important — but keep two things in mind first:
- The
access-logsfolder typically holds only the current log. Older days are in monthly archives (~/logs/*-Oct-2026.gz). Many searches return nothing because the wrong folder is searched. - IP addresses in logs are personal data. Be mindful about where you paste them.
Bash# how many POSTs to the usual targets, and what status codes came back
cd ~/logs
zgrep -H -E '\[1[4-6]/Oct/2026:' *Oct-2026*.gz 2>/dev/null \
| grep -E '"POST (/|//)?(wp-plain\.php|wp-admin/update\.php|wp-json/batch|xmlrpc\.php|wp-login\.php)' \
| awk '{i=index($1,":"); f=substr($1,1,i-1); split($7,p,"?"); print f, p[1], $9}' | sort | uniq -c | sort -rn | head -30
# successful logins (302) and plugin installs
zgrep -H -E '\[1[4-6]/Oct/2026:' *Oct-2026*.gz 2>/dev/null \
| grep -E '"POST (/|//)?(wp-login\.php|wp-admin/update\.php)' \
| awk '{i=index($1,":"); f=substr($1,1,i-1); ip=substr($1,i+1); split($7,p,"?"); if ($9=="302" || p[1] ~ /update/) print f, ip, p[1], $9}' | sort | uniq -c | sort -rn | head -20
What logs typically reveal after a shared-hosting malware attack:
- Thousands of POSTs per site per day to
xmlrpc.php, and hundreds towp-login.php: brute force. Most are blocked (409/406/503) but some return200. - Probes for web shells that don't exist on the server (a
wp-plain.phpfile, and a perl/CGI path used by a known shell). - A handful of successful logins (
302) from several IPs, and a fewupdate.phpcalls (plugin installs).
The entry point is often impossible to determine conclusively. Credentials (brute-forced or leaked) are the most likely vector, but they can rarely be proven. File timestamps are also unreliable on this kind of infection — the dropper changes modification times deliberately. Don't rely on them to establish a start date.
Common Mistakes to Avoid
fork: retry) and stops halfway. Avoid nested loops.-o -name becomes -o-name) and silently matches nothing. Put scripts in files.cat > file <<'EOF' only writes the file. Run it separately.$ENV{K}[...]. Use sed or wp config shuffle-salts.word_word rows on other sites..htaccess for the word "xmlrpc" gives false confidence. Always test that the block actually works via logs.access-logs/ instead of monthly archives) gives empty results and looks like nothing happened.find -mtime -3 is useless after a core re-extract. Everything looks new.db.php files are false positives. Elementor ships its own harmless db.php. Always check contents before deleting.What Is Often Still Unknown After Cleanup
- How the attacker first got in.
- How long the infection had been active (timestamps are deliberately unreliable).
- Whether any data was read from databases, since these kits typically communicate with a remote command-and-control server. Treat every database and site credential as exposed, and notify affected parties accordingly.
Lessons
Interactive Cleanup Checklist
0 of 16 completed- Full backup taken (infected — for reference only, never restore)
- Quarantine folder created outside the web root
- Error log read; suspicious file read (not run)
- Marker search across the whole account; per-site counts noted
- Loaders neutralised on all sites in one pass (and re-run to confirm)
- Database
optionsseed rows backed up and deleted (checked every options table) - Crontab,
.bashrc/.profile, processes, shared memory reviewed -
~/.sshand~/.configchecked; old keys removed -
.htaccessandauto_prepend_filechecked - Fake admins found and removed
- Salt keys, WordPress passwords, DB passwords, cPanel/FTP/SSH rotated (in that order)
- Second sweep: hidden dotfiles, file-manager plugins,
uploads/*.php, auth-cookie backdoors, core and plugin checksums - Inodes freed; PHP version confirmed patched
-
DISALLOW_FILE_EDITset, XML-RPC block tested, updates done, unused plugins removed, WAF and 2FA enabled - Verification repeated after a day and a week
- Affected parties informed
