Vulnerabilities fixed in WordPress WP Security Audit Log plugin.

The WordPress WP Security Audit Log plugin, which has 100,000+ active installations, fixed a broken access control vulnerability affecting version 4.0.1 and below that could lead to privilege escalation, sensitive data exposure and insecure deserialization.

In the “wp-security-audit-log/classes/Views/SetupWizard.php” script, the plugin registers the setup_page method via the admin_init hook:

add_action( 'admin_init', array( $this, 'setup_page' ), 10 );
...
...
public function setup_page() {
   // Get page argument from $_GET array.
   $page = filter_input( INPUT_GET, 'page', FILTER_SANITIZE_STRING );
   if ( empty( $page ) || 'wsal-setup' !== $page ) {
      return;
   }
...
...
   // Set current step.
   $current_step       = filter_input( INPUT_GET, 'current-step', FILTER_SANITIZE_STRING );
   $this->current_step = ! empty( $current_step ) ? $current_step : current( array_keys( $this->wizard_steps ) );
...
...
   // Data array.
   $data_array = array(
      'ajaxURL'    => admin_url( 'admin-ajax.php' ),
      'nonce'      => wp_create_nonce( 'wsal-verify-wizard-page' ),
      'usersError' => esc_html__( 'Specified value in not a user.', 'wp-security-audit-log' ),
      'rolesError' => esc_html__( 'Specified value in not a role.', 'wp-security-audit-log' ),
      'ipError'    => esc_html__( 'Specified value in not an IP address.', 'wp-security-audit-log' ),
   );
   wp_localize_script( 'wsal-wizard-js', 'wsalData', $data_array );
...
...
   /**
    * Save Wizard Settings.
    */
   $save_step = filter_input( INPUT_POST, 'save_step', FILTER_SANITIZE_STRING );
   if ( ! empty( $save_step ) && ! empty( $this->wizard_steps[ $this->current_step ]['save'] ) ) {
      call_user_func( $this->wizard_steps[ $this->current_step ]['save'] );
   }

   ob_start();
   $this->setup_page_header();
   $this->setup_page_steps();
   $this->setup_page_content();
   $this->setup_page_footer();
   exit;
}

It is used to load an optional wizard. Because it doesn’t check user capabilities and the admin_init can be triggered by anyone, an unauthenticated user can run the wizard simply by accessing “wp-admin/admin-post.php?page=wsal-setup”:

Note that to exploit the vulnerability, the wizard must not have been completed, otherwise it won’t work (after submitting the 9th and last step of the wizard, it will save a wsal-wsal-setup-complete flag to the database).
It allows to configure a large set of options, among them the possibility to exclude users, user roles or IP addresses from being written to the plugin’s activity log:

Throughout the wizard, after adding an entry the user must click the blue “Add” button to validate it. The validation is performed over AJAX and, if successful, the JavaScript code will insert a new input field with the validated value into the HTML form (e.g., exusers[] for users, exroles[] for roles, ipaddrs[] for an IP etc). But after clicking the green “Next” button to move on to the next step, the submitted form is saved but not validated by the plugin. Therefore, an unauthenticated attacker can insert the input field with its value and simply submit it with the security nonce found in the HTML form in order to bypass the JavaScript/AJAX validation:

The next set of options is a bit confusing: it indicates that it allows users to access the activity log, based on their username or role:

But after inserting a editors[]=subscriber input into the form and submitting it as an unauthenticated user, I logged in to the WordPress dashboard with a subscriber account and noticed that I was given more privileges than I expected because I had full access to the plugin settings, just like the administrator, not only to its log:

The attacker can change every option of the plugin, access the activity log (which could lead to sensitive data exposure) or delete it, but the most interesting issue is the “Import Settings” feature, which is managed by the tab_import_settings_save method found in the “wp-security-audit-log/classes/Views/Settings.php” script:

/**
 * Save: `Import/Export`
 */
private function tab_import_settings_save() {
   if ( isset( $_FILES['import-settings'] ) ) {
      if ( 0 === $_FILES['import-settings']['error'] ) {
         $filename  = isset( $_FILES['import-settings']['name'] ) ? sanitize_text_field( wp_unslash( $_FILES['import-settings']['name'] ) ) : false;
         $file_type = isset( $_FILES['import-settings']['type'] ) ? sanitize_text_field( wp_unslash( $_FILES['import-settings']['type'] ) ) : false;
         $file_size = isset( $_FILES['import-settings']['size'] ) ? (int) sanitize_text_field( wp_unslash( $_FILES['import-settings']['size'] ) ) : false;

         if ( 'application/json' === $file_type && $file_size < 500000 ) {
            $encoded_options = isset( $_FILES['import-settings']['tmp_name'] ) ? file_get_contents( sanitize_text_field( wp_unslash( $_FILES['import-settings']['tmp_name'] ) ) ) : false; // phpcs:ignore
            $options         = json_decode( $encoded_options );

            if ( $options ) {
               foreach ( $options as $key => $value ) {
                  $value = maybe_unserialize( sanitize_text_field( $value ) );
                  $this->_plugin->SetGlobalOption( $key, $value );
               }
...
...

It allows a json-encoded file to be imported. After decoding it, the plugin will pass all values to the WordPress maybe_unserialize function (which will check if they are serialized and, if applicable, will call the unserialize function). Because the authenticated attacker has full control over the imported data, this could lead to a PHP object injection vulnerability. Additionally, deserialization will not occur only while importing the data: because it will be re-serialized by the SetOptionValue method before being saved to the database, it will occur each time the plugin loads its options:

public function GetOptionValue( $name, $default = array() ) {
   ...
   ...
   // Unserialize if $value is array or object.
   $this->option_value = maybe_unserialize( $this->option_value );
   // return $this->IsLoaded() ? $this->option_value : $default;
   return $this->option_value;
}

If the serialized object was inserted into the login_page_notification option field, it will also happen when a user accesses the login page.

Timeline

The vulnerability was reported to the wordpress.org team on February 26, 2020. A new version 4.0.2 was released on February 28, 2020.

Recommendations

Update if you have version 4.0.1 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 this vulnerability since January 28th, 2020.

Stay informed about the latest vulnerabilities