The Symptom Pattern That Tells You It’s a Database Hack
Wordfence and Sucuri both returned clean. Google Search Console is still flagging cloaking. Your client’s mobile visitors are hitting a gambling redirect, but when you log in and browse the site yourself, everything loads normally—and that combination of facts means your file-based scanners missed the payload entirely because it’s waiting in the database, not on disk. Conditional redirect malware that checks for a logged-in cookie, a specific user-agent, or a referrer before firing produces no scannable artifact in your WordPress files; the decision to redirect happens only at runtime when PHP evaluates a real HTTP request that matches the attacker’s targeting criteria. A file scanner reads static bytes and finds nothing alarming. A remote crawler with no session cookie and a known user-agent never triggers the condition. The payload sits in wp_options serialized data, in wp_postmeta JavaScript fields, or inside Gutenberg block JSON, syntactically valid and invisible to signature-matching until you know exactly where to look and how to decode what you find.
Why Conditional Redirects Are Invisible to File Scanners by Design
A file scanner does exactly what the name says — it reads bytes stored on disk and compares them against known malicious patterns. That process never executes PHP. It never evaluates an if statement, never inspects a cookie header, never checks the incoming user-agent string. A redirect payload that lives behind a runtime condition produces no scannable artifact on disk, because the decision to redirect happens only when PHP processes a real HTTP request that matches the targeting criteria. The scanner reads the raw bytes, finds nothing alarming, and returns clean — correctly, from its perspective.
Sucuri’s analysis of a documented wp_options campaign shows how this works in practice. The payload checked two conditions before doing anything: whether the wordpress_logged_in_* cookie was absent, and whether the request URI did not contain /wp-admin. Stripped to its logic, the guard looks like this:
if ( ! isset( $_COOKIE['wordpress_logged_in_HASH'] )
&& strpos( $_SERVER['REQUEST_URI'], '/wp-admin' ) === false ) {
// fire the redirect
header( 'Location: https://malicious-destination.example/' );
exit;
}
Any request that carries a logged-in cookie — meaning every visit from an authenticated admin — passes through without being redirected. The scanner’s unauthenticated request also passes through. The only visitor who sees the redirect is exactly the one the attacker wants: an anonymous visitor arriving from search, on mobile, with no WordPress session.
Remote scanners face the same structural problem. When SiteCheck or a similar external crawler requests your site, it sends a known crawler user-agent, no referrer cookie, and a clean session. A conditional payload written to target, say, Windows users arriving from Google search will simply not fire for that request profile. Campaign analysis confirms this pattern — obfuscation, API calls, and conditional triggers are documented techniques for reducing detection risk in redirect malware. The scanner isn’t failing; it’s just asking the wrong question to see the payload.
PHP serialized data adds another layer of cover. WordPress uses serialization to store structured values as strings in the database — a widget configuration stored in wp_options might look like a:2:{s:5:"title";s:10:"Recent Posts";s:4:"code";s:47:"eval(base64_decode('bWFsaWNpb3VzIGNvZGU='));"}. The a:2 means an array with two elements; each s:N prefix is a byte-count for the string that follows. When the injection is placed correctly — with the byte-count updated to match the payload length — the serialized structure is syntactically valid and WordPress unserializes it without complaint. The PHP files on disk are untouched, which is why file-layer signature rules don’t see it. An eval(base64_decode(...)) string sitting inside a widget’s option_value column isn’t flagged by file scanning because no file contains it.
Hand-editing serialized data without updating the byte-counts breaks the option entirely — WordPress throws an unserialization error and the widget or theme setting stops working. Attackers who know what they’re doing update the counts correctly, which is precisely what makes a well-formed malicious payload visually indistinguishable from a legitimate long widget configuration during a casual phpMyAdmin browse.
Where the Payloads Actually Live — and the SQL to Find Them
Before running any of the queries below, one constraint matters: these are read-only SELECT statements. They show you what’s there; they change nothing. Any UPDATE or DELETE is a separate decision that requires a full database backup first — a complete mysqldump or host-level snapshot, not just a plugin export.
wp_options: The Named Keys Worth Checking First
Sucuri has documented multiple campaigns in which attackers modify the siteurl and home values in wp_options to redirect visitors to malicious destinations. The R_Evil hacktool specifically automated this across multiple installations at scale. Verifying these two values is the fastest first step:
SELECT option_name, option_value
FROM wp_options
WHERE option_name IN ('siteurl', 'home');
If those look correct, the injection is more likely hiding in a subtler key. Campaign analysis documents malicious JavaScript and iframes injected into widget content and theme settings stored in wp_options — entries like widget_text, widget_block, or theme_mods_{your-theme-name}, where a serialized array buries one malicious string among dozens of legitimate configuration values. Patchstack’s redirect hack guide notes that malicious scripts in wp_options are frequently stored as serialized data embedded within legitimate-looking structures, which is exactly why a casual browse through phpMyAdmin won’t surface them. Query for the obfuscation patterns these payloads typically use:
SELECT option_name, LENGTH(option_value) AS val_length
FROM wp_options
WHERE option_value LIKE '%eval(%'
OR option_value LIKE '%base64_decode(%'
OR option_value LIKE '%gzinflate(%'
OR option_value LIKE '%document.location%'
OR option_value LIKE '%window.location%'
OR option_value LIKE '%fromCharCode%'
ORDER BY val_length DESC;
Legitimate plugins use base64_decode for valid reasons, and eval appears in some caching and page-builder contexts. These hits are diagnostic starting points, not verdicts. Any row that matches deserves inspection — look at the surrounding serialized structure, identify which plugin owns that option key, and check whether the content makes sense for that plugin’s function. A 48-kilobyte widget_text value containing a gzinflate call warrants a much harder look than a known theme option encoding a font stack.
To catch oversized rows without knowing the exact obfuscation in use:
SELECT option_name, LENGTH(option_value) AS val_length
FROM wp_options
ORDER BY val_length DESC
LIMIT 20;
Also pull active_plugins and read it carefully. This option stores a serialized array of plugin file paths — WordPress loads each listed path on every request. Any unfamiliar path, particularly one pointing outside the standard /wp-content/plugins/ structure, warrants investigation, though no source in this research cycle directly confirms active_plugins as a redirect-delivery mechanism specifically.
SELECT option_value
FROM wp_options
WHERE option_name = 'active_plugins';
wp_postmeta: A Confirmed Vector Since 2024
BleepingComputer reported a 2024 campaign targeting the Popup Builder plugin in which redirect code was stored in wp_postmeta — specifically in the Custom JavaScript/CSS event fields the plugin executes on the front end. PublicWWW data showed 3,329 affected sites; Sucuri independently confirmed 1,170. The lesson is that any plugin storing executable code in postmeta creates a surface worth scanning:
SELECT post_id, meta_key, LEFT(meta_value, 200) AS preview
FROM wp_postmeta
WHERE meta_value LIKE '%document.location%'
OR meta_value LIKE '%window.location%'
OR meta_value LIKE '%eval(%'
OR meta_value LIKE '%base64_decode(%'
OR meta_value LIKE '%<script%'
ORDER BY post_id;
For Elementor sites specifically, _elementor_data rows can be substantial — serialized JSON that encodes every widget on a page, including any custom HTML or JavaScript widgets. Gutenberg stores block markup directly in wp_posts.post_content, including nested innerBlocks, which is a newer surface that postmeta queries won’t reach. Add a parallel scan of wp_posts targeting the same obfuscation patterns if Gutenberg or a block-based builder is in play.
A Note on Auto-Reinfection Before You Touch Anything
Campaign analysis documents the DollyWay campaign as installing a modified WPCode plugin variant with obfuscated snippets and an auto-reinfection mechanism that spread code across active plugins on every page load. If you delete a visible payload row without identifying and removing the loader that writes it, the row will reappear within minutes. The SQL above finds the payload; before any removal step, you need to understand what’s generating it. Tracing the loader is what separates a cleanup that holds from one that has to be repeated.
From Detection to Clean: Backup, Isolate, Verify
You have a suspicious row. Before you touch it, take a full database dump — not a plugin-generated export, but a proper mysqldump or a host-level snapshot taken right now. Patchstack’s redirect hack guide explicitly recommends a complete database backup before beginning cleanup. Run this from the command line, substituting your credentials and database name:
mysqldump -u user -p database_name > backup_before_cleanup_$(date +%Y%m%d).sql
The counter-argument comes up regularly: why not just restore from a pre-infection backup and skip the manual work? That’s genuinely the right call when you have a confirmed-clean backup and you’ve already closed the entry point. The problem is that infections are often discovered well after they landed — long enough that rolling backups may contain the payload, and long enough that a restore without identifying the loader tends to result in reinfection.
The distinction between payload and loader matters more than anything else in this procedure. The row you found in wp_options or wp_postmeta is likely a symptom of something writing it back. Delete the row without finding the writer and you’ve bought a temporary clean scan, not a clean site. Before any UPDATE or DELETE, decode the obfuscation — pipe the base64 payload through base64 -d on the command line, or drop it into a local PHP sandbox — identify the redirect destination, and trace any secondary persistence mechanism it references. The 2024 joint Patchstack/Sucuri State of WordPress Security whitepaper names conditional redirects to VexTrio scam domains and Japanese keyword spam among documented 2023 campaign outcomes, which gives you a reference point for what malicious destinations typically look like during triage.
Before any UPDATE or DELETE: take a full database backup. Deletion of serialized options can break plugins that expect the key to exist — prefer
UPDATEto a known-good value where possible. If you delete a serializedwidget_textrow that a plugin actively reads, the plugin may error out or reset its configuration on next load.
Once the loader is identified and disabled — typically by deactivating or removing the plugin or snippet responsible for writing the payload — remediate the affected row. For serialized wp_options entries, an UPDATE that restores the expected clean value is safer than DELETE. For wp_postmeta rows containing injected JavaScript, deletion is usually clean, but confirm that the meta key is one the plugin will recreate correctly on next save rather than one it reads and never rewrites.
Verification is where most cleanup procedures leave a gap. Patchstack’s malware removal guide recommends re-requesting affected URLs with a Googlebot user-agent to confirm the cloaked content is no longer served. Run this from your terminal against a URL that was previously redirecting:
curl -A 'Googlebot/2.1 (+http://www.google.com/bot.html)' https://example.com/affected-page/
If the response is a clean page rather than a redirect or injected script, the database-side payload is gone. Follow that with Google Search Console → URL Inspection → Request Indexing on the same URL, which triggers a fresh Googlebot crawl and starts clearing the cloaking flag from Google’s side. Wordfence, Sucuri, or MalCare should run in parallel to confirm no file-side persistence survived — the database queries handle the wp_options and wp_postmeta layer; file-layer tools handle everything else. Both checks are necessary before you call this resolved.
If the serialized-data layer — Gutenberg innerBlocks, Elementor _elementor_data, widget serialized arrays — is where you’re spending the most time manually, Content Guard Pro’s Standard Scan handles recursive parsing of those structures non-destructively, with confidence scoring so you’re triaging real findings rather than chasing false positives. The free tier on WordPress.org covers wp_posts if you want to start there before committing to anything else.
Next: Verification Across Both Layers
You’ve identified the suspicious row, traced the loader, taken your backup, and removed or quarantined the payload. The final step is verification that spans both the database layer and the file layer—not because one layer is more important than the other, but because conditional redirects often involve both a persistent injector in files and a payload trigger in the database. Run the Googlebot curl request against a URL that was previously redirecting to confirm the database-side payload no longer executes. Then re-run Wordfence, Sucuri, or MalCare to confirm no file-side persistence wrote the payload back. File-layer scanners are essential tools; they catch the other half of the story. Together with the SQL diagnostics and the cloaking verification technique in this article, they form a complete picture. If you’re managing multiple sites or want to reduce the manual query work in subsequent cleanups, consider a non-destructive database scanner that can parse serialized structures and Gutenberg block data recursively—tools like Content Guard Pro’s Standard Scan offer that parsing capacity alongside confidence scoring so you’re investigating real findings rather than chasing every base64 string in a legitimate plugin. Either way, document what you found, what you removed, and what entry point allowed it in—then close that door before you declare the site clean.