An encoded blob in wp_options is half of a loader. The other half is a file.
A database scan, a host’s malware report, or your own query turned up base64_decode, eval or gzinflate inside the wp_options table. The site may look normal, a file cleanup may already have been done, and the infection may still be coming back. Options-table payloads survive every file replacement because they are not files — but they also do nothing on their own. Something reads the row and executes it. This guide covers how to find every suspect row without changing anything, how to tell a plugin’s legitimate data from an injected payload, and why the row and the file that runs it have to be removed together, in the right order.
Prepared by G. Schad, WordPress and Linux security specialist
base64_decode, eval, gzinflate or a long encoded string in wp_options
Mechanism
A loader file calls get_option() and executes what it returns
Why it survives
Reinstalling core, plugins and themes never touches the database
Removal order
Preserve, remove the loader, then the row, then rotate
How it works
Why malware stores its payload in the options table
The options table is where WordPress and every plugin keep settings, which makes it a good hiding place. It holds hundreds or thousands of rows on a typical site, many of them long serialised arrays nobody reads by hand, and none of them is checked by a file-integrity scan. Reinstalling WordPress core, replacing every plugin from a clean download, even restoring the theme — none of that touches a single row.
Rows flagged for autoload are loaded into memory on every request, so the payload is always one function call away. The loader that makes that call is typically short: read an option, decode it, execute it. Short code is easy to miss in a large theme file and easy to hide in a must-use plugin with an innocent name.
This is a documented pattern, not a theory. In the ClickFix incident covered on this site, base64-encoded script fragments and loader data were stored in options records on affected sites, alongside an unauthorised call in the active theme’s functions.php and a .user.ini directive — four layers, each able to bring the infection back.
Diagram · Two halves of one loader
The row holds the code. A file decides when it runs.
01 · Every requestWordPress loads the autoloaded optionswp_load_alloptions()Every row flagged for autoload is read into memory before plugins and themes run.
02 · A file that loads earlyA loader reads one option by nameget_option( 'name' )Usually a few lines in an mu-plugin, a drop-in, the active theme’s functions.php, or a modified core file.
03 · PHPThe value is decodedbase64_decode · gzinflate · str_rot13Often several layers deep, so no single readable string gives the game away.
04 · PHPThe decoded code is executedeval()With full access to the files, the database and the request — on every page view.
↺ A loader that finds its row missing can fetch it again, and a row whose loader is deleted can be reactivated by any file that reads it.
A simplified loader: autoloaded options are read into memory on every request, so a single line in a file that loads early is enough to execute the stored payload. Remove either half and the other is inert — but each can be used to rebuild the other.
Run these in phpMyAdmin, Adminer, the MySQL client, or through WP-CLI. None of them changes anything. The table is called wp_options only when the table prefix is wp_ — check $table_prefix in wp-config.php and substitute yours.
Figure 1 · One read-only query
One autoloaded row holding 48 KB of encoded PHP, under a name no plugin claims.
user@site:~/public_html
$ wp db query "SELECT option_name, autoload, LENGTH(option_value) AS bytes FROM wp_options WHERE option_value LIKE '%base64_decode%' ORDER BY bytes DESC"+---------------------------+----------+-------+| option_name | autoload | bytes |+---------------------------+----------+-------+| wp_cache_sys_a91f | yes | 48213 |+---------------------------+----------+-------+$ grep-rln"wp_cache_sys_a91f" wp-content/wp-content/mu-plugins/cache-helper.php# the row is the payload; the file that reads it is the trigger
The query only reads. The option name, size and output are illustrative; the shape — large, autoloaded, encoded, owned by nothing installed — is what to look for. Illustrative reconstruction.
01
Search the values for decoding and execution functions
Every row whose value mentions a function that decodes or executes code, largest first:
SQL · read-only
SELECT option_id, option_name, autoload, LENGTH(option_value) AS bytesFROM wp_optionsWHERE option_value LIKE '%base64_decode%' OR option_value LIKE '%gzinflate%' OR option_value LIKE '%str_rot13%' OR option_value LIKE '%eval(%' OR option_value LIKE '%<script%'ORDER BY bytes DESC;
A match is a lead, not a verdict: security and caching plugins legitimately store strings like these in their own settings and logs. What matters is who owns the row, which the next sections cover.
02
List the largest autoloaded rows
Encoded payloads are often large. Since WordPress 6.6 the autoload column can hold on, off, auto, auto-on and auto-off as well as the older yes and no; this covers every value WordPress treats as autoloaded:
SQL · read-only
SELECT option_name, autoload, LENGTH(option_value) AS bytesFROM wp_optionsWHERE autoload IN ('yes', 'on', 'auto-on', 'auto')ORDER BY bytes DESCLIMIT 25;
Most of the top 25 will be recognisable plugin settings. A large row with a name that matches no installed plugin, theme or core option deserves a closer read.
03
Look for long unbroken encoded strings
A payload with its function names split or encoded will not match the first query. A long run of base64 characters will:
SQL · read-only
SELECT option_name, autoload, LENGTH(option_value) AS bytesFROM wp_optionsWHERE option_value REGEXP '[A-Za-z0-9+/]{300,}'ORDER BY bytes DESC;
Expect some legitimate hits — embedded images, licence keys, cached API responses. On a large table the regular expression is slow; run it off-peak.
04
Read one row without executing it
The database client is the safest way to read a value. With WP-CLI, the db commands load only wp-config.php for the credentials, not plugins or themes:
WP-CLI
$ wp db query "SELECT option_value FROM wp_options WHERE option_name = 'OPTION_NAME'"
Read it as text. Do not paste it into a PHP file, a browser console, or an online “decoder” that runs code. To see what the encoding hides, the decoder linked below works entirely in your browser and executes nothing.
Most long, strange-looking option values are legitimate. These are the distinctions that decide it, roughly in order of how quickly they settle the question.
Serialised arrays are normal
A value that starts a:12:{ or O:8: is PHP-serialised data — the ordinary format for plugin settings. Encoded PHP source is not serialised; it is usually one long string, sometimes wrapped in a single serialised string element.
Transients are disposable caches
Names beginning _transient_ or _site_transient_ are temporary caches that WordPress and plugins regenerate. They can be abused, but a transient holding a feed or an API response is expected.
The name should belong to something
Legitimate options are usually prefixed with the plugin or theme that owns them. A name imitating core, a cache, or a session — with no installed component that uses it — is the classic disguise.
Follow the name into the code
Search the installation for the option’s name. A match inside the plugin it belongs to explains it. A match in an mu-plugin, a drop-in, uploads, or a theme file you did not edit is the loader:
shell
$ grep-rn"OPTION_NAME" wp-content/ wp-config.php
No match at all is not reassuring: the loader may build the name from encoded pieces, or live outside wp-content.
Decoded content settles it
A value that decodes to PHP calling eval, assert, create_function, system or file_put_contents, or to JavaScript writing script tags into the page, is not configuration. You do not need to understand all of it to decide.
Timing corroborates
Rows have no timestamps, but the loader file does. A file in mu-plugins or uploads modified in the same window as the first symptoms ties the two together.
Find the loader
The row is inert without the code that runs it
The decisive step is finding what executes the payload. Search the files for the decoding and execution functions the loader needs, and read every match outside a plugin you can name:
Expect noise: many legitimate plugins use base64_decode for ordinary purposes. Narrow it to the places loaders favour — wp-content/mu-plugins, drop-ins such as object-cache.php and advanced-cache.php in wp-content, the active theme’s functions.php and its includes, and any PHP file under uploads, where PHP should not exist at all.
Check the layers above WordPress too. A loader can sit in a .user.ini auto_prepend_file target, in a modified core file, or in wp-config.php itself. The guides on those layers cover what to look for in each.
Remove the trigger before the payload. A loader that is still running can rewrite a deleted row; a row left behind can be picked up again by the next file that reads it.
01
Preserve the evidence
Export the whole database to a directory outside the web root, then copy the loader file with its timestamps:
shell
$ mkdir-p ~/incident$ wp db export ~/incident/before-cleanup.sql$ cp-p wp-content/mu-plugins/SUSPECT.php ~/incident/
Record the SHA-256 of each copy. This export is the only record of what the payload was.
02
Put the site into maintenance mode or block traffic
Every request runs the payload while you work. Containment first, if the business can tolerate it; agree what stays online.
03
Remove the loader
Delete the rogue file, or remove the injected lines from a theme or core file — replacing core, plugins and themes from official sources is safer than hand-editing.
04
Then delete the row
Only once the loader is gone, and only after confirming no installed plugin owns the name:
WP-CLI
$ wp option delete OPTION_NAME
If the value was injected into a legitimate option rather than a new one, restore that option from the plugin’s settings screen instead of deleting it.
05
Rotate everything the payload could read
It ran with the site’s full access: database password, WordPress salts and keys, administrator passwords and application passwords, hosting, SFTP and API credentials. Change them from a device that is not part of the incident.
06
Verify after traffic resumes
Re-run the queries above the next day and again a few days later. A row that reappears means a loader, a scheduled task or a credential was missed.
Code that can write one option can write any row. When a payload turns up in wp_options, check the neighbouring places an attacker with database access uses: script injections in post content and widgets, a tampered role definition in the user_roles option, an extra administrator in usermeta, and scheduled events in the cron option.
No. Plugins store encoded images, licence data and cached responses, and security plugins log the very strings attackers use. It becomes a finding when the row belongs to nothing installed, decodes to executable code, or is read by a file that should not exist.
I deleted the option and it came back. Why?
Because the loader is still in place, or a scheduled task rewrites the row. Remove the file that reads it first, check WP-CLI’s cron event list for unfamiliar hooks, then delete the row and watch whether it returns.
Can I just restore a database backup?
Only if you know the backup predates the injection — and options-table payloads are often older than the first visible symptom. Restoring also brings back whatever entry point was used. Compare the backup’s suspect rows before trusting it.
My table prefix is not wp_. Does any of this change?
Only the names. The table becomes yourprefix_options and options such as user_roles take the prefix too. Read $table_prefix in wp-config.php and substitute it in every query.
Why did Wordfence or my scanner not report it?
File scanners read files, and a payload in a database row is not a file. Some plugins scan selected tables for known signatures, but an encoded payload under an unremarkable name can pass. The loader file is often the easier thing for a scanner to catch.
Should I run the decoded code to see what it does?
Never on the live server, and not in your normal browser. Decoding is safe; executing is not. Read the decoded text, or use a decoder that works without executing anything.