Skip to content
Back to Articles

Deleted Files, Same Infection: The Casino Kit That Kept a Full Copy of Itself in wp_options

Share:

The complaint was simple: the server's security scanner kept reporting infected files, the files kept coming back after every cleanup, and every other tool the owner tried said the site was clean. Both statements were true. The live site was mostly clean. The infection was sitting in two places nobody thinks of as "the site": the WordPress database, and a security plugin's own quarantine folder.

This article walks through a casino SEO kit that stores a complete copy of itself in wp_options, re-drops its files from there on a schedule, and locks its helper files with permissions so a casual rm fails. It also explains why the kit's copies inside a quarantine folder kept the scanner's alert open for days. Everything is sanitized: no site names, no IPs. The kit used one random 8-character hex marker per install, shown below as <id>.

🧭 The layers at a glance

  • A payload store in the database: 17 options named _wp_cg_<id>_*, about 13.8 MB in total, holding every file of the kit as base64 plus a map of where each file goes.
  • A watchdog outside the web root: /tmp/cron-helper-<id>.php, with 14 hidden marker files next to it, that rebuilds anything that goes missing.
  • An hourly WP-Cron event with a harmless-sounding name, wp_cache_maintenance_hourly_<id>, that triggers the re-drop on normal page traffic.
  • Hidden helper plugins and mu-plugins named cache-helper-<id>, db-helper-<id> and maintenance-helper-<id>, two of them only three lines long.
  • A fake copy of a well-known anti-spam plugin (folder name = real slug + -<id>) carrying about 265 casino doorway pages, a page template and a CSV index.
  • A fake inactive theme with a plainly named .zip inside it. The zip held a PHP web shell.
  • A casino page in the database, plus its revision, two customizer changesets, a custom-CSS revision, two attachments and 16 casino images in uploads/. All created by one administrator account registered the same day.

🗄️ Layer 1: the database is the real payload

Most kits keep their payload on disk and use the database only for settings. This one did the opposite. Its largest option was a single serialized PHP array of about 13.8 million characters. Each array key was a base64-encoded file, and each value was the list of paths where that file should be written. Smaller sibling options held the file map, the doorway page list, the spam page content and a rollback copy of the theme footer.

A quick count of the kit's options looks like this:

SELECT option_id, option_name, CHAR_LENGTH(option_value) AS len, autoload
FROM wp_options
WHERE option_name LIKE '\_wp\_cg\_%'
ORDER BY len DESC;

Do not decode these with a browser-based tool or by writing them to a file inside the site. Decode them in memory and only print hashes, sizes and the first bytes. That is enough to see what is inside without creating another copy for a scanner to find:

<?php
// Read-only: hash every file stored in the payload option, never write it out.
$db  = new mysqli('localhost', 'DB_USER', 'DB_PASS', 'DB_NAME');
$row = $db->query("SELECT option_value FROM wp_options WHERE option_name LIKE '\\_wp\\_cg\\_%\\_fs'")->fetch_row();
foreach (unserialize($row[0]) as $b64 => $targets) {
    $code = base64_decode($b64, true);
    printf("%s %8d  %s\n", hash('sha256', $code), strlen($code),
           is_array($targets) ? reset($targets) : '');
}

In this case the store held the watchdog, the helper plugins and mu-plugins, a full single-file web file manager, the doorway pages and a clean copy of the active theme's footer. Removing every file on disk without removing these options would have been pointless: the next cron tick would have written them all back.

⏱️ Layer 2: an hourly cron event that looks like caching

The kit registered a normal WP-Cron event. WP-Cron runs on visitor traffic, so the kit did not need a real system crontab to stay alive. The hook name was built to blend in with caching plugins:

wp cron event list --fields=hook,next_run_relative,recurrence | grep -iE 'cache_maint|helper'
# wp_cache_maintenance_hourly_<id>   in 41 minutes   1 hour

Its callback reads the payload options, compares each target path with the stored hash, and rewrites any file that is missing or different. That is why the infected files "came back": every cleanup that only touched the file system was undone within the hour.

🐕 Layer 3: a watchdog in /tmp

The second persistence layer lived outside the web root, in /tmp/cron-helper-<id>.php. Its configuration was a block of constants, each holding a base64-encoded serialized value: the WordPress root, the list of directories to repopulate, the drop map, the option prefix, the theme footer path, a lock directory and a few process names to look like PHP session or cache helpers.

define('_T_AB',  unserialize(base64_decode('...')));  // WordPress root
define('_T_DIRS',unserialize(base64_decode('...')));  // directories to refill
define('_T_MAP', unserialize(base64_decode('...')));  // file => drop path
define('_T_XP',  unserialize(base64_decode('...')));  // "_wp_cg_<id>_"
define('_T_CD',  unserialize(base64_decode('...')));  // /etc/cron.d/<random>

Each value decodes cleanly with base64 -d once you strip the PHP around it. The watchdog also tried to add itself to the user crontab through shell_exec('crontab ...'), and to write a five-minute entry into /etc/cron.d/ if that directory was writable. Next to it were 14 hidden files named /tmp/.cgph-<id>-*.ok. They are completion markers: the watchdog writes one per finished step, so it knows what it has already rebuilt.

Scanners that only look at the document root never see any of this. Always include /tmp and the account's home directory when you search for a marker:

find "$HOME" /tmp -name '*<id>*' -printf '%T+ %m %u %s %p\n' 2>/dev/null | sort
ls -la /tmp/.cgph-* 2>/dev/null

🔒 Layer 4: three-line stubs that lock their own permissions

Two of the mu-plugins had no payload at all. Their only job was to make themselves hard to delete:

<?php
// DB helper
if(!defined('ABSPATH'))exit;if((@fileperms(__FILE__)&0777)!==0444)@chmod(__FILE__,0444);if(((int)@fileperms(dirname(__FILE__))&0777)!==0555)@chmod(dirname(__FILE__),0555);

On every request the file sets itself to 0444 and its directory to 0555. A read-only directory means nothing inside it can be removed or renamed by a user who does not own it, even if the files themselves look harmless. Because these stubs contain no dangerous function calls, a signature scanner has very little to match on.

The cache-helper-<id> plugin was the dropper. It declared a normal plugin header ("Cache maintenance helper"), filtered all_plugins so it would not appear in the plugin list, and read the same _wp_cg_<id>_ option prefix as the watchdog.

🎰 Layer 5: doorway pages, a fake plugin and a casino page

The spam itself had two forms. On disk, a fake copy of a popular anti-spam plugin held about 265 static .htm doorway pages, a large .txt page source, a single-fallback.php page template that wraps the spam in the site's own header and footer, and a CSV index of URLs stored under wp-content/cache/. A small .htaccess with Require all granted made sure the index stayed reachable even inside a protected folder.

In the database there was a casino landing page with hreflang and canonical tags pointing to an external casino domain. Around it sat everything WordPress creates when someone builds a page through the admin: a revision, two customize_changeset posts, a custom-CSS revision and two attachments. All of them had the same author: an administrator account registered the day the kit appeared. Searching by author ID instead of by keyword is what finds the whole cohort:

SELECT ID, post_type, post_status, post_date, post_name
FROM wp_posts
WHERE post_author = 123    -- the suspicious account
ORDER BY ID;

🧳 Layer 6: a web shell in a zip, in a theme nobody uses

The last piece was a fake theme with a generic name, an empty index.php and a minimal style.css. It was never activated. Inside it was a zip archive with an ordinary product-sounding name. The PHP file inside the zip used eval and shell_exec on request input: a classic web shell, kept as an archive so it could be unpacked again later. Most scanners do not open archives in theme folders, and an inactive theme is easy to overlook. List the contents without extracting:

find wp-content/themes -name '*.zip' -printf '%T+ %s %p\n'
unzip -l wp-content/themes/<theme>/<file>.zip
unzip -p wp-content/themes/<theme>/<file>.zip '*.php' | grep -oE '.{0,80}(eval|shell_exec|assert)\s*\(.{0,80}'

🧪 Why the alert never closed: the quarantine folder

The site had a security plugin that does its own quarantine. Every time the kit re-dropped itself, the plugin moved the new files into wp-content/<plugin>-quarantine/<random>.quarantined/. Over two days that folder collected about 630 kit files: helper plugins, stubs, the page template, the .htaccess and hundreds of doorway pages.

Quarantine here meant "moved and blocked from the web", not "deleted". The folder sat inside the document root, so the server-level scanner kept finding real malware there and kept reporting it. The owner's other tools reported the site as clean because they checked the public pages and the active code, which were indeed clean. Both views were correct, and the result was a ticket that would not close.

Two lessons follow:

  • A plugin's quarantine folder inside the web root is still part of the site. Inventory it like any other directory, by hash.
  • Do not delete quarantine folders blindly either. Group the files by hash, confirm which hashes are malware, and leave the plugin's own index.php, .htaccess and any clean copies in place.
Q=wp-content/<plugin>-quarantine
find "$Q" -type f -size +0 -print0 | xargs -0 sha256sum | awk '{print $1}' | sort | uniq -c | sort -rn | head -40
find "$Q" -type f -size +0 | sed -E 's/.*\.([A-Za-z0-9]+)$/\1/' | sort | uniq -c | sort -rn

The quarantine folder was also locked. Its subdirectories were 0555 and owned by the web application user, not the SSH user. In /tmp, the sticky bit stopped one user from deleting another user's files. The deletion had to run as the file owner: a one-time PHP script, protected by a token, that removed a file only if its SHA-256 matched the approved list, and then deleted itself.

🛠️ Cleaning it in the right order

Order matters with self-healing malware. If you delete files first, the cron event puts them back. If you delete the options first while the watchdog is still running, it may still have its own copy of the drop map. The sequence that worked:

  1. Back up everything. A full database dump, targeted dumps of every option and post you will delete, and a tar of every file before removal. Pack evidence straight from the originals: in this case, real-time scanning emptied PHP copies placed elsewhere on the account within seconds.
  2. Remove the watchdog and its markers in /tmp, and check the user crontab and /etc/cron.d/ for entries pointing to it.
  3. Delete the WP-Cron event and confirm with a fresh wp cron event list.
  4. Delete the payload options (_wp_cg_<id>_*).
  5. Remove the files: helper plugins, stubs, the fake plugin, the fake theme with its zip, the casino images, and the hash-confirmed kit copies in the quarantine folder.
  6. Delete the spam posts by ID with their post meta, including changesets, CSS revisions and attachments.
  7. Flush every cache layer: object cache, page cache and any reverse proxy in front of the site.
  8. Verify at the origin, not through the CDN. The homepage should contain no casino strings, the spam URLs should return 404, and a full file and database rescan should report nothing.

Leave the suspicious administrator account to the site owner. Report it, with its registration date and the content it created, but let the owner remove it and reset credentials. Deleting users during a cleanup creates questions later that a database backup cannot always answer.

curl -sk -H "Host: example.com" https://127.0.0.1/ | grep -ciE 'casino|<id>'      # expect 0
curl -sk -o /dev/null -w '%{http_code}\n' -H "Host: example.com" https://127.0.0.1/<spam-slug>/   # expect 404

🔍 Detection checklist

  • Options whose names share a prefix with a random hex marker, especially if one of them is several megabytes.
  • Serialized arrays whose keys are long base64 strings. Legitimate plugins almost never do this.
  • WP-Cron hooks with "cache", "maintenance" or "helper" in the name that no installed plugin registers.
  • PHP files in /tmp, and hidden .ok or lock files next to them.
  • mu-plugins whose only code is chmod() on themselves and their directory.
  • Plugin folders named like a well-known plugin plus a suffix, or plugins that filter all_plugins.
  • Archives inside theme or plugin folders, particularly in themes that are not active.
  • A security plugin's quarantine folder inside the web root that keeps growing.
  • Posts, changesets and attachments whose author is a recently registered administrator.

💡 Takeaways

  • If malware keeps coming back, look for where the copy lives. Here it was the database, not a file.
  • WP-Cron is enough for persistence. The attacker does not need shell access or a system crontab.
  • "Quarantined" is not "removed". A quarantine folder in the web root still counts as infected content.
  • Tiny files with no dangerous functions can still be part of the attack. Judge them by what they do.
  • Clean the persistence first, then the payload store, then the files, and verify at the origin.

❓ Frequently asked questions

Why did the malware come back after the files were deleted?

A complete copy of every file was stored as base64 in the WordPress options table. An hourly WP-Cron event and a watchdog script in /tmp read that copy and rewrote any missing file.

Why did one scanner report infection while other tools said the site was clean?

The public pages and active code were clean. The kit's files were sitting in a security plugin's quarantine folder inside the web root, and server-level scanning kept finding them there.

Is it safe to delete a security plugin's quarantine folder?

Not blindly. Hash the files, confirm which hashes are malware, remove only those, and keep the plugin's own index.php, .htaccess and any clean file copies.

Why could the files not be deleted over SSH?

The kit set its folders to read-only (0555), and the files were owned by the web application user. The deletion had to run as that user, with a hash check on every file.