WordPress uListing plugin fixed multiple critical vulnerabilities.

The WordPress uListing plugin (3,000+ active installations) fixed multiple critical vulnerabilities affecting version 1.6.6 and below that could lead to unauthenticated arbitrary account creation, WordPress options change, information disclosure and SQL injection among several other issues.

Broken Access Control

Users can interact with the plugin via two API: the WordPress built-in AJAX API and WP_Route, a third-party API (e.g., https://example.com/1/api/{some_route}). Most actions and endpoints are accessible to unauthenticated users, lack security nonces and data is seldom validated.
Due to the large number of issues, I’ll review below the most critical ones only.

Unauthenticated Arbitrary Account Creation

The plugin registers the stm_listing_register AJAX action to load the eponymous function in the “ulisting/includes/classes/StmListingAuth.php” script line 53:

public static function stm_listing_register()
{
   $result = array(
      'errors' => [],
      'message' => null,
      'status'  => 'error'
   );

   $request_body = file_get_contents('php://input');
   $data = json_decode($request_body, true);

   $data_for_validate = $data;
   $validator = new Validation();
   $data_for_validate = $validator->sanitize($data_for_validate);
   $validator->validation_rules(array(
      'email' => 'required|valid_email',
      'first_name' => 'required|max_len,50|min_len,3',
      'last_name' => 'required|max_len,50|min_len,3',
      'login' => 'required|max_len,50|min_len,3',
      'password' => 'required|max_len,50|min_len,8',
      'password_repeat' => 'required|equalsfield,password',
      'role' => 'required',
   ));

   $validated_data = $validator->run($data_for_validate);

   if($validated_data === false) {
      $result['errors'] = $validator->get_errors_array();
      wp_send_json($result);
      die;
   }

   extract($data);
   /**
    * @var $email ;
    * @var $first_name ;
    * @var $last_name ;
    * @var $login ;
    * @var $password ;
    * @var $password_repeat ;
    * @var $role ;
    * @var $agency_id ;
    */

   $user = wp_create_user($login, $password, $email);

   if (is_wp_error($user)) {
      $result['message'] = $user->get_error_message();
   } else {
      if($user = new StmUser($user)) {
               do_action("ulisting_profile_edit", ['user' => $user, 'data' => $validated_data]);
         wp_update_user(array(
            'ID'              => $user->ID,
            'first_name'      => $first_name,
            'last_name'       => $last_name,
            'role'            => $role
         ));
      }
      ...
      ...

It allows users to register an account on the blog. Because users can submit a role, which is not checked by the plugin, an unauthenticated user can create an administrator account on the blog (or any other role).

Unauthenticated Arbitrary Account Change

The plugin registers the stm_listing_profile_edit AJAX action to load the eponymous function in the “ulisting/includes/classes/StmListingAuth.php” script line 138:

public static function stm_listing_profile_edit() {

   $result = array(
      'errors' => [],
      'message' => null,
      'status'  => 'error'
   );
   $validator = new Validation();
   $data_for_validate = $validator->sanitize(array_merge($_POST,$_FILES));

   $validator->validation_rules(array(
      'user_id' => 'required',
      'email' => 'required|valid_email',
      'first_name' => 'required|max_len,50|min_len,3',
      'last_name' => 'required|max_len,50|min_len,3',
      'avatar' => 'extension,png;jpg'
   ));

   $validated_data = $validator->run($data_for_validate);

   if($validated_data === false) {
      $result['errors'] = $validator->get_errors_array();
      wp_send_json($result);
      die;
   }

   extract($validated_data);
   /**
    * @var $user_id ;
    * @var $email ;
    * @var $first_name ;
    * @var $last_name ;
    */

   if($user = new StmUser($user_id) AND $user->ID) {

      do_action("ulisting_profile_edit", ['user' => $user, 'data' => $validated_data]);

      $result['status'] = 'success';
      $result['message'] = esc_html__('Profile update completed successfully.', "ulisting");

      if(isset($_FILES['avatar'])) {
         $avatar = $user->updateAvatar( $_FILES['avatar'] );
         if(isset($avatar['error'])) {
            $result['status'] = 'error';
            $result['errors']['avatar'] = $avatar['message'];
         }else
            $result['url_avatar'] = $avatar['url'];
      }

      wp_update_user(array(
         'ID'              => $user->ID,
         'first_name'      => $first_name,
         'last_name'       => $last_name,
         'user_email'      => $email,
      ));

      foreach ( $validated_data['user_meta'] as $k => $val )
         update_user_meta($user->ID, $k, $val);

   } else {
      $result['message'] = esc_html__('User not found', "ulisting");
      wp_send_json($result);
      die;
   }

   wp_send_json($result);
   die;

}

The function allows users to edit their account. Because it doesn’t verify that users are logged-in and editing their own account, an unauthenticated user can edit any account on the blog such as modifying the admin account and changing its email address, and can also edit values in the usermeta table in the database and change the user profile picture.

Unauthenticated WordPress Options Change (via AJAX)

The plugin registers the stm_update_email_data AJAX action to load the eponymous function in the “ulisting/includes/classes/StmListingSettings.php” script line 200:

/**
* Update Email Template Images by key
*/
public static function stm_update_email_data() {
   $result = [
      'status'  => 'success',
      'success' => true
   ];

   $request_body = file_get_contents('php://input');
   $request_data = json_decode($request_body, true);

   if (isset($request_data['socials'])) {
      update_option('ulisting_email_socials', $request_data['socials']);
   }

   if (isset($request_data['images']) && !empty($request_data['images'])) {
      foreach ($request_data['images'] as $image)
         update_option($image['option_name'], $image['id']);
   }
   wp_send_json($result);
}

The function doesn’t check for user capabilities, lacks a security nonce and doesn’t verify or validate user input. An unauthenticated user can change any WordPress option in the database in order, for instance, to enable registration (users_can_register) and to set the user default role to administrator, or modify the value of siteurl in order to redirect all traffic to an external malicious website. It is also possible to modify the plugin ulisting_email_socials option.

Unauthenticated WordPress Options Change (via WP_Route)

In the “ulisting/includes/route.php” script line 200, the plugin registers the /1/api/ulisting-builder/listing-single-layout/new-layout route that loads the StmListingSingleLayout::import_new_layout method from the “ulisting/includes/classes/StmListingSingleLayout.php” script line 1222:

public static function import_new_layout(){
   $result = [
      'success' => false,
   ];

   $files = $_FILES;

   if(!empty($files['file']) && !empty($_POST['id']) && file_exists($files['file']['tmp_name'])){
      $content = file_get_contents($files['file']['tmp_name']);

      if(is_array($content))
         $content = ulisting__sanitize_array($content);
      else
         $content = sanitize_text_field($content);

      if(isset($_POST['listing_type_id']) && $_POST['type'] === 'single')
         update_post_meta($_POST['listing_type_id'], $_POST['id'], $content);
      elseif ($_POST['type'] === 'inventory')
         update_option($_POST['id'], $content);
   
      $result['success'] = true;
   }

   return $result;
}

Here too, the method doesn’t check for user capabilities and there’s no security nonce either, allowing an unauthenticated user to change any WordPress option in the database. It is also possible to edit values in the postmeta table in the database.

Unauthenticated Information Disclosure

In the “ulisting/includes/route.php” script line 60, the plugin registers the /1/api/ulisting-user/search route that loads the StmUser::search method from the “ulisting/includes/classes/StmUser.php” script line 303:

/**
 * @param $search string
 *
 * @return mixed list array
 */
public static function search($search) {
   if(!$search)
      return [];
      $data = [];
      $users = new WP_User_Query( array(
         'search' => '*'.esc_attr( $search ).'*',
         'number' => 50,
         'search_columns' => array(
         'user_login',
         'user_nicename',
         'user_email',
         'user_url',
      ),
   ) );
   foreach ($users->get_results() as $user) {
      $data[] = array(
         'id' => $user->data->ID,
         'name' => $user->data->display_name,
         'email' => $user->data->user_email
      );
   }
   return $data;
}

The method doesn’t check if the user has the list_users capability.
An unauthenticated user can retrieve the list of all users and their email address in the database.

Unauthenticated Arbitrary Roles and Capabilities Creation/Deletion

In the “ulisting/includes/route.php” script line 36, the plugin registers the /1/api/ulisting-user/role/save route that loads the UlistingUserRole::save_role_api method from the “ulisting/includes/classes/UlistingUserRole.php” script line 83:

public static function save_role_api(){
   $result = [
      'success' => false,
      'message' => false
   ];
   $request_body = file_get_contents('php://input');
   $request_data = json_decode($request_body, true);
   if(isset($request_data['roles'])) {
      self::save_roles($request_data['roles']);
      $result['success'] = true;
   }
   return $result;
}

/**
 * @param $roles
 */
public static function save_roles($roles) {
   global $wp_roles;
   $model = new UlistingUserRole();
   $i = empty(get_role("agency")) ? 1 : 0;
   foreach ($roles as $role) {

      if(!uListing_user_role_active() AND $i == 2)
         continue;

      if($role['is_delete'] == 1){
         remove_role($role['slug']);
         continue;
      }
      if(!isset($model->roles[$role['slug']])){
         add_role($role['slug'],$role['name'], $role['capabilities']);
         $wp_role = get_role( $role['slug'] );
      }else{
         $wp_role = get_role( $role['slug'] );
         if (isset($wp_roles->roles[$role['slug']]) && !empty($role['name'])) $wp_roles->roles[$role['slug']]['name'] = $role['name'];
         foreach ($role['capabilities'] as $key => $val) {
            $wp_role->add_cap($key, $val);
         }
      }
      do_action('ulisting_user_role_save_custom_fields',['wp_role' => $wp_role, 'custom_fields' => $role['custom_fields']]);
      $i++;
   }
}

The function doesn’t check for user capabilities and lacks a security nonce.
An unauthenticated user can remove or add roles, and add capabilities to the blog.

Unauthenticated SQL Injection #1

In the “ulisting/includes/route.php” script line 348, the plugin registers the /1/api/ulisting-page-statistics/listing route:

/**
 * Page statistics
 */
$wp_router->get( array(
   'uri'  => ULISTING_BASE_URL.'/ulisting-page-statistics/listing',
   'uses' => function(){
      if(isset($_GET["type"]) AND isset($_GET["listing_id"]))
         wp_send_json( \uListing\Classes\UlistingPageStatistics::get_listing_page_statistics($_GET) );
      die;
   }
   )
);

If the $_GET["type"] and $_GET["listing_id"] variables are set, it loads the “UlistingPageStatistics::get_listing_page_statistics” method found in the “ulisting/includes/classes/UlistingPageStatistics.php” script:

$statistics = UlistingPageStatistics::query()
   ->select(" page_statistics.id, page_statistics.type, page_statistics.`created_date`  , count(page_statistics.id) as count ")
   ->asTable("page_statistics")
   ->where_raw("page_statistics.`object_id` = " . $params["listing_id"] . " OR page_statistics.`object_id` = " . $params["user_id"])
   ->where_raw(" page_statistics.`created_date` between '".$start_date."' and '".$end_date."' ")
   ->group_by(" HOUR(page_statistics.`created_date`), page_statistics.`id`, page_statistics.`type`")
   ->find();

The listing_id and user_id GET variables are inserted into the SQL query without being checked or sanitized.
An unauthenticated user can perform a SQL injection attack with either variable.

Unauthenticated SQL Injection #2

In the “ulisting/includes/classes/UlistingPageStatistics.php” script line 66, the plugin retrieves the IP from untrusted sources, HTTP_CLIENT_IP and HTTP_X_FORWARDED_FOR:

public static function getRealIpAddr() {
   if (!empty($_SERVER['HTTP_CLIENT_IP'])) { //check ip from share internet
      $ip=$_SERVER['HTTP_CLIENT_IP'];
   } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR']))  { //to check ip is pass from proxy
      $ip=$_SERVER['HTTP_X_FORWARDED_FOR'];
   } else {
      $ip=$_SERVER['REMOTE_ADDR'];
   }
   return $ip;
}

It doesn’t validate it and insert it, unsanitized, into the SQL query line 117:

public static function page_statistics_for_listing($listing_id){
   global $wpdb;
   $page_statistics = UlistingPageStatistics::query()
      ->asTable("page_statistics")
      ->join(" left join `".$wpdb->prefix."ulisting_page_statistics_meta` as meta on meta.`page_statistics_id` = page_statistics.id ")
      ->where("page_statistics.`object_id`", $listing_id)
      ->where_raw("DATE(page_statistics.`created_date`) = DATE('".date("Y-m-d")."') ");
   if(is_user_logged_in())
      $page_statistics->where("meta.`meta_key`", "user_id")
         ->where("meta.`meta_value`", get_current_user_id());
   else
      $page_statistics->where("meta.`meta_key`", "ip")
         ->where("meta.`meta_value`", self::getRealIpAddr());

An unauthenticated user can perform a SQL injection attack by forging a bogus IP address.

Unauthenticated Arbitrary Post/Page Deletion

In the “ulisting/includes/route.php” script line 89, the plugin registers the /1/api/ulisting-user/deletelisting route that loads the StmUser::delete_listing method from the “ulisting/includes/classes/StmUser.php” script line 511:

public static function delete_listing() {
   $result = [
      'success' => false,
      'data'    => []
   ];

   $request_body = file_get_contents('php://input');
   $request_data = json_decode($request_body, true);

   $validation = new Validation();
   $request_data = $validation->sanitize($request_data);
   $validation->validation_rules(array(
      'user_id' => 'required',
      'listing_id' => 'required'
   ));

   if ( ($validated_data = $validation->run($request_data)) === false) {
      $result['errors'] = $validation->get_errors_array();
      return $result;
   }

   if( !($user = new StmUser($request_data['user_id'])) ) {
      return $result;
   }

   $listing_id = ( isset($request_data['listing_id']) ) ? $request_data['listing_id'] : null;
   $listingUserRelation = StmListingUserRelations::query()->where('listing_id', $listing_id)->findOne();

   $back_slots = get_option('ulisting_back_slots');
   $back_slots = strval($back_slots) === 'true';

   if ($listingUserRelation) {
      if ($listingUserRelation->user_id != $request_data['user_id']) return $result;
   }

   if ($listing_id) {
      $listing    = StmListing::find_one($listing_id);
      $user_plans = $listing->get_user_plan();
      if ( !empty($listing) && $back_slots && !empty($user_plans) ) {
         foreach ($user_plans as $user_plan) {
            if ( isset($user_plan->payment_type) && $user_plan->payment_type === StmPricingPlans::PRICING_PLANS_PAYMENT_TYPE_ONE_TIME) {
               $user_plan_meta = $user_plan->get_user_plan_meta();
               $limit          = intval($user_plan_meta->meta_value) + 1;
               $user_plan_meta->meta_value = $limit;
               $user_plan_meta->save();
            }
         }
      }

      wp_delete_post( $listing_id, 'false' );
      $result['success'] = true;
   }
   return $result;
}

The plugin doesn’t check if the user is logged-in and allowed to delete the corresponding page or post, and there’s no security nonce either.
An unauthenticated user can delete any page or post on the blog.

Additional Issues

Additional issues include:

  • Unauthenticated WordPress options deletion.
  • Unauthenticated plugin settings change.
  • Unauthenticated arbitrary post/page status change.
  • Unauthenticated arbitrary post meta deletion and change.
  • etc

Bypassing Firewalls & Security Plugins

While looking at the WP_Route API code I noticed that unlike the WordPress REST API, which requires a strict syntax, it allowed character encoding. For instance, if http://example.com/1/api/ulisting-user/search?search=* will return the list of all users on the blog, so will http://example.com/%31%2f%61%70%69%2f%75%6c%69%73%74%69%6e%67%2d%75%73%65%72%2f%73%65%61%72%63%68?search=*. This kind of light obfuscation is interesting because on websites that have a poorly implemented security, hackers could use it to bypass security rules and exploit the vulnerabilities. Users of our NinjaFirewall WAF for WordPress don’t have to worry about such evasion tricks, the firewall will handle them without any problem.

Recommendations

Update immediately if you have version 1.6.6 or below installed. If you are using our web application firewall for WordPress, NinjaFirewall WP Edition (free) and NinjaFirewall WP+ Edition (premium), you are protected against those vulnerabilities.

Timeline

Due to unsuccessful attempts to contact the authors on January 11, 2021, the issue was escalated to the WordPress Plugins Team on January 16 and a new version 1.7 was released on January 19.

Stay informed about the latest vulnerabilities