Guide

Is your WordPress site running this?

Detection and removal guide for the commercial SEO parasite described in The Malware Asked Who Was Asking. That essay is the story. This is the checklist.

Lachy McGregor - 07/09/2026

Copies the whole guide as plain text with a short framing prompt, so you can paste it into ChatGPT, Claude or whatever you use and have it walk you through the checks on your own site.

This is written for whoever is holding a WordPress site at eleven at night wondering whether the thing described in the essay is on it.

Everything below was tested on a real infection in August 2026. The kit is commercial and built to be deployed on thousands of sites, so if you are reading this because something felt off, the odds are reasonable that what you have is the same product or a close relative. None of the checks need special tools. You need SFTP or SSH access to the site, access to the database, and a terminal.

One rule before anything else: the admin screens are lying to you.

This malware filters what WordPress shows you and corrects the post counts so the numbers agree. Nothing you see in wp-admin can be trusted until the code is gone. Work from the files and the database directly.

Where the commands go

Every command in this guide is labelled with one of four tags. Each block also has a "What this does" section you can open if you want to understand it before you run it.

SQLphpMyAdmin. Your hosting control panel links to it (WP Engine: the install's Overview page; cPanel: the Databases section). Click your site's database in the left column, then the SQL tab, paste, press Go. Every query assumes the table prefix is wp_. If your tables are named like wpx7_posts, change wp_ to match. The prefix is in wp-config.php as $table_prefix. SELECT only reads. DELETE changes things, so back up first.
ShellA terminal. If your host gives you SSH, log in and run from the site root, the folder containing wp-config.php. If your host only gives SFTP, which is common on managed WordPress hosting, download the folder in question and run the same commands on your own computer. Windows: Git Bash or WSL. Mac: Terminal. Everything used is a standard Unix tool. Nothing needs installing.
BrowserThe address bar. Type the URL and look at what comes back.
Look forDo not run this. It is a fragment of what the malware looks like, for you to search a file for.

1. Five checks that take ten minutes

You do not need to do all five. One positive result is enough to move to section 2.

Check 1. Do two lists of posts disagree?

BrowserThree addresses, then a count in wp-admin.
yoursite.com/post-sitemap.xml
yoursite.com/wp-json/wp/v2/posts?per_page=100
What this does
post-sitemap.xml
The list of posts your SEO plugin hands to Google. Yoast names it this; All in One SEO and others use sitemap.xml with a posts sub-sitemap. Count the <url> entries.
wp-json/wp/v2/posts?per_page=100
WordPress's own REST API listing published posts. Count the entries (each starts with "id":). If you have more than 100 posts, add &page=2.
wp-admin
Posts, All Posts, the "Published" count.

All three come from the same table. They should agree. This malware filters the REST API and the admin list but not the SEO plugin's sitemap, so the sitemap is the one that tells the truth.

All three should match. If the sitemap is larger than the other two, posts exist that WordPress is hiding from its own interfaces.

Check 2. Are there posts owned by nobody?

SQLphpMyAdmin, SQL tab. Read-only.
SELECT DISTINCT post_author FROM wp_posts
WHERE post_author NOT IN (SELECT ID FROM wp_users);
What this does
SELECT DISTINCT post_author FROM wp_posts
List every author ID that appears on any post, once each.
WHERE post_author NOT IN (SELECT ID FROM wp_users)
Keep only the ones that do not match a real user account.

Every legitimate post is owned by a user. The kit assigns its posts to made-up author numbers, because "author 7023" is cheaper to hide than a real account and never shows up in the Users screen. An empty result is clean.

Any result is bad. This kit uses four-digit author IDs that match no user as campaign identifiers. On the infected site they were 7011 and 7023. Yours may differ.

Check 3. Are the hidden text blocks in the database?

SQLphpMyAdmin, SQL tab. Read-only.
SELECT COUNT(*) FROM wp_options WHERE option_name LIKE 'home_links_custom_%';
What this does
SELECT COUNT(*) FROM wp_options
Count rows in the settings table.
WHERE option_name LIKE 'home_links_custom_%'
Only rows whose name starts with home_links_custom_. The % is a wildcard for "anything after this".

The kit stores its hundred hidden-text variants as home_links_custom_0 to _99. No legitimate plugin uses that name. The operators randomise almost everything else per site, but not this.

Zero is clean. Anything else is this kit.

Check 4. Is there a private sitemap?

BrowserTry each, and try the numbers either side.
yoursite.com/sitemap23.xml
yoursite.com/?feed=xmlsitemap23
What this does

The kit registers its own feed named xmlsitemapNN and a rewrite rule mapping sitemapNN.xml to it, where NN is a campaign number (23 on the infected site). It lists only the campaign's posts, so Google finds them even though the real sitemap plugin does not know about them. A 404 on both is clean. A valid XML sitemap that your SEO plugin did not make is the kit.

Check 5. Does Google see a different page from you?

ShellAny terminal, on any computer. Does not need site access.
curl -sL -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" https://yoursite.com/ > as-google.html
curl -sL https://yoursite.com/ > as-browser.html
diff as-google.html as-browser.html
What this does
curl -sL
Fetch a page. -s keeps it quiet, -L follows redirects (most sites bounce from the bare domain to www, and without -L you would save the redirect instead of the page).
-A "Mozilla/5.0 (compatible; Googlebot/2.1; ...)"
Send Google's crawler user agent so the site thinks Google is asking.
> as-google.html
Save the response to a file.
diff as-google.html as-browser.html
Show every line that differs between the two saved pages. No output means identical.

Then search both files for opacity:0.003, left:-5000px, casino, pokies and slot. The kit rotates between ten hiding techniques keyed to your hostname, so grep for the words rather than the CSS. Note that this kit cloaks by IP address rather than user agent, so from your own machine both fetches get the version with the payload attached. That is fine. You are reading the HTML, not running it, and the point is to see the hidden block at all.

2. Before you remove anything

Take a full backup of files and database first. Not to restore from, since it contains the malware, but as evidence and as a safety net if a removal step breaks the site.

Pull the session tokens before you reset any passwords. This is the single most useful piece of evidence you will get, and a password reset does not clear it, but you want it captured while it is intact:

SQLphpMyAdmin, SQL tab. Read-only. Export the result or copy it somewhere.
SELECT u.user_login, m.meta_value FROM wp_usermeta m
JOIN wp_users u ON u.ID = m.user_id WHERE m.meta_key = 'session_tokens';
What this does
FROM wp_usermeta m JOIN wp_users u ON u.ID = m.user_id
Pair each row of per-user metadata with the user it belongs to, so you get a username instead of a number.
WHERE m.meta_key = 'session_tokens'
Only the row that holds that user's active login sessions.
SELECT u.user_login, m.meta_value
Show the username and the raw session data.

The value is a serialised PHP array with one entry per live session, each carrying the IP address, user agent and login time. A normal account holds one or two. The compromised account on the infected site held 62, one per attacker IP. That tells you which account the operator is using and gives you the addresses to look for in the logs.

Pull your access logs now. Most managed hosts retain 48 hours or less. The two commands that separate attacker activity from background noise:

ShellTerminal, in the folder where you saved the log file. Change access.log to its name.
# successful logins by IP: a 302 on a login POST is success, 200 is failure
grep '"POST /wp-login' access.log | grep '" 302 ' | awk '{print $1}' | sort | uniq -c | sort -rn

# PHP files being requested that are not standard: 200 means it exists, 404 is scanner noise
grep -oE '"(GET|POST) /wp-[^ ?"]+\.php[^"]*" [0-9]{3}' access.log | sort | uniq -c | sort -rn
What this does
grep '"POST /wp-login' access.log
Keep only lines where someone submitted the login form.
| grep '" 302 '
Of those, keep the ones the server answered with a redirect. WordPress redirects on a successful login and re-shows the form (200) on a failed one, so 302 means the password was right.
| awk '{print $1}'
Take just the first column, the client IP.
| sort | uniq -c | sort -rn
Count how many times each IP appears and list the busiest first.
grep -oE '"(GET|POST) /wp-[^ ?"]+\.php[^"]*" [0-9]{3}'
Pull out just the request path and status code for every request to a PHP file under a wp- directory. -o prints only the matching part, -E enables the pattern syntax.

In the second list, anything ending in 404 is the internet guessing at files that do not exist, which every site on earth receives constantly. Anything you do not recognise ending in 200 is a file that exists on your server and is being used.

Check the status code before reacting to a path. Half of what looks alarming in a log is the internet knocking on doors that do not exist.

3. Find the injector

The kit lives in one of three places. Check all three even after you find it in one, because on the infected site there were two independent injectors and the second had survived a professional cleanup for eleven months.

Place 1. The active theme's functions.php

Open wp-content/themes/<your active theme>/functions.php and look for a block delimited at both ends by a comment that is a bare 32-character hex string:

Look forSearch the file for /* followed by 32 letters and digits.
/* c369598fbf4b5b5b98513261d3a30ad9 */
What this does

Yours will be a different hash. The two identical comments mark the start and end of the injected block so the operator's tooling can find and update it later. Between them is around 400 lines of clean, readable PHP with no obfuscation. Every function is named after a real WordPress function with a word bolted on: get_sidebar_double, is_singular_cookie, wp_footer_branch, get_template_part_compiler. It reads like theme code, which is the point.

The tell is this line, which nothing legitimate does:

Look forSearch the file for md5(sha1(.
get_option(md5(sha1($_SERVER['HTTP_HOST'])))
What this does
$_SERVER['HTTP_HOST']
Your site's hostname, as sent by the visitor's browser.
md5(sha1(...))
Hash it twice, producing a 32-character string that is different for every hostname.
get_option(...)
Read a WordPress setting with that string as its name.

So the kit's configuration is stored under a name that is never written in the code and differs on every infected site. A list of known-bad option names finds nothing. This line is how it loads its settings, and it is the single most reliable signature of this kit in a file.

Place 2. A code-snippet plugin's database rows

If WPCode, Code Snippets, or any plugin that stores and runs PHP is installed, read every snippet it holds. Do not just list them.

SQLphpMyAdmin, SQL tab. Read-only. Then click each row's ID to open post_content and read the code.
SELECT ID, post_title, post_date, post_status, LENGTH(post_content)
FROM wp_posts WHERE post_type IN ('wpcode', 'code_snippets', 'snippet');
What this does
FROM wp_posts WHERE post_type IN ('wpcode', 'code_snippets', 'snippet')
Snippet plugins store each snippet as a row in the posts table with a special type. This pulls those rows and nothing else.
SELECT ID, post_title, post_date, post_status, LENGTH(post_content)
Show the ID, its title, when it was created, whether it is active (publish), and how long the code is.

Then read the actual code of every one. On the infected site the malicious snippet was titled "Completely Disable Comments", lifted from the plugin's own example library, and its code did nothing of the sort. It hooked template_include at maximum priority, scanned every request for gambling keywords, turned 404s into 200s, and rewrote noindex to index. It is not a file. Core reinstalls and file scanners cannot see it.

Place 3. The autoloaded options

SQLphpMyAdmin, SQL tab. Read-only.
SELECT option_name, LENGTH(option_value) FROM wp_options
WHERE autoload = 'yes' ORDER BY 2 DESC LIMIT 25;
What this does
WHERE autoload = 'yes'
Only settings WordPress loads on every single page view.
ORDER BY 2 DESC LIMIT 25
Sort by the second column, the size, largest first, and show the top 25.

The kit needs its configuration on every request, so it marks it autoload. In a normal site every name in the top 25 is recognisable: a theme, a plugin, WordPress itself. An option named as 32 hex characters, a few kilobytes in size, is the kit's configuration. You will also find wp_custom_range (cached Google IP ranges) and possibly wp_custom_filters.

4. Find the backdoors

Removing the injector does nothing if the operator can walk back in. On the infected site there were 34 backdoor files, in four different toolkits, and the operator kept using them for nine hours after the WordPress password was dead.

Rule zero: compare, do not scan.

WordPress core is a fixed, public set of files. Every file in wp-includes and wp-admin is byte-identical on every site running that version. So the question is never "does this look malicious". It is "should this file exist at all":

ShellTerminal. Download wp-includes, wp-admin and wp-includes/version.php into a folder called site first, then run from the folder that contains site.
grep 'wp_version =' site/wp-includes/version.php                 # e.g. 7.0.4
curl -sSL -o wp.zip https://wordpress.org/wordpress-7.0.4.zip && unzip -q wp.zip -d ref

# files on the site that are not in the official release
comm -23 <(cd site/wp-includes && find . -type f | sort) \
         <(cd ref/wordpress/wp-includes && find . -type f | sort)
comm -23 <(cd site/wp-admin && find . -type f | sort) \
         <(cd ref/wordpress/wp-admin && find . -type f | sort)

# files that are in both but have been changed
(cd site && find wp-includes wp-admin -type f -exec md5sum {} + | sort -k2) > site.md5
(cd ref/wordpress && find wp-includes wp-admin -type f -exec md5sum {} + | sort -k2) > ref.md5
join -j 2 -o 0,1.1,2.1 site.md5 ref.md5 | awk '$2!=$3 {print $1}'
What this does
grep 'wp_version =' site/wp-includes/version.php
Find out which WordPress version the site runs. Use that number in the next line.
curl -sSL -o wp.zip https://wordpress.org/wordpress-7.0.4.zip && unzip -q wp.zip -d ref
Download the official release of exactly that version and unpack it into a folder called ref.
find . -type f | sort
List every file under a folder, alphabetically.
comm -23 <(list A) <(list B)
Compare two sorted lists and print only the lines that are in A but not in B. So: every file on your site that is not in the official release. The <( ) wrapper feeds a command's output in as if it were a file.
find ... -exec md5sum {} +
Compute a fingerprint of every file's contents.
join ... | awk '$2!=$3 {print $1}'
Line the two fingerprint lists up by filename and print any file whose fingerprint on your site differs from the official one. That is a modified core file.

Two lists come out: files that should not exist, and files that have been altered. This has no false negatives and needs no signatures. On the infected site it found sixteen files in ten minutes, including four that no pattern in this guide would have caught. Expect the "should not exist" list to include a few harmless things from your host, so check each one rather than deleting the list blindly.

Rule one: no PHP belongs under uploads. Ever.

ShellTerminal, from the site root, or from the folder you downloaded wp-content into.
find wp-content/uploads -type f \( -name "*.php" -o -name "*.phtml" -o -name "*.phar" \)
What this does
find wp-content/uploads -type f
Walk every file under the uploads folder.
\( -name "*.php" -o -name "*.phtml" -o -name "*.phar" \)
Keep only those with a PHP-executable extension. The -o means "or"; the escaped brackets group the three tests.

Uploads holds media. Every hit is either a plugin datastore that announces itself (sucuri-settings.php opens with <?php exit(0); ?>, and Smush and similar leave an index.php containing only a comment) or malware. Also look for a year folder with no month folders inside it. WordPress never creates one of those.

Then the heuristics, for wp-content where there is nothing to compare against

ShellTerminal, from the site root or your downloaded copy. Run each line separately.
# the loader that reassembles a split web shell: tiny, and always present
find . -name "*.php" -size -1k -exec grep -l "include_once __DIR__" {} \;

# the unauthenticated remote code loaders, which are fixed sizes
find . -type f -name "*.php" \( -size 403c -o -size 1117c \)

# the rest of the split-shell suite, also fixed sizes
find . -type f -name "*.php" \( -size 446c -o -size 11512c -o -size 3559c -o -size 3507c -o -size 2824c \)

# four-line remote loaders: a GET parameter fetched and executed
grep -rlE "eval\((curl_exec|file_get_contents)" --include="*.php" .

# files that shadow a core filename with a word appended
find wp-includes wp-admin -name "class-*-*.php" | while read f; do
  [ -f "ref/wordpress/$f" ] || echo "$f"
done
What this does
find . -name "*.php" -size -1k -exec grep -l "include_once __DIR__" {} \;
Every PHP file under 1 KB that contains the phrase include_once __DIR__. The split web shell is reassembled by a 446-byte loader that does nothing but include eleven sibling files. Nothing legitimate is that small and does that.
find . -type f -name "*.php" \( -size 403c -o -size 1117c \)
PHP files that are exactly 403 or 1,117 bytes. c means bytes. Those are the two remote-loader variants, and their size never changes between deployments.
... -size 446c -o -size 11512c ...
Same idea for the other fixed-size pieces of the shell suite.
grep -rlE "eval\((curl_exec|file_get_contents)" --include="*.php" .
Search every PHP file (-r recursive, -l list filenames only) for code that fetches something over the network and executes it directly. That construct has no legitimate use in a WordPress site.
find wp-includes wp-admin -name "class-*-*.php" | while read f; do [ -f "ref/wordpress/$f" ] || echo "$f"; done
For every file whose name looks like a core class file, check whether the official release has a file at that same path. Print the ones it does not. This catches names like class-smtp-integer.php sitting beside the real class-smtp.php. It needs the ref folder from rule zero.

Filenames are worthless as an indicator. The operator randomises them per deployment. Sizes are not, because the payloads are identical every time.

Two more places file scanners skip

ShellTerminal, from the site root or your downloaded copy.
ls wp-content/mu-plugins/
ls wp-content/db.php wp-content/object-cache.php wp-content/advanced-cache.php
What this does
ls wp-content/mu-plugins/
List the "must-use" plugins folder. Anything in here runs on every request and does not appear in the normal Plugins screen. Managed hosts put their own files here, so expect a few. Read any you do not recognise.
ls wp-content/db.php ...
Check for the three "drop-in" files. If present, WordPress loads them before anything else, including before security plugins. On most sites none exist, and "No such file" for all three is the good answer. If one exists and you did not install a caching plugin that uses it, read it.
SQLphpMyAdmin, SQL tab. Read-only.
SELECT option_value FROM wp_options WHERE option_name = 'cron';
What this does

Pulls the list of scheduled tasks. WordPress runs these on a timer. Malware commonly registers one to re-plant itself after a cleanup, which is the most common reason a cleaned site comes back infected a day later. The value is a serialised array; search it for hook names you do not recognise. Legitimate entries are named after WordPress and your plugins (wp_update_plugins, wpseo_..., woocommerce_...).

Do not sort by date. Every planted file on the infected site had its timestamp forged to match the legitimate files beside it, some by four years. If you have shell access, sort by permissions instead: files written by the web server process carry a different mode (644) from files written by the host's deployment tooling (664). That signal does not survive an SFTP download to Windows, so read it on the server or not at all.

5. Remove, in this order

Order matters. The admin view lies while the injector is live, so the injector goes first.

  1. Cut the injector block from functions.php. Everything between the two hex markers, inclusive. Check the file still starts with <?php and the theme's real code follows. Edit it over SFTP, not through the WordPress theme editor.
  2. Delete the snippet rows from any code-snippet plugin, then delete the plugin.
  3. Delete the configuration options:
    SQLphpMyAdmin, SQL tab. Changes data. Replace the placeholder with the name from section 3, place 3.
    DELETE FROM wp_options WHERE option_name = '<the 32 hex name>'
       OR option_name LIKE 'home_links_custom_%'
       OR option_name IN ('wp_custom_range', 'wp_custom_filters');
    What this does

    Removes the kit's configuration row, its hundred hidden-text rows, and its two helper rows, in one statement. The three OR conditions each match one of those groups. Nothing else in wp_options matches any of them. phpMyAdmin will tell you how many rows were deleted; on the infected site it was 102.

  4. Delete the campaign posts, including trashed ones:
    SQLphpMyAdmin, SQL tab. Changes data. Replace the placeholder with the IDs from check 2, comma-separated.
    DELETE FROM wp_postmeta WHERE post_id IN (SELECT ID FROM wp_posts WHERE post_author IN (<orphan IDs>));
    DELETE FROM wp_posts WHERE post_author IN (<orphan IDs>);
    What this does
    First statement
    Deletes the metadata rows (SEO fields, thumbnails, custom fields) attached to every post owned by a phantom author. This goes first because once the posts are gone there is no way to find their metadata.
    Second statement
    Deletes the posts themselves. Because it matches on author rather than status, it takes published, draft and trashed ones together.

    Do this in SQL rather than through wp-admin, because wp-admin cannot see these posts while the injector is live, and may still be caching the filtered view afterwards.

  5. Delete every backdoor file from section 4. Keep a list with full paths. You will need it in section 7.
  6. Delete file-manager and theme-editor plugins. WP File Manager and Theme Editor were not exploited on the infected site. They were used, as designed, by someone holding a valid login. They are admin-to-shell by design.
  7. Flush rewrite rules. Settings, Permalinks, Save. Only meaningful after step 1, because the injector reinstates its rule on every request.

6. Close the door

Cleanup without this is theatre. The infected site had been competently cleaned eleven months earlier and reinfected through the same credential because nobody rotated it.

  1. Reset every administrator password. All of them.
  2. Delete every session token. A password reset does not end a session. This does:
    SQLphpMyAdmin, SQL tab. Changes data. Logs everyone out, including you.
    DELETE FROM wp_usermeta WHERE meta_key = 'session_tokens';
    What this does

    Removes the stored sessions for every user. Every login cookie in existence for the site stops working at once, including the attacker's 62. Everyone, you included, logs in again with the new passwords. Safe to run at any time; it deletes nothing but sessions.

  3. Rotate the eight authentication salts in wp-config.php. If the operator ever read that file, and they had file-read access, they can mint valid admin cookies without a password until the salts change. Generate new ones at api.wordpress.org/secret-key/1.1/salt/, paste them over the existing eight define lines, and check the quotes balance on each line before uploading, because a broken wp-config.php takes the whole site down. Keep a copy of the original.
  4. Set define('DISALLOW_FILE_EDIT', true); in the same edit. It removes the code editors from wp-admin, so a stolen login can no longer write PHP through the browser.
  5. Remove administrator accounts that do not need to exist. Any account with no email address was created by code. Any agency account that is not actively in use is an open door.
  6. Ask your host to reissue the database password and any host API key that appears in wp-config.php. On managed hosts this cannot be self-served.
  7. Check Google Search Console for users and ownership verification you did not add. Look in the site root for google*.html verification files. An attacker's verification file gives them Search Console access that survives everything you did on the server.
  8. Turn on two-factor authentication for every remaining admin.
  9. If the compromised credential belonged to an agency, tell them, and tell them to check their own machine before rotating anything else. Credentials that arrive via an infostealer came off a laptop, and rotating fifty client passwords from that laptop hands over fifty new ones.

7. Verify by asking the machine

Do not trust that the deletions happened. Request every removed path and read the status code:

ShellAny terminal. First save the list from section 5 step 5 as removed-paths.txt, one path per line, relative to the site root.
UA="Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
while read p; do
  printf '%s  ' "$p"; curl -sL -A "$UA" -o /dev/null -w '%{http_code} %{size_download}\n' "https://yoursite.com/$p"
done < removed-paths.txt
What this does
UA="..."
Store the Googlebot user agent in a variable so the next line stays readable.
while read p; do ... done < removed-paths.txt
Run the middle line once for each line of the file, with $p set to that path.
printf '%s ' "$p"
Print the path, without a newline, so the result lands beside it.
curl -sL -A "$UA" -o /dev/null -w '%{http_code} %{size_download}\n' "https://yoursite.com/$p"
Fetch the path as Googlebot, throw the body away (-o /dev/null), and print only the status code and the byte count.

Every line should be 404, or 403 on hosts that block PHP under uploads. A 200 with a zero-byte body is a live backdoor waiting for a parameter it has not been given. On the infected site two of those were still there three hours after the delete commands were written, because the commands were never run. Nothing about the response looked wrong. Only the status code knew.

Then repeat check 5 from section 1. The Googlebot response and the browser response should now be byte-for-byte identical. The entire attack depended on them differing.

Then check post-sitemap.xml again. The count should match wp-json and wp-admin.

Then request a review in Search Console if there is a manual action.

8. Indicators

Reusable on any WordPress site. Filenames are per-deployment and are listed only as examples of the naming pattern.

Attacker infrastructure, as of September 2026

IndicatorRole
lambercheesered.comredirect payload host, 2026 campaign, /leoallgames.js
earthquake-jke.comcasino landing page framed by the above
charbroiledburgers.orgredirect payload host, 2025 campaign
apiary-fm.comshell platform credential server, /mshell_cred.json
wp-file-manager.comshell platform payload host, /mshell/ (not the real plugin)
croco.cxreferenced in injector
counter.yadro.rulegitimate Russian analytics counter, abused for visitor tracking

Database

  • Autoloaded option named as 32 hex characters
  • home_links_custom_0 through _99, wp_custom_range, wp_custom_filters
  • post_author values with no matching user
  • post_type of wpcode or similar whose code does not match its title
  • Any account with more than a handful of session tokens

Filesystem

  • Paired /* <32 hex> */ markers in a theme functions.php
  • Any file in wp-includes or wp-admin absent from the official release
  • Any .php under wp-content/uploads
  • PHP files of exactly 446, 403 or 1,117 bytes
  • Eight-letter lowercase random filenames (mhsrvbxw.php) in wp-admin/images/, wp-includes/Text/, or inactive default themes
  • Core filenames with a word appended: class-smtp-integer.php, options-reading-http.php, theme-editor-exception.php, class-ftp-sockets-reference.php, class-wp-simplepie-file-cookie.php
  • Animal-word filenames under uploads (rabbit.php, dove.php, leopard.php, modules/snake.php)
  • Variable names built from WordPress function names plus a suffix: $add_image_size_tdr, $number_format_i18n_tps
  • wp-config.txt or any wp-config* copy anywhere under wp-content

HTTP

  • sitemapNN.xml resolving but unknown to your SEO plugin
  • ?feed=xmlsitemapNN
  • Outbound fetches of gstatic.com/ipranges/goog.txt from the web server
  • Different response for a Google IP than for anyone else

Antivirus

Windows Defender detects the remote loader source as Backdoor:PHP/Dirtelti.JA. Expect any write-up containing real samples to be quarantined. Keep samples inside documents, not in files with executable extensions.

9. Report it

The kit is undocumented anywhere public as of September 2026. If you find it, the people who can do something with that are:

  • Wordfence, samples@wordfence.com, and Sucuri Labs, labs@sucuri.net (password-protected zip, password virus). They write the signatures.
  • abuse.ch ThreatFox for the domains, if you observed them live within the last ten days.
  • Google Safe Browsing, for the redirect domains.
  • Your host's security team. They can look across their other customers.
  • In Australia, ReportCyber at cyber.gov.au, or 1300 292 371.

Notes for readers: the infected site and the agency that maintained it are not named. Every command above was run against a live infection on a managed WordPress host in August 2026. WordPress releases for hash comparison are at wordpress.org. Google's crawler IP ranges are at gstatic.com/ipranges/goog.txt.