Two administrator accounts that nobody created. That was the whole complaint. The accounts were already gone by the time I looked, and the site looked normal. But the real story was not the accounts. It was what they had been used for: a full, professionally structured fake plugin, a few tiny loaders, around 300 spam posts, and casino links hidden inside the site's own legitimate pages, including their revision history.
This article walks through each layer, how it hides, and how to find and remove it without breaking the real site. Everything is sanitized: no site names, no IPs. The technique is what matters.
🧭 The layers at a glance
- A fake plugin of 77 files, laid out like a modern Composer project (bootstrap/, config/, sdk/, src/Adapter/...). It hides admin users, exposes a remote command API, and injects links into menus and the footer.
- Four tiny PHP loaders (91–115 bytes each) in the web root,
wp-content/,uploads/and a sibling directory outside the WordPress tree. - Zero-byte stubs with names like
dragonshell.phpandgettest.php, left behind after something emptied them. - About 300 spam posts (casino, pharma, adult, crypto) with forged dates, plus their revisions, post meta and 27 spam categories.
- Hidden link blocks spliced into 11 real pages and 53 of their revisions, positioned off-screen so the owner never sees them.
- Plugin options in the database holding the operator's API credentials, link templates and a "render mode" switch.
🎭 Layer 1: a fake plugin that looks like real engineering
Most fake plugins are one ugly file. This one was not. It had a composer.json, a .gitignore, empty .gitkeep files in resources/, and a clean hexagonal layout:
wp-content/plugins/<fake-editor>/
├── bootstrap/ autoload.php compat.php kernel.php ...
├── config/ api.php plugin.php environment.php ...
├── sdk/src/
│ ├── Application/RegisterSite/ RegisterSiteHandler.php
│ ├── Application/Sync/ SyncHandler.php
│ └── Infrastructure/Http/ CurlApiClient.php
└── src/Adapter/
├── Admin/ RenderUser.php Menu.php ...
├── Http/ RestController.php LegacyRestAdapter.php
├── Frontend/ LinkHandler.php
└── Services/ ApiInfoService.php PluginUpdateUpgraderService.php ...
Every file had the same modification time to the nanosecond, and the main plugin file was zero bytes. That last detail matters: with an empty main file, WordPress does not list it as an active plugin, so it never shows up in active_plugins. Something else loads it.
The giveaway was in the strings. Every hook name and every route was hex-encoded:
\add_action("\x70\x72\x65\x5f\x67\x65\x74\x5f\x75\x73\x65\x72\x73", [self::class, "\x66\x61\x6c\x6c\x55\x73\x65\x72\x4c\x69\x73\x74"], 10, 1);
\register_rest_route($namespace, "\x2f\x73\x65\x6e\x64\x2d\x63\x72\x65\x64\x65\x6e\x74\x69\x61\x6c\x73", [ ... ]);
Decoded, these are pre_get_users and /send-credentials. Real plugins do not hex-encode the name of a WordPress hook. A quick way to decode them all at once:
grep -rhoE '"(\\x[0-9a-f]{2})+"' wp-content/plugins/<suspect>/ \
| sort -u \
| while read -r s; do printf '%s → %b\n' "$s" "$(echo "$s" | tr -d '"')"; done
👻 Layer 2: hiding the attacker's admin accounts
One class, RenderUser.php, did only one thing: remove certain users from every place an owner might look. Decoded, it hooked five points:
pre_get_users: AnyWP_User_Query(Users screen, plugins that list users)pre_user_query: The raw SQL of the user query (also fixes the count)rest_user_query:/wp-json/wp/v2/usersusers_list_table_query_args: The Users list table in wp-adminwp_dropdown_users: Author dropdowns in the post editor
A simplified version of the idea looks like this:
add_action('pre_get_users', function ($q) {
$hidden = get_option('<plugin>_hidden_ids', []);
$q->set('exclude', array_merge((array) $q->get('exclude'), $hidden));
});
The lesson: never trust the Users screen during an investigation. Ask the database directly, and compare the table's AUTO_INCREMENT with the highest ID. A gap means accounts were created and later removed.
SELECT u.ID, u.user_login, u.user_registered, m.meta_value AS caps
FROM wp_users u
JOIN wp_usermeta m ON m.user_id = u.ID AND m.meta_key = 'wp_capabilities'
WHERE m.meta_value LIKE '%administrator%';
SELECT AUTO_INCREMENT FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wp_users';
SELECT MAX(ID) FROM wp_users;
In this case the counter was ahead of the highest ID by two. Two accounts had existed and were gone. That matched the complaint exactly.
📡 Layer 3: a remote command API, with two ways in
RestController.php registered a private REST namespace with ten routes. Decoded:
/command /create-post /data /status
/update-links /delete-links /raw /set-render-mode
/send-credentials /update-plugin
That is a complete remote control panel: publish posts, change which links are injected, switch how they render, update the plugin itself, and send credentials back. An ApiAuthGuard class sat on rest_pre_dispatch so only the operator's signed requests got through.
The clever part was the fallback. Many hardened sites disable the REST API. This site returned 404 for /wp-json/ entirely. The plugin did not care: LegacyRestAdapter.php hooked template_redirect at priority 0 and dispatched the same commands from ordinary front-end requests. Blocking REST does not block a plugin that brings its own router.
The sdk/ folder handled the other direction: a RegisterSiteHandler and CurlApiClient that announce the compromised site to the operator's server, so it joins their inventory of sites ready to sell links on.
🔗 Layer 4: link injection, three different ways
LinkHandler.php added links through wp_nav_menu_items and wp_footer, using templates stored in the database. But the persistent damage was in the content itself. Eleven real pages had blocks like these spliced between paragraphs:
<span aria-hidden="true" style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;">
<a href="https://casino-site.example/">best online casino</a>
</span>
<div style="overflow:hidden;height:1px;">
<a href="https://casino-site.example/">non gamstop casinos</a>
</div>
<span style="position:absolute;left:-5000px;top:auto;width:1px;height:1px;overflow:hidden;">
<a href="https://casino-site.example/">...</a>
</span>
Off-screen to a human, perfectly readable to a search engine crawler. Three things made this harder than it looks:
- Revisions. Every edit WordPress saves creates a revision. Cleaning the page alone leaves 53 infected copies in the revision history, ready to come back with one click on "Restore".
- Variants. Most blocks held one link, but one page had blocks with two to four links inside a single wrapper. A regex written for one link silently skipped them.
- Legitimate content around them. These were real pages with shortcodes, page-builder rows and forms. Deleting them was not an option.
Counting each shape per page first gives you an expected number to verify against later:
SELECT ID, post_type,
(CHAR_LENGTH(post_content) - CHAR_LENGTH(REPLACE(post_content,'left:-9999px','')))
/ CHAR_LENGTH('left:-9999px') AS shape_a,
(CHAR_LENGTH(post_content) - CHAR_LENGTH(REPLACE(post_content,'overflow:hidden;height:1px','')))
/ CHAR_LENGTH('overflow:hidden;height:1px') AS shape_b
FROM wp_posts
WHERE post_content REGEXP 'left:-9999px|left:-5000px|overflow:hidden;height:1px';
Use CHAR_LENGTH, not LENGTH. With multibyte content LENGTH counts bytes and the numbers drift.
🧪 Layer 5: tiny loaders and empty stubs
Four small files carried the same comment marker and the same shape:
<?php @error_reporting(0); $junk = '...'; eval("?>" . $undefined_var); }
/* 8819240c:xxxx */
By the time I saw them the payload variable had been stripped and the stray } caused a parse error, so they were not working. They were still attacker droppers and still had to go. One of them sat in a sibling directory next to the web root, outside the WordPress tree, where a WordPress-only scan never looks. The PHP error log told the history: the same file first threw parse errors from mu-plugins/, then the same marker variables appeared in the theme's functions.php, before both were moved or cleaned.
The zero-byte stubs (cap.php, dragonshell.php, gettest.php, a dotfile in mu-plugins/) are harmless on their own, but they are the attacker's bookmarks. Remove them. Before you delete anything in the web root, check .user.ini and .htaccess for an auto_prepend_file that points at it, or the whole site returns 500.
📰 Layer 6: the spam posts, and why one query is not enough
The first automated scan flagged about a hundred spam posts. There were nearly three hundred. The rest were published later, with forged dates, under the same author, and mixed topics (casino, then pharma, then "AI companion", then crypto) that no single keyword catches.
What found them all was treating them as a cohort instead of matching words:
- same author ID;
- a continuous ID range (IDs are assigned in insertion order; dates can be forged, IDs cannot);
- the spam categories they were filed under;
post_modified_gmtclustered in the attack window, even whenpost_dateclaimed months earlier.
SELECT ID, post_status, post_date_gmt, post_modified_gmt, LEFT(post_title,80)
FROM wp_posts
WHERE post_author = <AUTHOR> AND post_type = 'post' AND ID BETWEEN <FIRST> AND <LAST>
ORDER BY ID;
Then check for the long tail: the revisions whose post_parent is a spam post, the postmeta rows, the term_relationships, and the spam categories themselves. Each of those is its own DELETE, and they go children first.
🛠️ Cleaning it without breaking the site
The order matters more than the commands.
- Full database dump first, verified non-zero. Then one targeted dump per category you will touch (spam posts, revisions, meta, terms, options, infected pages). If anyone ever asks "did you delete X?", the full dump answers it.
- Copy every malicious file to an evidence folder outside the web root and compare hashes before deleting the originals.
- Remove the files: the fake plugin directory, the loaders, the stubs.
- Delete the spam database rows children first: term relationships, post meta, comments, revisions, posts, then term taxonomy and terms. Recount terms after.
- Strip the hidden blocks from pages and revisions with a dry run first. Write the content back through prepared statements, never by escaping strings by hand.
- Flush the object cache, transients and every page-cache layer, including a reverse proxy like Varnish if there is one.
- Verify at the origin, not through the CDN: each cleaned page returns 200 and contains zero off-screen wrappers; each spam URL returns 404.
The page cleanup was a small PHP script run from the command line. The important parts are the dry run, the multi-link pattern and the residual check:
$patterns = [
'~<span aria-hidden="true" style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;">(?:<a [^>]*>[^<]*</a>)+</span>~',
'~<div style="overflow:hidden;height:1px;">(?:<a [^>]*>[^<]*</a>)+</div>~',
'~ ?<span style="position:absolute;left:-5000px;top:auto;width:1px;height:1px;overflow:hidden;">(?:<a [^>]*>[^<]*</a>)+</span>~',
];
$apply = in_array('--apply', $argv, true);
foreach ($ids as $id) {
$old = $db->query("SELECT post_content FROM wp_posts WHERE ID=" . (int) $id)->fetch_row()[0];
$new = preg_replace($patterns, '', $old);
$residual = ($new === null) ? 1 : preg_match('~left:-9999px|left:-5000px|overflow:hidden;height:1px~', $new);
if ($residual) { echo "SKIP $id (variant left)\n"; continue; }
echo "OK $id " . strlen($old) . " -> " . strlen($new) . "\n";
if ($apply) {
$st = $db->prepare("UPDATE wp_posts SET post_content=? WHERE ID=?");
$st->bind_param('si', $new, $id);
$st->execute();
}
}
The first dry run reported six SKIPs: one page and its five revisions. Those were the multi-link variant. Without the residual check they would have been "cleaned" and still carried links. The (?:<a ...>...</a>)+ group fixed it, and the next dry run returned 64 OK, 0 SKIP.
🔍 Detection checklist
- A plugin folder whose main file is empty, or whose files all share one identical mtime down to the nanosecond.
- Hex-encoded strings that decode to WordPress hook names (
pre_get_users,rest_pre_dispatch,template_redirect,wp_footer). - Any plugin hooking
pre_user_queryorusers_list_table_query_argsthat is not a membership or role plugin you installed. wp_usersAUTO_INCREMENThigher thanMAX(ID) + 1.- Page or revision content containing
left:-9999px,left:-5000pxoroverflow:hidden;height:1pxwrapped around a link. - Options with names like
*_api_credentials*,*link_template*orlink_render_modethat no installed plugin owns. - PHP files outside the WordPress tree but inside the account, especially next to the web root.
- Posts whose IDs are newer than their neighbours but whose dates are months older.
💡 Takeaways
- The admin accounts in the complaint were the door, not the infection. Follow what the accounts were used for.
- A fake plugin can look more professional than a real one. Judge it by what it hooks, not by how it is organized.
- Disabling the REST API is not a defence against a plugin that ships its own front-end router.
- Cleaning pages means cleaning their revisions too.
- When a scanner flags part of a spam campaign, expand by author, ID range and category until the cohort is closed.
- Dump before you delete, dry-run before you write, verify at the origin.
❓ Frequently asked questions
Why didn't the hidden admin accounts show up in the Users screen?
The fake plugin filtered every user query WordPress makes (wp-admin list, REST API, author dropdowns) and removed specific IDs. Querying the database directly bypasses those filters.
Is disabling the WordPress REST API enough to block this kind of plugin?
No. This plugin had a fallback that read the same commands from ordinary front-end requests through template_redirect, so it kept working with /wp-json/ returning 404.
Why clean post revisions as well as the pages?
Revisions are full copies of the page content. If they still contain the hidden links, restoring any old revision brings the links back.
How do you find spam posts with forged dates?
Use the post ID order and post_modified_gmt instead of post_date. IDs are assigned in insertion order and cannot be backdated.