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.
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:
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:
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
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.