Anatomy of a Hacked WordPress
I was hired to do a maintenance audit for a small WooCommerce shop on shared hosting. The site kept going down, and that was the whole reason I was called. It had a brutal number of plugins, no SSL, no security plugins (not even Wordfence), and most of the standard things you’d expect weren’t there.
The funny thing was when the owner casually mentioned that a few days earlier the site had been “hacked and turned into a Turkish casino page”, and that a nephew had restored it and everything looked fine again.
Well… it wasn’t. Over FTP I found: a PHP file in the web root that decrypted something with AES and ran it, a folder of scripts named after programming languages, and a file manager in a folder pretending to belong to Let’s Encrypt, on a site with no certificate. So I took a full copy of the site and its database and went through it offline.
I’m sharing this because I find it interesting how much tooling you can see together in this kind of attack, and that’s why I felt like posting it, even if it’s nothing special, because in the end it’s all automated.
By the time I arrived the site had been compromised for at least two weeks. So this post is told in the order things happened, not in the order I found them. The first part, March 10 to 23, is everything from before I was there, rebuilt from what was left on the server. The second part starts on March 24, when I took control of the site.
Two things are missing from my copy. The nephew was trying to wipe the whole hack by restoring the site, and as far as I can tell both went with it. I point them out when we get to them:
- The casino page itself. I never saw it, only the remains of it that turned up later when I inspected the database.
- Three files from the web root. I saw them over FTP when I first connected, but they were gone by the time I took the full copy, so I only have my notes on them.
Almost everything else in this post survived his restores. The hack was noticed a few days before I arrived, but I think the restore itself was only a day or so before. After it the casino page was the only thing that stopped showing up, which is probably why he thought everything was clean, even though the rest had been there for a while and was still there.
A note on the screenshots: they are generated from the real code. The shop’s data, names and credentials are replaced with mock values for obvious reasons, and the important parts of the malware are redacted so that nothing here can be copied and reused.
Everything that was living on the server, and what it talked to.
The first fake admins
The oldest thing I can date is from March 10. The attackers wiped the usual logs, so the dates (all UTC) come from file timestamps, the users table, the attackers’ own log, and the error log of a marketing plugin that tried to sync every new user and failed. They cleaned every audit log they knew about, but not that one.
This is that marketing plugin’s error log:
2026-03-10 03:19:22 Unable to push contact adm3aqfag@example.com
2026-03-10 06:32:34 Unable to push contact bot@local.invalid
2026-03-11 08:34:01 Unable to push contact admin@wordpress.local
2026-03-12 08:31:43 Unable to push contact admq9srye@example.com
2026-03-12 08:31:52 Unable to push contact admh5extn@example.com
2026-03-12 11:05:10 Unable to push contact admln@example.com
The first malware files I found are from March 11, but there are two fake admins on March 10. And the real administrator’s account had an application password (a separate credential for the REST API) created two seconds before bot:
user_id: 2 (the shop's real administrator)
meta_key: _application_passwords
name: auto-bootstrap
created: 2026-03-10 06:32:32
last_used: never
A script, already logged in as the real administrator, made itself an API credential and then another administrator. Creating users and API keys is what you do once you’re inside, so the way in is older than this.
A plugin that is not Jetpack
On March 11 at 08:33, six files landed within nine seconds: three plugins and three must-use plugins (wp-content/mu-plugins: loaded on every request, can’t be deactivated, not shown in the Plugins screen), all with a 36 in the name. The kit’s own code calls it a bundle (// CONFIGURATION - Bundle 36), and the number is baked into every constant and function name, so the files of another bundle won’t have the same hashes. Sucuri has written about malware living in that folder in Hidden Malware Strikes Again: Mu-Plugins Under Attack.
The three plugins live in folders called jetpack-36, crontrol-36 and usersw-36. Jetpack is a real plugin, one of the most installed in WordPress and made by the company behind WordPress.com, and WP Crontrol and User Switching are real ones too. So in a plugins folder with thirty others, these names look like they belong there, and that’s the whole point of them.
On activation it:
- Created an administrator,
WildGuardian, hidden by adding a condition to the SQL of every user query (pre_user_query). - Published three casino articles (two German, one Spanish) from
.txtfiles it ships with, and saved their URLs to a CSV the operator can download. The shop’s domain has years of reputation with Google, and the attacker gets paid for those links. - Hid those posts from the owner everywhere WordPress lists posts. With the direct link they load normally.
- Made them undeletable: trashing one returns “This post cannot be deleted.”
I couldn’t find anything public about this exact kit. The closest is the one Sucuri describes in The Case of Hidden Spam Pages, which also hides its spam posts and removes the Delete option.
This is how the admin is created and then filtered out of every user query:

The posts are hidden the same way, with ordinary WordPress hooks:
pre_get_posts admin list, home, archives, search, feeds
wp_count_posts the All / Published counters
views_edit-post the "Mine" counter
rest_post_query the REST API and the block editor
wp_sitemaps_posts_query_args the XML sitemap
widget_posts_args the Recent Posts widget
It even subtracts its posts from the counters, so the numbers still add up:

The three posts are authored by the real administrator ('post_author' => get_current_user_id()), so the kit was first run from a session logged in as that account, which matches the application password from the day before.
Why deleting it did nothing. Each of the six files keeps copies of the others in nine hidden folders around wp-content, and on every page load each piece rewrites any that are missing or changed.

It has a remote control: four REST routes that look like WordPress core, with no permission check, only a key in a k parameter.
GET /wp-json/wp/v2/cache/status bundle ID, protected post IDs, CSV URL
GET /wp-json/wp/v2/cache/csv the list of planted posts
POST /wp-json/wp/v2/cache/repair redeploy every component
POST /wp-json/wp/v2/cache/cleanup flush caches, wipe logs, purge rival spam
Once an hour it recreates its admin if missing and empties the tables of the popular activity-log plugins, the login history of a firewall plugin and debug.log:

The same cleanup deletes any post matching a spam keyword list, except its own three. This attacker already expected other spammers on the same site.

Five shells in one minute
On March 12 three more adm…@example.com admins show up. On March 13 at 07:30, five web shells were written in the same minute, and they were five different tools:
- a signed shell with no password, with author, version and contact details in the banner. It’s on its author’s GitHub, as the banner says: LMNx9-JOHNY/WEB-SHELL,
- a fake “health monitor” plugin,
- a file manager packed as gzip+base64 across 42 variables, with the page title “Caterscam Corp.”,
- a “stealth” file manager with the page title “CORE PANEL”,
- a 106-line file manager with no login that calls itself “Tiny File Manager”. It borrows the name of a legitimate open-source project, not its code, and its save button says “Simpan”, which is Indonesian.
The health monitor answers ?wp_health with a harmless JSON status, so its operator can check thousands of sites and see which are still infected. The actual shell is behind a password.

The packed file manager splits its payload into 42 small pieces, so no single line looks like a big blob:

Join the 42 pieces, base64-decode and gunzip them, without running anything, and what comes out is a plain 226-line file manager. Most of its comments are in English, but four of them are in Indonesian:
<?php
error_reporting(0);
echo "<!DOCTYPE html>
<html>
<head>
<title>Caterscam Corp.</title>
[...]
// Tambahkan kelas 'visited' jika sudah diklik sebelumnya
[...]
// Handle delete
if (isset($_GET['del'])) {
[...]
// Tampilkan direktori dulu
[...]
// Lalu file
Every shell folder also got this .htaccess:

It blocks every script in the folder except index.php, which stops other attackers from dropping theirs there. It’s also the easiest way to find the shells. All fifteen folders I ended up finding had it:
grep -rl --include=.htaccess suspected .
Someone starts listening
On March 18 a new must-use plugin appeared, a “Honeypot Logger” (a little bit obvious with that name tbh) by “Internal Security”, with the description in Russian and a Telegram bot token and chat ID in plain text.

It sends everything interesting to a Telegram group. To a firewall that’s just HTTPS to a messaging app.
wp_login username, real IP, browser
user_register username, role, who created it, password in plain text
profile_update username, who changed it, new password in plain text
delete_user who was removed and by whom
admin_init any plugin or theme file that changed
upgrader_process_complete plugin installs, updates and removals
WordPress hashes your password before saving, but when you submit the form it’s still in $_POST:

It also creates its own hidden admin, sys_maintenance, on plugins_loaded. Most of what it watches isn’t about the shop, it’s about what other administrators are doing. And it kept a local copy of everything it sent in a log file, which survived. Most of the dates from here on come from it.
256 logins from 256 places
Less than five hours after the spy was installed, the log started filling up:

Between March 18 and 20 the real administrator account logged in 256 times from 256 different IPs, always as the same Yandex Browser on a Mac. That’s a bot using the admin’s credentials through residential proxies, so blocking IPs does nothing.
In those same days:
- March 19. Seven real pages (home, cart, checkout, legal notice…) got casino links no visitor could see: a block pushed off-screen with
top: -4321px, or a sentence slipped into a real paragraph. - March 20 and 22. Revisions appeared with Russian articles about yet another casino brand.
- March 20. A dropper was uploaded into the spy’s own log folder. Nothing malicious is stored in it: it takes hex from the request, XORs each byte with 39, writes it to a temp file, runs it and deletes it.
- March 20, at night.
wgetandcurllogged in with three different admin accounts in three seconds, the real one included. A script checking which credentials still worked.

The nephew’s cleanup
What the shop did notice was the site turning into a Turkish casino page. This is the first missing piece. I don’t have an exact date for it or a copy of the page, only the remains in the database: the nephew restored the site to get rid of it.
On March 23 he also logged in, deleted seven administrator accounts he didn’t recognise, and seven minutes later changed his own password. The spy reported all of it to Telegram, the new password included.
Five of the seven are the bot’s accounts from March 10 to 12. The other two, admins and crocoblock-supp, never show up in the marketing log, so I can’t date them. They are either older than everything else here, or the second one was a real support account from a plugin vendor.

That was a reasonable attempt to wipe the hack, and the shop looked normal again. But everything described so far was still there afterwards: the kit and its nine backup folders, the shells, the spy and the two hidden admins. He deleted every admin WordPress showed him, but those two are filtered out of the user list by the malware itself, so he was never going to see them from the dashboard.
The auto-bootstrap application password stayed too, because changing your password doesn’t revoke those. They are listed at the bottom of each user’s profile page, and almost nobody looks there.
My day 1
Everything above happened before I arrived. From here on it’s what I saw myself.
They called me because the site kept crashing. Nobody had asked me to look for malware. Luckily for the owner I’m used to dealing with it, and luckily he mentioned the hack. Otherwise I would have updated the plugins, fixed the crashes and left everything else running.
On March 24 I was given an account to start the audit, and the spy sent my password to Telegram too.
Then came the FTP session from the introduction. These three things were in the web root:
ok1.php, a loader. It rebuiltbase64_decodeandopenssl_decryptfrom hex strings behind chainedgotos, decrypted an AES-256-CBC blob and passed it toeval().test/, with subfolders named after programming languages.test/python/index.phpwas a green-on-black shell calling itself “CORE_V5”, with comments in Turkish (“Yeniden Yapılandırılmış Sürüm”), which fits the Turkish casino page.- A “Local File Manager” in
css/acme-challenge/, with a copy in.well-known/acme-challenge/. Its password,privdayz, was built character by character from ASCII codes, so it never appears as text in the file. It’s also the name of a site that distributes web shells,privdayz[.]com.
One of these shells also named its functions in leet: H0LO, h0l0_3d1t, h0l0_s4v3.
This is the second missing piece. I wrote down what these three did, but they aren’t in the copy I analysed. I don’t know for sure what happened to them. The nephew was still logging in those days, so my guess is that he cleaned the web root or restored it again before I pulled the full copy. So this part comes from my notes of that day, I have no timestamps for them, and I’ll never know what the encrypted one was running. The Turkish panel is the only trace I saw of whoever put up the casino page.
The hosting logs still showed visitors landing on one of the casino articles, and Google and OpenAI’s search bot requesting spam URLs like /listing/<number>/. The site had no SSH, so I downloaded it over FTP, just as I found it. If you have never downloaded more than 5 GB of tiny PHP files over FTP, one by one, I don’t recommend it as a hobby. I also downloaded a .wpress export, which is the one that had the database and the rest of the content, and went through all of it offline.
Pastebin, zip:// and an emoji
Offline, the .htaccess search took the shell count from five to fifteen:
33,850 wp-content/.tmb/css/index.php
16,516 wp-content/css/acme-challenge/Renderer/index.php
321,890 wp-admin/css/colors/ocean/abilities-api/index.php
321,890 wp-admin/js/widgets/acme-challenge/index.php
79,442 wp-admin/images/HTML/index.php
79,442 wp-admin/maint/wflogs/index.php
16,516 wp-includes/assets/img/index.php
81,791 wp-includes/abilities-api/acme-challenge/index.php
81,791 wp-includes/SimplePie/library/SimplePie/Decode/HTML/assets/index.php
8,234 wp-includes/Text/Diff/Renderer/images/index.php
These are the only part of the story I can’t place on the timeline: all ten came down over FTP and not in the .wpress, so every file has the time of my download. They could be from before or after the nephew’s restore. Most are in wp-admin and wp-includes, in folders named after Let’s Encrypt (acme-challenge), Wordfence (wflogs), which wasn’t even installed, or a real WordPress folder (abilities-api).
Most malware scanners look for known suspicious text, like eval( next to base64_decode. Each of the next three tricks is a way of not containing that text.
Loaders that contain nothing. They ask for a password, download PHP from a paste site and run it. The URL is stored as a list of character codes, so “pastebin” never appears in the file.

Both pastes were still online. Each one is yet another loader: a small class carrying a 19 KB payload encrypted with a password that the attacker sends in the request (_wp in one, _rg in the other), so you can’t see what it finally runs. One of them also starts by printing %PDF-0-1, to pass as a PDF.
A PHP file that is one line long. soup.php is a single require that uses PHP’s zip:// wrapper to load code from a zip called s, with no extension. A scanner sees one harmless line of PHP and a file it doesn’t open. Sucuri documented the same trick in Stealthy PHP Malware Uses ZIP Archive to Redirect WordPress Visitors.

The zip has a single entry called 87: 8 KB of PHP with 88 gotos and every string written as hex and octal escapes. Decoding only the strings is enough to see what it is:
"\x24\x32\x79\44\x31\60\44..." -> "$2y$10$..." a bcrypt hash, so there is a password in front of it
the other strings -> "pd", "url", "ts_url", "ts_reset", "updated : ", "success"
So it looks like one more loader: it asks for a password, takes a URL and remembers it in a ts_url cookie.
A shell hidden behind an emoji. Two files weighed 322 KB and looked like forty lines of PHP with one emoji in a string.

Behind that emoji there are 81,743 invisible characters. Unicode has 256 “variation selectors” that show nothing by themselves, and 256 values is exactly one byte, so the function turns each one into a byte and rebuilds a whole PHP file. Searching the original for eval or base64_decode finds nothing. It’s the trick Paul Butler described in Smuggling arbitrary data through an emoji, and MITRE tracks it as T1027.018, Invisible Unicode.
Decoded, it’s a file manager with the login switched off, a standalone PHP “Filemanager” by Den1xxx whose code points to a GitHub repository that is no longer online:

A fake WordPress header, a fake plugin header, and “authorize”:”0”.
One more administrator. Another file manager, with the page title “GreyFile”, had a “Create WP Admin” button that creates an admin called savvy. The third different hidden-admin mechanism on this site.
Getting rid of it
With the whole picture from the offline copy, I did the cleanup. Everything has to go in one pass, so nothing is left to restore from: the six “36” files and their nine backup folders, the spy and its logs, every folder with a guard .htaccess, the soup.php pairs, both hidden admins, the kit’s options and hourly task, the three posts, the hidden links and a few hundred poisoned revisions. Then every password again, this time with nobody listening.
For this I wrote a few scripts and .sql files, so I could go through everything and test it on the copy first: checking what was left in the database, deleting the metadata of the malicious users, the infected revisions, the hidden links in the pages, and so on. After each step I ran the checks again, until nothing came back.
The site was clean, but Google took longer. Search Console ended up showing more than 500,000 casino URLs indexed under this small shop’s domain.
So who was in there?
A guess from language and style, not proof. But taking into account that this is WordPress, it doesn’t look like one team, just the classic automation that targets these websites:
- The March 10 bot: automated, creates
adm…@example.comaccounts and an API credential. - The “36” kit: tidy, English comments, numbered by bundle. Industrial SEO spam.
- The March 13 batch: five unrelated shells, two of them with some Indonesian in them (a few comments in one, a button in the other), like a reseller’s starter pack.
- The Telegram spy: Russian, more interested in other admins than in the shop. I found nothing public about it either.
- The first shells I found: a Turkish panel, matching the Turkish casino page.
- The loaders in the core folders: the best at hiding. The
soup.phpones have Chinese characters in their comments. The Den1xxx file managers have a comment in Russian, but that comes from their original author.
Most likely, access to this site was sold or shared more than once, and each buyer left their own way back in. That explains the guard files, the kit that deletes rival spam and the spy that reports on other admins: they’re defences against each other.
How did they get in?
Probably the boring way, through an outdated plugin. The site had a premium plugin with an expired licence that wasn’t getting updates, and with that many plugins and nothing watching the site, there were plenty of doors.
The obvious suspect is a file-manager plugin running a version with security holes that weren’t patched. But its oldest traces on this install are from March 10 at 20:39, seventeen hours after the first fake admin, so it was most likely a tool the attackers installed and not the door they came through.
I can’t prove any of it. The kit wiped the logs every hour, and the oldest thing I can date is that first fake admin on March 10. The two admin accounts I can’t date suggest the access may be older than that.
Indicators of compromise
Most of these tools are public and linked above. For the ones I couldn’t find anywhere (the “36” kit, the Telegram spy and the “Caterscam Corp.”, “CORE PANEL” and “GreyFile” file managers), the hashes are in this list.
For anyone hunting the same tooling. NN is the bundle number, 36 in this case.
Files
wp-content/plugins/jetpack-NN/
wp-content/plugins/crontrol-NN/
wp-content/plugins/usersw-NN/
wp-content/mu-plugins/sunrise-NN.php
wp-content/mu-plugins/db-NN.php
wp-content/mu-plugins/maintenance-NN.php
wp-content/mu-plugins/honeypot-core.php
wp-content/logs/honeypot.log
wp-content/logs/honeypot-admin.log
wp-content/cache/.objects/NN.csv
*-NN.dat and NN.csv in hidden folders under wp-content
soup.php next to an extensionless file named "s"
ok1.php and test/<language>/index.php in the web root
any .php inside css/acme-challenge/ or .well-known/acme-challenge/
.htaccess containing the string "suspected"
index.php inside acme-challenge/, abilities-api/ or wflogs/ within core folders
Hidden backup folders under wp-content
uploads/<year>/<month>/.thumbnails/
uploads/.cache-dir/
upgrade/.temp/
upgrade/core/.backup/
cache/.objects/
languages/.cache/
themes/.starter-starter/
fonts/.woff-cache/
debug/.logs/
Database
Users: WildGuardian, sys_maintenance, savvy, bot, adm<5-6 random chars>
E-mails: admin@wordpress.local, admin_<timestamp>@<site>, savvy@domain.com,
bot@local.invalid, adm<random>@example.com
Options: wp_NN_adm, wp_NN_hp, wp_NN_pf, wp_NN_ci, honeypot_file_hashes
User meta: honeypot_hidden_user, _honeypot_just_created,
_application_passwords containing the name "auto-bootstrap"
Cron task: wp_cache_gc_NN
In content: "top: -4321px", "data-sheets-root", "in-cell-link"
In shells: "CORE_V5", h0l0_3d1t, h0l0_s4v3, password "privdayz"
Spam URLs: /listing/<number>/
Network
Telegram bot ID: 7659365610
Telegram chat ID: -1002560914839
Remote payloads: pastebin[.]com/raw/RcPgq10V
pastebin[.]com/raw/Sp6PiQLV
Control API: /wp-json/wp/v2/cache/status
/wp-json/wp/v2/cache/csv
/wp-json/wp/v2/cache/repair
/wp-json/wp/v2/cache/cleanup
Query parameters: wp_health, create_wp_user, ts_reset, val, k, _wp, _rg
Cookies: ts_url, wp_health_sid, fm_user
Response header: X-WP-Health: active
User agent: YaBrowser/23.1.2.980 on "Intel Mac OS X 10_15_7", one IP per login
Spam destinations
w3-win[.]com
casino-midas[.]net
sgcasino-espana[.]com
sgspain[.]com
wazambacasino-es[.]top
casinogratogana-es[.]top
casinado-casino[.]org
casino-savaspin[.]de
casino-coolzino[.]de
coolzinospain[.]es
win-unique-casino[.]es
malinacasino-app[.]es
locowincasinos[.]es
casino-my-empire[.]com
SHA-256
7b81fc363613a67d0d6fc8bee0c64dbb6aabd93b579c680ed5213defc13b42ec Telegram spy
36731559cfb2fe60bf5c858acc0ba98a53d766c7f9954ccd56271e59cdaa18f1 spam kit, main plugin
4b65eceee9c10443a610064d6baf2c703d745c8b386920120416a76c092f69bf spam kit, admin creator
940963dc2f1da6c02c320111d91067e1a9e50bc0c7e8d9fceeea8740c5042f92 spam kit, restorer plugin
c340e563b5970cb0c14ada00bd79057ad25c8b82e4bfaff1982f0f40bb8b3f7c spam kit, must-use core
02a51076adb289065a0a7f2bbe8706c6829b0aecb142dbd70238134cf3a0eebd signed shell
3b8d1069704cea49131fbebf21591120c13649e4fb64b8c199e65dd99f89b06c "health monitor" shell
27cfeadb85e6eadd701324ef86ea01ccfa07854c91a913e352a29ab7ebb14624 soup.php
8bf3a82723fd4f02d74fb0ce10bde47bc61019b840527095a9b9bfcd6fab4a6f XOR dropper
baf2a94b842b507c140ca2f1c2532208eb4f941e9f774bdbae26f9a7648d8913 packed file manager ("Caterscam Corp.")
b9ade1b619f670ca561eded654632121933dfecc87ebc15cd49393a964bd43ef Tiny File Manager, no login
3a4d703389545dd2b311eb34b5ce22570e59528e2d1429053329bcf7d8341164 "CORE PANEL" stealth file manager
9867c30d3a7868a53487ace88b836d563a1c82c0046855270a4eb51adf08ada8 "GreyFile" manager with "Create WP Admin"
ed66bc868fae34f5fbaac2733e4ef2840577a1eb4718783512effff2b79dd18e paste loader, hex-wrapped (2 copies)
b855b9f1d71167a8286b72b9e8af431cb87886162aa9d55a8a80b8f45f34f03e paste loader, goto-obfuscated
dce0ebb8e16fa68b264c486380d6e7590fda6a1082f7322d36dc790720c60b25 emoji loader (2 copies)
c85cc09a8da052c2f18506cce7e32eb210b3647315bf8f545194b8ce7743a895 Den1xxx Filemanager variant
89cad47cd8229776beb7ac9024c1b7eb9a9a9fb37a7d5e5e10c0b447cfa2157a Den1xxx Filemanager variant
a676e80dddb8dda105558f3ba46d81ef8603e5e790fab10deeb5d9ae4d50b4ff Den1xxx Filemanager with fake plugin header (2 copies)
b35535e548302d369fc45c793a4e11322e67b0b480c0a5b178779b0dc2754c3f zip "s" next to soup.php
cd82122e94ce1f664ee403d286f228d1caf156e7d44513bc03bff9d09e150ce0 zip "s" next to soup.php
97aaba83e1258532d4797ec6a7a7b38e74a0357dc4ac7c30c3284186f46da55a zip "s" next to soup.php
e7c1e01ca01e3742676f6fcfe382ed1bde6fd766d591d844e22105aa7bba1f91 pastebin payload RcPgq10V
30ec43b7ecbe2bd9e2f62cca9efe20571aa93ecb2720360769ee864d84b8aa61 pastebin payload Sp6PiQLV
The kit’s hashes will differ in other bundles, because the bundle number is baked into each file.