Two Paths to Privilege Escalation Vulnerability In The Simple Membership Plugin

Published 27 September 2023
Updated 18 October 2023
Rafie Muhammad
Security Researcher at Patchstack
Table of Contents

This blog post is about the vulnerability in the Simple Membership plugin. If you're a Simple Membership user, please update the plugin to at least version 4.3.5.

Patchstack Developer and Business users are protected from the vulnerability. You can also sign up for the Patchstack Community plan to be notified about vulnerabilities as soon as they become disclosed.

For plugin developers, we have security audit services and Threat Intelligence Feed API for hosting companies.

About the Simple Membership Plugin

The plugin Simple Membership (versions 4.3.4 and below, free version), which has over 50,000 active installations is known as the more popular custom membership plugin in WordPress. This plugin is developed by smp7 and wp.insider.

Vulnerability In The Simple Membership Plugin

This plugin is claimed to be a flexible, well-supported, and easy-to-use WordPress membership plugin for offering free and premium content.

The security vulnerability

The Simple Membership plugin suffers from two privilege escalation vulnerabilities. The first vulnerability is an Unauthenticated Membership Role Privilege Escalation which could result in unauthenticated users being able to register an account with a role set to an arbitrary membership level.

The other vulnerability in the Simple Membership plugin is Authenticated Account Takeover which could allow an authenticated user to take over any member account through an insecure password reset process. The described vulnerabilities were fixed in version 4.3.5 and assigned CVE-2023-41957 and CVE-2023-41956.

Unauthenticated Membership Role Privilege Escalation

The main vulnerable code exists in the create_swpm_user function:

private function create_swpm_user() {
	global $wpdb;
	$member = SwpmTransfer::$default_fields;
	$form   = new SwpmFrontForm( $member );
	if ( ! $form->is_valid() ) {
		$message = array(
			'succeeded' => false,
			'message'   => SwpmUtils::_( 'Please correct the following' ),
			'extra'     => $form->get_errors(),
		);
		SwpmTransfer::get_instance()->set( 'status', $message );
		return false;
	}

	$member_info = $form->get_sanitized_member_form_data();

	//Check if the email belongs to an existing wp user account with admin role.
			SwpmMemberUtils::check_and_die_if_email_belongs_to_admin_user($member_info['email']);

	//Go ahead and create the SWPM user record.
	$free_level                           = SwpmUtils::get_free_level();
	$account_status                       = SwpmSettings::get_instance()->get_value( 'default-account-status', 'active' );
	$member_info['last_accessed_from_ip'] = SwpmUtils::get_user_ip_address();
	$member_info['member_since']          = SwpmUtils::get_current_date_in_wp_zone(); //date( 'Y-m-d' );
	$member_info['subscription_starts']   = SwpmUtils::get_current_date_in_wp_zone(); //date( 'Y-m-d' );
	$member_info['account_state']         = $account_status;
	if ( $this->email_activation ) {
		$member_info['account_state'] = 'activation_required';
	}
	$plain_password = $member_info['plain_password'];
	unset( $member_info['plain_password'] );

	if ( SwpmUtils::is_paid_registration() ) {
					//Remove any empty values from the array. This will preserve address information if it was received via the payment gateway.
					$member_info = array_filter($member_info);

					//Handle DB insert for paid registration scenario.
		$member_info['reg_code'] = '';
		$member_id = filter_input( INPUT_GET, 'member_id', FILTER_SANITIZE_NUMBER_INT );
		$code = isset( $_GET['code'] ) ? sanitize_text_field( stripslashes ( $_GET['code'] ) ) : '';
		$wpdb->update(
			$wpdb->prefix . 'swpm_members_tbl',
			$member_info,
			array(
				'member_id' => $member_id,
				'reg_code'  => $code,
			)
		);

		$query                           = $wpdb->prepare( 'SELECT membership_level FROM ' . $wpdb->prefix . 'swpm_members_tbl WHERE member_id=%d', $member_id );
		$member_info['membership_level'] = $wpdb->get_var( $query );
		$last_insert_id                  = $member_id;
	} elseif ( ! empty( $free_level ) ) {
		$member_info['membership_level'] = $free_level;
		$wpdb->insert( $wpdb->prefix . 'swpm_members_tbl', $member_info );
		$last_insert_id = $wpdb->insert_id;
	} else {
		$message = array(
			'succeeded' => false,
			'message'   => SwpmUtils::_( 'Membership Level Couldn\'t be found.' ),
		);
		SwpmTransfer::get_instance()->set( 'status', $message );
		return false;
	}
	$member_info['plain_password'] = $plain_password;
	$this->member_info             = $member_info;
	return true;
}

The function handles the membership registration process. The interesting part is the process inside of the SwpmUtils::is_paid_registration() if statement. Let's see the function:

public static function is_paid_registration() {
    $member_id = filter_input( INPUT_GET, 'member_id', FILTER_SANITIZE_NUMBER_INT );
            $code = isset( $_GET['code'] ) ? sanitize_text_field( stripslashes ( $_GET['code'] ) ) : '';
    if ( ! empty( $member_id ) && ! empty( $code ) ) {
        return true;
    }
    return false;
}

So, we can get into the if statement by only supplying member_id and code on the GET parameter. Back to the main function, the code tries to construct a $query and fetch membership_level value from a certain member using the member_id.

The membership_level then will be assigned to $member_info['membership_level'] which is the new user object in the user registration process. With this condition, a user could register with any membership level from an arbitrary member account.

Authenticated Account Takeover

The main vulnerable code exists in the process_password_reset_using_link function:

public function process_password_reset_using_link() {
    $swpm_reset = filter_input( INPUT_POST, 'swpm-password-reset-using-link' );
    if( is_null( $swpm_reset ) ) {
        return;
    }

    $error_message = '';
    
    $user_login = filter_input( INPUT_POST, 'swpm_user_login', FILTER_UNSAFE_RAW );
    $user_login = sanitize_user( $user_login );

    //Validate password reset key
    $is_valid_key = check_password_reset_key($_GET['key'], $_GET['login']);
    if ( is_wp_error( $is_valid_key ) ) {
        $error_message = __("Error! A password reset request has been submitted but the password reset key is invalid. Please generate a new request.", "simple-membership");
    }

    //Validate password fields match
    $swpm_new_password = filter_input( INPUT_POST, 'swpm_new_password', FILTER_UNSAFE_RAW );
    $swpm_renew_password = filter_input( INPUT_POST, 'swpm_reenter_new_password', FILTER_UNSAFE_RAW );		
    if( $swpm_new_password != $swpm_renew_password ) {
        $error_message = __("Error! Password fields do not match. Please try again.", 'simple-membership');
    }

    //Validate user exists
    $user_data = get_user_by( "login", $user_login );
    if( !$user_data ) {			
        $error_message = __("Error! Invalid password reset request.", 'simple-membership');
    }

    if( strlen( $error_message) > 0 ) {
        //If any error messsage, save it in the transient for output later. The transient will be deleted after it is displayed.
        //The error output is displayed in the form's HTML output file.
        set_transient( "swpm-passsword-reset-error", $error_message );
        return;
    }

    if ( ! empty( $swpm_reset ) && strlen( $error_message ) == 0 ) {
        //Valiation passed. Lets try to reset the password.
        $is_password_reset = SwpmFrontRegistration::get_instance()->reset_password_using_link( $user_data, $swpm_new_password );
        if( $is_password_reset ) {
            $login_page_url = SwpmSettings::get_instance()->get_value( 'login-page-url' );

            // Allow hooks to change the value of login_page_url
            $login_page_url = apply_filters('swpm_register_front_end_login_page_url', $login_page_url);

            $after_pwd_reset = '<div class="swpm-reset-password-success-msg">' . SwpmUtils::_( 'Password Reset Successful. ' ) . SwpmUtils::_( 'Please' ) . ' <a href="' . $login_page_url . '">' . SwpmUtils::_( 'Log In' ) . '</a></div>';
            $after_pwd_reset = apply_filters( 'swpm_password_reset_success_msg', $after_pwd_reset );
            $message_ary = array(
                'succeeded' => true,
                'message'   => $after_pwd_reset,
            );
            SwpmTransfer::get_instance()->set( 'status', $message_ary );
            return;
        }
    }
}

The function handles the process of password reset through a reset password link feature. In the plugin context, the user can enable password reset through a link that will be sent to the user's email.

If we look closely, the code tries to verify the reset password key using the function check_password_reset_key on $_GET['key'] and $_GET['login'] parameter. But the code constructs the $user_data object with a different parameter which is $user_login that is constructed from the swpm_user_login POST parameter.

If the check password reset key is successful, a user's reset password process will be done with a call to SwpmFrontRegistration::get_instance()->reset_password_using_link( $user_data, $swpm_new_password ) . With this condition, a user could provide a valid reset password key and their user's login parameter with any member's login value through the swpm_user_login POST parameter to take over their account with a password reset.

The patch of the vulnerability in the Simple Membership plugin

For the first vulnerability, the vendor decided to check if the SQL query to update the member information via the code parameter is valid. This code value could only be obtained by user's that already completed their payment or process on a paid membership level. The patch can be seen below:

Vulnerability In The Simple Membership Plugin

For the second vulnerability, the vendor decided to match the login parameter used for the reset password key check and the actual user object on the $user_data variable.

The patch for the vulnerability in the Simple Membership plugin can be seen below:

Vulnerability In The Simple Membership Plugin

Conclusion

For custom registration processes, always apply more checks on user-controlled parameters. Check and secure code processes that handle role or membership level assignment of user during the registration process.

For custom password reset processes, make sure to implement the same primary identifiers to check the actual password reset key using the check_password_reset_key function and the user object that will be used to actually reset the user's password.

Timeline

29 August, 2023We found the vulnerability and reached out to the plugin vendor.
30 August, 2023Simple Membership plugin version 4.3.5 released to patch the two reported issues.
25 September, 2023Added the vulnerabilities to the Patchstack vulnerability database.
27 September, 2023Security advisory article publicly released.

Help us make the Internet a safer place

Making the WordPress ecosystem more secure is a team effort, and we believe that plugin developers and security researchers should work together.

  • If you're a plugin developer, join our mVDP program that makes it easier to report, manage and address vulnerabilities in your software.
  • If you're a security researcher, join Patchstack Alliance to report vulnerabilities & earn rewards.

The latest in Security Advisories

Looks like your browser is blocking our support chat widget. Turn off adblockers and reload the page.
crossmenu