WordPress Malware Remediation Incident – Case Study

WordPress Malware Remediation Incident

A persistent malware infection on a client’s WordPress install turned out to be part of a wider compromise affecting several sites under the same cPanel account.

The most visibly affected site will be referred to in this article as:

infected-example.com

The cPanel account will be referred to as:

accountname

The server hostname, IP addresses, real domains, usernames, file hashes, and other identifying information have all been replaced with generic placeholders.

The affected account hosted dozens of live WordPress sites, so suspending or deleting the account was not an acceptable option. The goal was to identify the persistence mechanism, stop reinfection, remove confirmed malware, refresh WordPress core files safely, repair permissions, and clean the other sites in the same account.

Initial Symptoms

Upon checking, first obvious symptom was that robots.txt files were being overwritten repeatedly.

A known-good robots.txt file would be replaced with altered content such as:

User-agent: *
Disallow:

Sitemap: https://wrong-domain.example/sitemap.xml

Other symptoms included:

  • New PHP files appearing inside plugin directories
  • Spam landing pages appearing under unexpected paths
  • Gambling-related content indexed under otherwise legitimate domains
  • WordPress core files failing checksum verification
  • Files and directories with unusual permissions
  • Some wp-content directories set to read-only permissions
  • WordPress files that reappeared after deletion
  • Sitemap URLs pointing to an unrelated domain
  • Suspicious activity affecting multiple sites under one cPanel account

The important clue was that deleted files were being recreated. That indicated an active persistence mechanism rather than a one-time infection.

1. Identify Recreated Malware Files

The first step was to search for the recurring payload and active processes responsible for copying it.

A suspicious file had appeared under shared memory:

/dev/shm/payload-file

Search for suspicious processes:

pgrep -af '/dev/shm/payload-file|while sleep|cp /dev/shm|suspicious-name'

This revealed several shell processes running under the compromised cPanel user.

They followed a pattern similar to:

while sleep 0.1; do
    cp /dev/shm/payload-file /home/accountname/domain.example/wp-content/plugins/plugin-name/index.php
done

The loop copied the payload into WordPress plugin directories every fraction of a second.

This explained why deleting the infected PHP files did not solve the problem. The files were immediately recreated by an active shell process.

2. Stop the Active Persistence Processes

Once the malicious process IDs were identified, terminate them.

Example:

kill -9 PROCESS_ID_1 PROCESS_ID_2 PROCESS_ID_3

Then confirm that no matching processes remain:

pgrep -af '/dev/shm/payload-file|while sleep|cp /dev/shm'

A clean result should return no malicious process entries.

Do not rely only on killing the copied PHP files. The process responsible for recreating them must be stopped first.

3. Remove the Malicious Cron Job

The compromised cPanel account also contained a malicious cron job.

Inspect the user’s cron entries:

crontab -u accountname -l

The malicious entry ran every minute and used encoded shell commands to download or recreate malware.

A typical pattern looked like:

* * * * * echo BASE64_STRING | base64 -d | sh

Back up the cron file before changing it:

crontab -u accountname -l > /root/accountname-crontab-backup.txt

Edit the user’s crontab:

crontab -u accountname -e

Remove only the confirmed malicious line.

Then verify:

This was one of the most important steps because the cron job could restart the infection even after the running shell processes were killed.

crontab -u accountname -l

4. Preserve the Malicious Payload for Evidence

Before deleting the shared-memory payload, preserve a forensic copy.

Create a case directory:

CASE_DIR="/root/malware-case-$(date +%Y%m%d-%H%M%S)"

mkdir -p "$CASE_DIR"
chmod 700 "$CASE_DIR"

Record metadata:

stat /dev/shm/payload-file > "$CASE_DIR/payload-stat.txt"
sha256sum /dev/shm/payload-file > "$CASE_DIR/payload-sha256.txt"
file /dev/shm/payload-file > "$CASE_DIR/payload-filetype.txt"

Copy the payload:

cp -a /dev/shm/payload-file "$CASE_DIR/"

This preserved evidence for later analysis while allowing the active payload path to be neutralised.

5. Block the Shared-Memory Payload Path

After the payload was preserved, remove it:

rm -f /dev/shm/payload-file

Then replace the filename with a root-owned directory:

mkdir /dev/shm/payload-file
chown root:root /dev/shm/payload-file
chmod 000 /dev/shm/payload-file

Verify:

stat /dev/shm/payload-file

Expected result:

File: /dev/shm/payload-file
Type: directory
Owner: root
Group: root
Permissions: 000

This was an effective containment measure because malware expecting to recreate a normal file at that path could no longer do so.

It is not a substitute for removing the persistence mechanism, but it added a useful defensive barrier.

6. Use auditd to Trace Recreated Files

Because robots.txt and other files were being rewritten, auditd was used to capture the responsible process.

Add an audit rule for one affected file:

auditctl -w /home/accountname/infected-example.com/robots.txt \
-p wa \
-k infected_robots

Add a broader rule for the affected document root:

auditctl -w /home/accountname/infected-example.com \
-p wa \
-k infected_docroot

Inspect events:

ausearch -k infected_robots -i

Or:

ausearch -k infected_docroot -i

Useful fields included:

  • Executable path
  • Process ID
  • Parent process ID
  • User ID
  • Command name
  • File path
  • Time of modification

The audit results helped confirm whether changes were being made by PHP, shell scripts, cron, SSH sessions, or another process.

List active audit rules:

auditctl -l

7. Search for Confirmed Spam Pages

Several compromised sites contained large spam landing pages.

Search by known spam terms:

grep -RInsE \
'casino|slot|poker|jackpot|betting|bonus|gambling' \
/home/accountname \
--include='*.php' \
--include='*.html' \
--include='*.htm'

Large injected pages were found in paths such as:

/home/accountname/domain.example/contact/index.php
/home/accountname/domain.example/privacy-policy/index.php
/home/accountname/domain.example/category/example/index.php

Inspect file size and type:

ls -lh /home/accountname/domain.example/contact/index.php
file /home/accountname/domain.example/contact/index.php

Review the beginning safely:

head -40 /home/accountname/domain.example/contact/index.php

Once confirmed as malicious, preserve a copy if required:

cp -a \
/home/accountname/domain.example/contact/index.php \
"$CASE_DIR/"

Then remove it:

rm -f /home/accountname/domain.example/contact/index.php

If the surrounding directory was created solely for the spam page and contained no legitimate content:

rm -rf /home/accountname/domain.example/contact

Repeat only for paths that have been positively identified as malicious.

8. Search for Recently Modified PHP Files

Recent modification times are useful for narrowing the investigation.

Search for recently modified PHP files:

find /home/accountname \
-type f \
-name '*.php' \
-mtime -14 \
-printf '%TY-%Tm-%Td %TH:%TM:%TS %s %p\n' \
| sort

Search for unusually large PHP files:

find /home/accountname \
-type f \
-name '*.php' \
-size +500k \
-printf '%s %p\n' \
| sort -nr

Search for PHP files in upload directories:

find /home/accountname \
-type f \
-path '*/wp-content/uploads/*' \
-name '*.php' \
-print

Search for common obfuscation functions:

grep -RInsE \
'base64_decode|gzinflate|str_rot13|eval[[:space:]]*\(|assert[[:space:]]*\(|shell_exec|passthru|system[[:space:]]*\(' \
/home/accountname \
--include='*.php'

These searches can produce false positives, particularly inside legitimate plugins. Every result must be reviewed before deletion.

9. Check WordPress Databases for Spam

The file system was not the only place checked. WordPress databases were searched for confirmed spam indicators.

For one site:

SITE="/home/accountname/infected-example.com"

Search published post content:

su -s /bin/bash -c "
wp db query \"
SELECT ID, post_title, post_status
FROM wp_posts
WHERE post_content REGEXP 'casino|slot|poker|gambling|jackpot'
   OR post_title REGEXP 'casino|slot|poker|gambling|jackpot';
\" --path='$SITE'
" accountname

Search options:

su -s /bin/bash -c "
wp db query \"
SELECT option_name
FROM wp_options
WHERE option_value REGEXP 'suspicious-domain|casino|gambling';
\" --path='$SITE'
" accountname

Search user records:

su -s /bin/bash -c "
wp user list \
--fields=ID,user_login,user_email,roles \
--path='$SITE'
" accountname

Broad terms such as slot can appear in legitimate content, plugin settings, serialized data, or CSS. Narrow searches using confirmed malicious domains, titles, or unique phrases are more reliable.

10. Refresh WordPress Core Files Safely

Once persistence was stopped and confirmed malware removed, WordPress core files were refreshed.

The objective was to replace:

  • wp-admin
  • wp-includes
  • Root-level WordPress core files

The objective was not to overwrite:

  • Themes
  • Plugins
  • Uploads
  • Custom wp-content data
  • wp-config.php

Build a list of WordPress document roots:

find /home/accountname \
-mindepth 2 \
-maxdepth 2 \
-type f \
-name wp-config.php \
-printf '%h\n' \
| sort -u \
> /root/accountname-wordpress-paths.txt

Include public_html if applicable:

if [[ -f /home/accountname/public_html/wp-config.php ]]; then
    echo /home/accountname/public_html \
    >> /root/accountname-wordpress-paths.txt
fi

sort -u -o \
/root/accountname-wordpress-paths.txt \
/root/accountname-wordpress-paths.txt

Back up WordPress configuration files:

BACKUP="/root/accountname-wp-config-backups-$(date +%Y%m%d-%H%M%S)"

mkdir -p "$BACKUP"

while IFS= read -r SITE; do
    DOMAIN="$(basename "$SITE")"

    cp -a \
    "$SITE/wp-config.php" \
    "$BACKUP/${DOMAIN}-wp-config.php"
done < /root/accountname-wordpress-paths.txt

Refresh the installed WordPress version:

while IFS= read -r SITE; do
    echo
    echo "Refreshing: $SITE"

    VERSION=$(
        su -s /bin/bash -c \
        "wp core version --path='$SITE'" \
        accountname 2>/dev/null
    )

    if [[ -z "$VERSION" ]]; then
        echo "FAILED: Could not determine WordPress version"
        continue
    fi

    su -s /bin/bash -c "
    wp core download \
      --path='$SITE' \
      --version='$VERSION' \
      --force \
      --skip-content
    " accountname

done < /root/accountname-wordpress-paths.txt

Using --skip-content preserved installed themes, plugins, uploads, and other content.

11. Repair Read-Only WordPress Guard Files

Some core refreshes initially failed with errors such as:

Unable to copy 'wp-content/themes/index.php'
Couldn't extract WordPress archive
There was an error overwriting existing files

The affected files had permissions set to:

444

Check:

stat -c '%U:%G %a %n' \
/home/accountname/domain.example/wp-content/themes/index.php

Repair the standard guard files:

while IFS= read -r SITE; do
    for FILE in \
        "$SITE/wp-content/index.php" \
        "$SITE/wp-content/plugins/index.php" \
        "$SITE/wp-content/themes/index.php"
    do
        if [[ -f "$FILE" ]]; then
            chattr -i -a "$FILE" 2>/dev/null || true
            chown accountname:accountname "$FILE"
            chmod 644 "$FILE"
        fi
    done
done < /root/accountname-wordpress-paths.txt

Ensure the directories are writable:

while IFS= read -r SITE; do
    for DIR in \
        "$SITE/wp-content" \
        "$SITE/wp-content/plugins" \
        "$SITE/wp-content/themes"
    do
        if [[ -d "$DIR" ]]; then
            chattr -i -a "$DIR" 2>/dev/null || true
            chown accountname:accountname "$DIR"
            chmod 755 "$DIR"
        fi
    done
done < /root/accountname-wordpress-paths.txt

After repairing those permissions, the core refreshes completed successfully.

12. Verify WordPress Core Checksums

After refreshing core files, verify each site:

while IFS= read -r SITE; do
    echo
    echo "Checking: $SITE"

    su -s /bin/bash -c "
    wp core verify-checksums \
      --path='$SITE'
    " accountname

done < /root/accountname-wordpress-paths.txt

Expected output:

Success: WordPress installation verifies against checksums.

Checksum verification confirms official WordPress core files, but it does not check:

  • Plugin files
  • Theme files
  • Uploads
  • Custom PHP files
  • Database content
  • Cron jobs
  • User accounts

It is therefore one part of the cleanup, not the entire cleanup.

13. Normalise Core Ownership and Permissions

Repair top-level WordPress core ownership:

while IFS= read -r SITE; do
    chown accountname:accountname \
        "$SITE/wp-config.php" \
        "$SITE/index.php" \
        "$SITE/wp-load.php" \
        "$SITE/wp-settings.php" \
        2>/dev/null || true

    chown -R accountname:accountname \
        "$SITE/wp-admin" \
        "$SITE/wp-includes"

    find "$SITE/wp-admin" "$SITE/wp-includes" \
        -type d \
        -exec chmod 755 {} +

    find "$SITE/wp-admin" "$SITE/wp-includes" \
        -type f \
        -exec chmod 644 {} +

done < /root/accountname-wordpress-paths.txt

Avoid blindly applying recursive ownership changes to an entire cPanel home directory. Some files may require special ownership or permissions.

14. Clean Unexpected Top-Level Files and Directories

A restricted allowlist was used to identify unexpected entries in each WordPress document root.

Allowed directories included:

.well-known
cgi-bin
wp-admin
wp-content
wp-includes

Allowed files included standard WordPress files such as:

.htaccess
index.php
robots.txt
wp-config.php
wp-login.php
wp-load.php
wp-settings.php
xmlrpc.php

List unexpected top-level entries:

SITE="/home/accountname/infected-example.com"

find "$SITE" \
-mindepth 1 \
-maxdepth 1 \
-printf '%f\n' \
| sort

This approach intentionally did not descend into wp-content, because plugins, themes, and uploads require separate review.

Only confirmed unexpected items were removed.

15. Repair and Standardise robots.txt

The malware had modified robots.txt files and inserted sitemap URLs pointing to the wrong domain.

A clean template was used:

User-agent: *
Allow: /wp-content/uploads/
Allow: /wp-admin/admin-ajax.php
Disallow: /wp-admin/
Disallow: /readme.html

Sitemap: https://example.com/sitemap.xml

For a single site:

DOMAIN="infected-example.com"
ROBOTS="/home/accountname/$DOMAIN/robots.txt"

chattr -i -a "$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

Sitemap: https://$DOMAIN/sitemap.xml
EOF

chown accountname:accountname "$ROBOTS"
chmod 644 "$ROBOTS"

If ownership changes returned:

Operation not permitted

Check file attributes:

lsattr "$ROBOTS"

Remove immutable or append-only flags:

chattr -i -a "$ROBOTS"

Then retry ownership and permissions:

chown accountname:accountname "$ROBOTS"
chmod 644 "$ROBOTS"

16. Find Missing robots.txt Files

Search all WordPress document roots:

while IFS= read -r SITE; do
    if [[ ! -f "$SITE/robots.txt" ]]; then
        echo "MISSING: $SITE/robots.txt"
    fi
done < /root/accountname-wordpress-paths.txt

Create missing files using the domain directory name:

while IFS= read -r SITE; do
    ROBOTS="$SITE/robots.txt"
    DOMAIN="$(basename "$SITE")"

    [[ -f "$ROBOTS" ]] && continue

    cat > "$ROBOTS" <<EOF
User-agent: *
Allow: /wp-content/uploads/
Allow: /wp-admin/admin-ajax.php
Disallow: /wp-admin/
Disallow: /readme.html

Sitemap: https://$DOMAIN/sitemap.xml
EOF

    chown accountname:accountname "$ROBOTS"
    chmod 644 "$ROBOTS"

    echo "CREATED: $ROBOTS"
done < /root/accountname-wordpress-paths.txt

For public_html, use the actual primary domain rather than the directory name.

17. Replace Incorrect Sitemap Domains

Some robots.txt files contained sitemap URLs using an unrelated domain.

Search:

grep -RIns \
'wrong-domain.example' \
/home/accountname/*/robots.txt \
/home/accountname/public_html/robots.txt \
2>/dev/null

Replace only inside Sitemap: lines:

while IFS= read -r SITE; do
    ROBOTS="$SITE/robots.txt"
    [[ -f "$ROBOTS" ]] || continue

    DOMAIN="$(basename "$SITE")"

    sed -i -E \
    "/^[[:space:]]*Sitemap[[:space:]]*:/{
        s#wrong-domain\.example#$DOMAIN#g
    }" \
    "$ROBOTS"

done < /root/accountname-wordpress-paths.txt

Backups should be made before bulk editing.

18. Check for Remaining Persistence

After cleanup, repeat all persistence checks.

Processes:

pgrep -af '/dev/shm/payload-file|while sleep|cp /dev/shm'

Cron:

crontab -u accountname -l

Shared-memory path:

stat /dev/shm/payload-file

Recent PHP files:

find /home/accountname \
-type f \
-name '*.php' \
-mtime -2 \
-printf '%TY-%Tm-%Td %TH:%TM:%TS %p\n' \
| sort

Suspicious upload PHP:

find /home/accountname \
-type f \
-path '*/wp-content/uploads/*' \
-name '*.php' \
-print

Audit events:

ausearch -k infected_robots -i
ausearch -k infected_docroot -i

No cleanup should be considered complete until the files remain unchanged over time.

19. Change Credentials and Review Access

After the infection was contained:

  • The cPanel password was changed
  • WordPress administrator passwords were reset
  • Unknown WordPress users were removed
  • FTP accounts were reviewed
  • SSH authorised keys were checked
  • Cron jobs were reviewed
  • API credentials were rotated
  • WordPress salts were regenerated
  • Plugin credentials were changed where applicable

Check SSH keys:

find /home/accountname \
-maxdepth 3 \
-type f \
-name authorized_keys \
-print \
-exec cat {} \;

Check recent logins:

last -a | head -50

Check running processes owned by the account:

ps -u accountname -f

Changing passwords alone does not remove an existing backdoor, cron job, SSH key, or malicious process.

20. Update Plugins and Themes

After core verification, update legitimate components.

List plugins:

su -s /bin/bash -c "
wp plugin list \
--path='/home/accountname/infected-example.com'
" accountname

Update plugins:

su -s /bin/bash -c "
wp plugin update --all \
--path='/home/accountname/infected-example.com'
" accountname

List themes:

su -s /bin/bash -c "
wp theme list \
--path='/home/accountname/infected-example.com'
" accountname

Update themes:

su -s /bin/bash -c "
wp theme update --all \
--path='/home/accountname/infected-example.com'
" accountname

Remove unused plugins and themes only after confirming they are not required.

What Actually Caused the Reinfection

The infection persisted because several mechanisms were active at the same time:

  1. A malicious payload existed in shared memory.
  2. Shell loops copied the payload into WordPress plugin directories repeatedly.
  3. A malicious per-minute cron job could restore or redownload malware.
  4. Spam pages had been planted across multiple document roots.
  5. Some files were protected with read-only or immutable attributes.
  6. Several WordPress core installations contained altered or damaged files.
  7. robots.txt files had been modified to reference an unrelated sitemap domain.

Deleting one infected PHP file would never have solved this incident.

The successful cleanup required stopping the processes, removing cron persistence, blocking the payload path, deleting confirmed malware, refreshing core files, repairing permissions, checking databases, restoring robots.txt, and verifying the entire cPanel account.

Key Lessons

Do not treat repeated file creation as a file problem

When malware returns immediately after deletion, look for:

  • Running processes
  • Cron jobs
  • Shared-memory payloads
  • SSH keys
  • PHP auto-prepend settings
  • Scheduled WordPress actions
  • Compromised plugins
  • External control scripts

Kill the persistence mechanism before deleting payloads

Otherwise, infected files will continue to return.

Preserve evidence before cleanup

Record:

  • File hashes
  • Modification times
  • Ownership
  • Permissions
  • Process IDs
  • Cron entries
  • Audit logs
  • Suspicious payload copies

Core checksums are necessary but not sufficient

A clean checksum result does not prove that plugins, themes, uploads, databases, cron jobs, or user accounts are clean.

Avoid destructive account-wide commands

On a shared cPanel account containing many live sites, every deletion should be narrowly scoped and verified.

Use dry-run scripts for bulk remediation

Bulk scripts should:

  • Print intended changes
  • Create backups
  • Avoid following symlinks
  • Process only confirmed WordPress roots
  • Preserve wp-content
  • Log every changed file
  • Require an explicit live mode

Final Result

After the remediation:

  • The malicious shell loops were stopped
  • The user cron persistence was removed
  • The shared-memory payload path was blocked
  • Confirmed gambling spam pages were deleted
  • WordPress core files were refreshed
  • Core checksum verification succeeded
  • Read-only guard files were repaired
  • robots.txt files were standardised
  • Incorrect sitemap domains were replaced
  • Missing robots.txt files were created
  • Ownership and permissions were corrected
  • Additional sites under the account were inspected and cleaned
  • No further recreation of the confirmed malware files was observed

The most important lesson from this incident was that persistent WordPress malware must be treated as a server-level process and account-level persistence problem, not simply as a collection of infected plugin files.

Similar Posts

Leave a Reply

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