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-contentdirectories 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
doneThe 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_3Then 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 -lThe 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.txtEdit the user’s crontab:
crontab -u accountname -eRemove 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 -l4. 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-fileThen 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-fileVerify:
stat /dev/shm/payload-fileExpected 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_robotsAdd a broader rule for the affected document root:
auditctl -w /home/accountname/infected-example.com \
-p wa \
-k infected_docrootInspect events:
ausearch -k infected_robots -iOr:
ausearch -k infected_docroot -iUseful 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 -l7. 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.phpReview the beginning safely:
head -40 /home/accountname/domain.example/contact/index.phpOnce 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.phpIf the surrounding directory was created solely for the spam page and contained no legitimate content:
rm -rf /home/accountname/domain.example/contactRepeat 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' \
| sortSearch for unusually large PHP files:
find /home/accountname \
-type f \
-name '*.php' \
-size +500k \
-printf '%s %p\n' \
| sort -nrSearch for PHP files in upload directories:
find /home/accountname \
-type f \
-path '*/wp-content/uploads/*' \
-name '*.php' \
-printSearch 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'
" accountnameSearch 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'
" accountnameSearch user records:
su -s /bin/bash -c "
wp user list \
--fields=ID,user_login,user_email,roles \
--path='$SITE'
" accountnameBroad 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-adminwp-includes- Root-level WordPress core files
The objective was not to overwrite:
- Themes
- Plugins
- Uploads
- Custom
wp-contentdata 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.txtInclude 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.txtBack 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.txtRefresh 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.txtUsing --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.phpRepair 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.txtEnsure 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.txtAfter 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.txtExpected 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.txtAvoid 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.phpList unexpected top-level entries:
SITE="/home/accountname/infected-example.com"
find "$SITE" \
-mindepth 1 \
-maxdepth 1 \
-printf '%f\n' \
| sortThis 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.txtCreate 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.txtFor 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/nullReplace 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.txtBackups 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 -lShared-memory path:
stat /dev/shm/payload-fileRecent PHP files:
find /home/accountname \
-type f \
-name '*.php' \
-mtime -2 \
-printf '%TY-%Tm-%Td %TH:%TM:%TS %p\n' \
| sortSuspicious upload PHP:
find /home/accountname \
-type f \
-path '*/wp-content/uploads/*' \
-name '*.php' \
-printAudit events:
ausearch -k infected_robots -i
ausearch -k infected_docroot -iNo 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 -50Check running processes owned by the account:
ps -u accountname -fChanging 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'
" accountnameUpdate plugins:
su -s /bin/bash -c "
wp plugin update --all \
--path='/home/accountname/infected-example.com'
" accountnameList themes:
su -s /bin/bash -c "
wp theme list \
--path='/home/accountname/infected-example.com'
" accountnameUpdate themes:
su -s /bin/bash -c "
wp theme update --all \
--path='/home/accountname/infected-example.com'
" accountnameRemove 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:
- A malicious payload existed in shared memory.
- Shell loops copied the payload into WordPress plugin directories repeatedly.
- A malicious per-minute cron job could restore or redownload malware.
- Spam pages had been planted across multiple document roots.
- Some files were protected with read-only or immutable attributes.
- Several WordPress core installations contained altered or damaged files.
robots.txtfiles 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.txtfiles were standardised- Incorrect sitemap domains were replaced
- Missing
robots.txtfiles 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.
