Back to feed
Jul 27, 2026 • 11 min read

107 Backdoors Behind a Full Disk: Anatomy of a WordPress Hack

#incident-response #wordpress #security #sysadmin #malware-analysis #debugging #hosting

The phone went off on a Saturday morning. A client's shop was down, and the hosting panel would not let them log in either. Correct username, correct password, and the form just came back at them with no error message at all.

I want to write this one down properly, because the path from "site is down" to "there is someone in here right now" took about twenty minutes, and almost none of it was clever. It was just running the boring commands in the right order.

First look

Before theorising about anything, two commands:

df -h
df -i

The root filesystem was at 100 percent. Not 96, not 99. Zero bytes available on a 150 gigabyte disk. Inodes were fine, so it was volume and not a swarm of empty files.

That immediately explained the panel login too. The panel writes a session file when you authenticate. With no space, that write fails, so there is no session to redirect into, so it renders the login form again. From a browser it is indistinguishable from a wrong password. I confirmed the credentials were fine by testing the candidate password against the stored hash directly, outside the panel:

import crypt
crypt.crypt(password, stored_hash) == stored_hash   # True

Worth internalising: a login form is a terrible witness to its own failure. When something says no without saying why, verify the input somewhere the failing system is not involved.

Finding the weight

Walk down from the root, one level at a time, and never guess:

du -xh --max-depth=1 / | sort -rh | head -15
du -sh /home/* | sort -rh

Use -x so you do not wander into bind mounts and count the same bytes twice. This host had every account bind mounted into a jail directory, and without -x the totals came out to more than the disk physically holds, which is a good way to waste ten minutes.

One account held 89 gigabytes. One site inside it held all of them. Inside that site, one cache directory held 88.

The next question is always whether it is a few huge files or a lot of small ones, because the answer points at completely different causes:

find . -type f -printf '%s\t%TY-%Tm-%Td %TH:%TM\t%p\n' | sort -rn | head -20
for d in */; do printf '%10s  %s\n' "$(find "$d" | wc -l)" "$d"; done | sort -rn

The largest single file was 20 megabytes. The directory held 291,596 files. Of those, 204,386 were orphaned .tmp files from the page cache plugin, roughly 400 kilobytes each, adding up to 74 gigabytes. All of them created within the previous seven days.

The plugin writes a temp file and renames it into place when the page finishes rendering. If the PHP worker dies first, the temp file stays forever and nothing ever cleans it up. Deleting them was safe and got the disk back to 49 percent, and the panel logged in on the first try afterwards.

That was the outage fixed. It was not the problem.

The thing that did not add up

Seven days. Two hundred thousand dead temp files. That is not normal traffic on a site this size, and dying PHP workers at that rate means something is hitting the box hard or something is wrong inside the application.

So I went looking, starting with the cheapest possible question: what changed recently?

SITE=/home/<user>/web/<domain>/public_html

find "$SITE" -path "$SITE/wp-content/cache" -prune -o \
  -type f -name '*.php' -newermt '2026-07-01' \
  -printf '%TY-%Tm-%Td %TH:%TM %10s %p\n' | sort

Prune the cache directory or you will be reading output for a very long time.

The first two lines were enough. Two plugin directories with names like wp2p_f0508b88 and galex_741afbdf, created eight days earlier. Randomised suffixes are a signature. No real plugin is named that way.

Confirming the shape of it

Once you have one sample you can hunt for the pattern. Grep for behaviour, not for names, and check every site on the box, not just the one that alerted:

grep -rlE '_REQUEST\["px"\]|chinafans|eval\(gzinflate|str_rot13|okk01' \
  /home/*/web/*/public_html --include='*.php'

Plus the structural tells, which catch things content grep misses:

# plugin directories with random hex suffixes
ls -d /home/*/web/*/public_html/wp-content/plugins/*_[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]*

# short hex directories dropped at the web root
find /home/*/web/*/public_html -maxdepth 1 -type d \
  -regextype posix-extended -regex '.*/[0-9a-f]{4,8}$'

# hidden PHP files, a classic hiding place
find /home/*/web/*/public_html/wp-content -name '.*.php'

That last one found a spam mailer sitting in a file called .156374890788147.php, which will never show up in an FTP client with default settings.

Final count on the affected site: 107 files carrying an injected backdoor. Eighty six of them were inside a well known contact form plugin. Someone had prepended a single line to the top of nearly every file in it:

error_reporting(0);@ini_set('display_errors',0);
if(isset($_REQUEST["px"])&&$_REQUEST["px"]==="<secret>"){ ... @passthru($__c); ... }

Send the right secret in a query string and you get shell command execution. In a directory listing those files look completely untouched. Same names, same sizes, roughly the same content. This is why "the file names look normal" is not evidence of anything.

Reading the payload

Two of the plugins held a heavier payload, wrapped in layers of obfuscation. The important part is that you never run it to find out what it does. You peel it statically. The chain here was base64, then a repeating key XOR, then gzinflate, twice over:

blob = "".join(re.findall(r"\.='([A-Za-z0-9+/=]+)';", src))
data = base64.b64decode(blob)
key  = base64.b64decode(embedded_key)
x    = bytes(data[i] ^ key[i % len(key)] for i in range(len(data)))
print(zlib.decompress(x, -15).decode())

Underneath was a full toolkit. Command execution, a file browser, exfiltration to a Telegram bot, a downloader that pulled a further payload from a public GitHub repository, a database manager dropper, a check for a known local privilege escalation, and a rule to serve a 404 to anything with "Google" in the user agent so the site would not get flagged in search results.

That last detail is the one I keep thinking about. It was built to stay quiet.

Where they got in

Here is the honest answer: I could not prove the initial entry point, and I am not going to pretend otherwise. Access logs had already rotated past the relevant window.

What the evidence does support is how they operated once inside. They held a WordPress administrator account, created a week before I looked, with a plausible name and an email address at a domain the site does not own. From there they used the built in theme and plugin file editor to write everything else:

grep 'plugin-editor.php' "$ACCESS_LOG" | awk '{print $1}' | sort | uniq -c | sort -rn

Ninety two requests from one address, in a tight burst, each one immediately followed by a request to the file it had just written to confirm the backdoor answered. You can read the whole session in the log like a transcript.

As for how they got that admin account in the first place, two conditions made it a matter of time. The security plugin had been inactive since January, so there was no WAF and no file change alerting for six months. And every plugin on the site was between one and several major versions behind.

Checking whether it went deeper

Before cleaning anything, establish the blast radius. A web shell running as the site user is bad. A compromised root is a rebuild.

find / -xdev -perm -4000 -type f 2>/dev/null    # unexpected SUID binaries
awk -F: '$3==0 {print}' /etc/passwd             # extra uid 0 accounts
for u in /var/spool/cron/crontabs/*; do echo "== $u"; grep -v '^#' "$u"; done
for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
  echo "== $f"; awk '{print $NF}' "$f"
done
ls -la --time-style=long-iso /etc/cron.d/
find /etc -xdev -newermt '2026-07-13' -type f

All clean. No stray SUID binaries, no second root account, no injected cron, no unfamiliar SSH keys, and every recent change under /etc was generated by the panel itself. The privilege escalation the payload probed for was patched on this kernel. The compromise stayed inside one account.

I also checked the database directly rather than through WordPress, because malware can filter itself out of the admin user list:

wp db query "SELECT ID, user_login, user_email, user_registered FROM wp_users;"

One rogue administrator, no hidden extras.

What I removed

In this order, because order matters. Cut the access before you delete the tools, or they will just write them again.

  1. Dropped the attacking addresses at the firewall.
  2. Set DISALLOW_FILE_EDIT in wp-config.php, which removes the exact mechanism they were using.
  3. Rotated the authentication salts with wp config shuffle-salts, which invalidates every logged in session including theirs.
  4. Deleted the rogue administrator and reset the legitimate one's password.
  5. Deleted the seven fake plugins, the fake theme, and the web root shells outright.
  6. Replaced the backdoored contact form plugin with a clean copy from the official source rather than trying to repair 86 files by hand.
  7. Stripped the injected line from the two legitimate files that had it, and ran php -l on each one afterwards.
  8. Rewrote a theme template that had been overwritten entirely.

Then re-ran every scan from the section above. Zero hits.

I kept a copy of everything in a directory outside the web root first. If you delete before you archive you lose the ability to answer questions later, and there are always questions later.

Alert on disk usage. Not a dashboard, an alert that reaches a person at 80 percent. This entire Saturday existed because a boring metric had no alarm on it. A full disk stops database writes, session writes, mail delivery, and log writing all at once, and each of those looks like a different bug.

Turn off file editing in WordPress. One line, and it removes the most convenient tool an attacker gets after stealing an admin session:

define('DISALLOW_FILE_EDIT', true);

Bound your leaks even if you cannot fix them. Properly fixing the cache plugin is a real project. Stopping it from filling a disk is one cron entry that deletes orphaned temp files older than an hour. A bounded leak cannot take down a server.

Update, and check that updates are actually happening. Every plugin here was months behind with automatic updates switched off. That is the condition that makes everything else possible.

Make sure your security plugin is still running. Nobody noticed it had been off since January. Whatever you use, monitor that it is alive, because a WAF that silently stopped working is worse than no WAF, since you think you are covered.

Verify your backups restore, not just that they ran. The most recent backup here was taken after the compromise, and it was incomplete anyway because the backup job had been failing for days against a full disk. Two failures compounding, both individually invisible.

Keep one account per site. This host does, and it is the only reason this is a story about one site rather than about nine. The blast radius was decided months before the incident, by a hosting layout choice.

The part that bothers me

The break in happened around the twentieth. The client called on the twenty-seventh. In between, the site served customers normally, took orders, and looked completely fine.

The only reason anyone found out is that an unrelated cache bug filled a disk and knocked the hosting panel offline. Without that, the shells would still be there.

Attacks that break things get fixed. Attacks that do not break anything are the ones that sit for months. Check the boring metrics.

© 2026 Kristijan Soldo