How to Clean a Self-Healing Malware Infection from Multiple WordPress Sites on a Shared cPanel Account

How to Clean a Self-Healing Malware Infection from Multiple WordPress Sites on a Shared cPanel Account

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.

TL;DR
  • 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.
Setup

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's wp-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.

Identification

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 (like lucid-toolkit-lab), and rogue mu-plugins containing 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.
Phase 0

Ground Rules Before You Touch Anything

  1. 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.
  2. Never rm a suspicious file. mv it. Make a quarantine folder outside the web root:
    mkdir -p ~/quarantine_manual
    mv <suspicious-file> ~/quarantine_manual/
    If a move turns out to be wrong, you can undo it.
  3. Read before you act. Run every command in read-only mode first (a find/grep that only lists), then act.
  4. 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).
  5. 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 -o and -name means a find can silently match nothing):
    cat > ~/script.sh <<'EOF'
    ...script...
    EOF
    bash ~/script.sh
    Note that the cat > ... <<'EOF' line only creates the file. Nothing runs until you run bash ~/script.sh.
Phase 1

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_CACHE is true).
  • It checks whether a fake plugin (lucid-toolkit-lab, a name that doesn't exist in any real plugin directory) is present in mu-plugins/ or plugins/.
  • If it isn't, it rebuilds it from four different sources: a hidden folder in wp-content, server shared memory, a random-hex .zip file, and a row in the database options table.
  • It also injects blocks (SC_TH_BEGIN ... SC_TH_END) into every theme's functions.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
Phase 2

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.

Phase 3

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.php if they contain the marker. Legitimate caching plugins and Elementor both ship harmless files with these names. An early find -name db.php listing will be full of false alarms.
  • After the perl edit, run php -l <file> on each functions.php to 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, set define('WP_CACHE', false); so no cache drop-in is loaded while you clean:
    Bashsed -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.

Phase 4

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_log and so on). The names sc_c2_cache / sc_last_c2 suggest 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 a deleted N line again, something on that site is still re-creating the rows.

After this step, files should stop coming back.

Phase 5

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 .bashrc line, 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
Phase 6

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.

Phase 7

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.
Phase 8

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.php inside 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.

Phase 9

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
Phase 10

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.

Phase 11

Hardening

  1. Turn off the theme and plugin editors in every wp-config.php:
    PHPdefine('DISALLOW_FILE_EDIT', true);
    Check it is present and true, not just that the word appears:
    Bashgrep -L "DISALLOW_FILE_EDIT', *true" wp-config.php */wp-config.php
  2. Block XML-RPC (a target of thousands of POST requests per day):
    Apache<Files xmlrpc.php>
    Require all denied
    </Files>
    Then test it. Logs can show XML-RPC responses of 409 and 406 (blocked) but also hundreds of 200s on some sites. A "block" that is present in .htaccess is not the same as a block that actually works.
  3. 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.
    Bashcat > ~/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
    After the updates, rerun the verification from Phase 8.5, because new plugin files will look "changed".
  4. List inactive plugins and delete the ones you don't need:
    Bashfor 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
  5. 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.bak files from the web root.
Phase 12

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.

Phase 13

Looking for the Entry Point

Reading access logs is important — but keep two things in mind first:

  • The access-logs folder 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 to wp-login.php: brute force. Most are blocked (409/406/503) but some return 200.
  • Probes for web shells that don't exist on the server (a wp-plain.php file, and a perl/CGI path used by a known shell).
  • A handful of successful logins (302) from several IPs, and a few update.php calls (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.

Reference

Common Mistakes to Avoid

1
The first cleanup script hits the process limit (fork: retry) and stops halfway. Avoid nested loops.
2
A pasted command loses a space (-o -name becomes -o-name) and silently matches nothing. Put scripts in files.
3
A heredoc doesn't run the script. cat > file <<'EOF' only writes the file. Run it separately.
4
Perl one-liners for salts may not compile if the regex contains $ENV{K}[...]. Use sed or wp config shuffle-salts.
5
Payload regex too narrow (only 10 hex characters) can miss 12-character and word_word rows on other sites.
6
A file-based check can't see database payloads. File searches can look completely clean while the database still holds the seed.
7
Checking .htaccess for the word "xmlrpc" gives false confidence. Always test that the block actually works via logs.
8
Searching the wrong log folder (access-logs/ instead of monthly archives) gives empty results and looks like nothing happened.
9
find -mtime -3 is useless after a core re-extract. Everything looks new.
10
Many db.php files are false positives. Elementor ships its own harmless db.php. Always check contents before deleting.
11
Trying to compress a large scanner quarantine wastes time. Delete it directly instead.
12
A scanner dashboard saying "remediated" is not proof. The kit can keep recreating files even while the scanner reports success.
Aftermath

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.
Takeaways

Lessons

1
One shared account turns one hack into every hack. Give each client their own account or VPS. On a shared account, one infected site can reach all others.
2
Find the seed, not just the symptoms: database rows, cron, shell startup files, hidden dotfiles, and keys of people who've left.
3
Clean all sites at once. Any survivor re-infects the rest.
4
Scanners saying "remediated" is not proof. Re-check hours and days later.
5
Rotate credentials only after the backdoors are gone, and treat every credential the malware could read as leaked.
6
Move, don't delete, and take a backup before every destructive step.
7
Test your hardening. "The rule is in the file" is not the same as "the rule works."
8
Remove what you don't use: file-manager plugins, database admin tools, old backups, unused themes.
9
Write your scripts to files, and run the risky ones on one site first.
10
Don't trust file dates.

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 options seed rows backed up and deleted (checked every options table)
  • Crontab, .bashrc/.profile, processes, shared memory reviewed
  • ~/.ssh and ~/.config checked; old keys removed
  • .htaccess and auto_prepend_file checked
  • 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_EDIT set, XML-RPC block tested, updates done, unused plugins removed, WAF and 2FA enabled
  • Verification repeated after a day and a week
  • Affected parties informed
Scroll to Top