Skip to content
Back to Articles

Hidden Admins, Hidden Links: The Fake WordPress Plugin That Turned a Site Into a Casino Link Farm

Share:

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.php and gettest.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: Any WP_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/users
  • users_list_table_query_args: The Users list table in wp-admin
  • wp_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:

  1. 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".
  2. 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.
  3. 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_gmt clustered in the attack window, even when post_date claimed 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.

  1. 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.
  2. Copy every malicious file to an evidence folder outside the web root and compare hashes before deleting the originals.
  3. Remove the files: the fake plugin directory, the loaders, the stubs.
  4. Delete the spam database rows children first: term relationships, post meta, comments, revisions, posts, then term taxonomy and terms. Recount terms after.
  5. 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.
  6. Flush the object cache, transients and every page-cache layer, including a reverse proxy like Varnish if there is one.
  7. 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_query or users_list_table_query_args that is not a membership or role plugin you installed.
  • wp_users AUTO_INCREMENT higher than MAX(ID) + 1.
  • Page or revision content containing left:-9999px, left:-5000px or overflow:hidden;height:1px wrapped around a link.
  • Options with names like *_api_credentials*, *link_template* or link_render_mode that 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.