Ultimate Membership Pro
Unauthenticated Privilege Escalation
Ultimate Membership Pro
Unauthenticated PHP Object Injection
This blog post is about Ultimate Membership Pro plugin vulnerabilities. If you’re an Ultimate Membership Pro user, please update the theme and plugin to version 12.8 or higher.
All paid Patchstack users are protected from this vulnerability. Sign up for the free Community account first, to scan for vulnerabilities and apply protection for only $5 / site per month with Patchstack. For plugin developers, we have security audit services and Enterprise API for hosting companies.
About the Ultimate Membership Pro Plugin
The plugin Ultimate Membership Pro (premium version), which has nearly 40,000 sales, is one of the more popular premium plugins specifically related to the custom membership feature. This plugin is developed by WPIndeed.
This plugin is an all-in-one WordPress Membership Plugin with endless membership features to manage member subscriptions for valuable content with one-time or recurring Payments.
The security vulnerability
This plugin suffers from Unauthenticated Privilege Escalation, where users can register for any membership level and gain the attached role for it.
The second vulnerability is Unauthenticated PHP Object Injection. This issue occurs when user-supplied input is not properly sanitized before being passed to the deserialization process. Since PHP allows object serialization, an unauthenticated user could pass ad-hoc serialized strings to a vulnerable mayben_userialize
call, resulting in an arbitrary PHP object(s) injection into the application scope.
The described vulnerabilities are patched in version 12.8 and assigned CVE-2024-43240 and CVE-2024-43242 respectively.
Unauthenticated Privilege Escalation
The underlying vulnerable code exists in the setRole and setRoleForRegister functions:
public function setRole( $role='', $postData=[], $shortcodesAttr=[] )
{
// special role for this level?
if ( isset( $postData['lid'] ) ){
$levelData = ihc_get_level_by_id( $postData['lid'] );
if ( isset( $levelData['custom_role_level'] ) && $levelData['custom_role_level']!=-1 && $levelData['custom_role_level']){
return $levelData['custom_role_level'];
}
}
/// CUSTOM ROLE FROM SHORTCODE
if ( isset( $shortcodesAttr['role'] ) && $shortcodesAttr['role'] !== false ){
return $shortcodesAttr['role'];
}
$role = get_option( 'ihc_register_new_user_role' );
if ( $role !== false && $role != '' ){
return $role;
}
$role = get_option( 'default_role' );
if ( $role !== false && $role != '' ){
return $role;
}
return 'subscriber';
}
public function setRoleForRegister( $role='', $postData=[], $shortcodesAttr=[] )
{
// special role for this level?
if ( isset( $postData['lid'] ) ){
$levelData = ihc_get_level_by_id( $postData['lid'] );
if ( isset( $levelData['custom_role_level'] ) && $levelData['custom_role_level']!=-1 && $levelData['custom_role_level']){
return $levelData['custom_role_level'];
}
}
/// CUSTOM ROLE FROM SHORTCODE
if ( isset( $shortcodesAttr['role'] ) && $shortcodesAttr['role'] !== false ){
return $shortcodesAttr['role'];
}
$role = get_option( 'ihc_register_new_user_role' );
if ( $role !== false && $role != '' ){
return $role;
}
$role = get_option( 'default_role' );
if ( $role !== false && $role != '' ){
return $role;
}
return 'subscriber';
}
The function itself is used in the user registration process where $postData is constructed from $_POST global variable. With this, users can supply $postData[‘lid’] and it will be constructed to $levelData variable which contains a custom membership level. Each membership level could also have a custom role attached to it, so in this case, users are able to register to any membership level and gain the attached role for the level via $levelData[‘custom_role_level’].
By default, the attached role on the membership level doesn’t allow an administrator role. However, it could still allow any other high-privilege role or custom role.
Unauthenticated PHP Object Injection
The underlying vulnerable code exists on multiple functions:
- ihc_ajax_stripe_connect_generate_payment_intent
- ihc_ajax_stripe_connect_generate_setup_intent
- user_can_view_the_post
- increment_user_posts_views
- ihc_init
- checkCookies
public function ihc_ajax_stripe_connect_generate_payment_intent()
{
if ( !ihcPublicVerifyNonce() ){
echo json_encode( [
'status' => 0,
'message' => esc_html__( 'Error. Please try again!', 'ihc' ),
] );
die;
}
$session = isset( $_POST['session'] ) ? sanitize_text_field($_POST['session']) : false;
if ( !$session || $session === '' ){
echo json_encode( [
'status' => 0,
'message' => esc_html__( 'Error. Please try again!', 'ihc' ),
] );
die;
}
$checkoutHash = base64_decode( $session );
if ( $checkoutHash === false || $checkoutHash === '' ){
echo json_encode( [
'status' => 0,
'message' => esc_html__( 'Error. Please try again!', 'ihc' ),
] );
die;ph
}
try {
$checkoutData = maybe_unserialize( $checkoutHash );
---------------- CUT HERE ----------------
public function ihc_ajax_stripe_connect_generate_setup_intent()
{
if ( !ihcPublicVerifyNonce() ){
echo json_encode( [
'status' => 0,
'message' => esc_html__( 'Error. Please try again!', 'ihc' ),
] );
die;
}
$session = isset( $_POST['session'] ) ? sanitize_text_field($_POST['session']) : '';
if ( !$session || $session === '' ){
echo json_encode( [
'status' => 0,
'message' => esc_html__( 'Error. Please try again!', 'ihc' ),
] );
die;
}
$checkoutHash = base64_decode( $session );
if ( $checkoutHash === false || $checkoutHash === '' ){
echo json_encode( [
'status' => 0,
'message' => esc_html__( 'Error. Please try again!', 'ihc' ),
] );
die;
}
try {
$checkoutData = maybe_unserialize( $checkoutHash );
---------------- CUT HERE ----------------
public function user_can_view_the_post($block, $block_or_show, $user_levels, $target_levels, $post_id, $usedLocation=''){
if ( $usedLocation === 'wp_menu' || $usedLocation === 'pre_get_posts' ){
return $block;
}
if (empty($block)){
/// ump pages can be seen everytime
if ( $this->isAUmpPage( $post_id ) || !$post_id ){
return $block;
}
/// only if user can view the post (has passed the previous tests)
global $current_user;
$uid = (isset($current_user->ID)) ? $current_user->ID : 0;
$cookie_name = 'ihc_workflow_restrictions_' . $uid;
if (isset($_COOKIE[$cookie_name])){
$cookie_post_arr = sanitize_text_field($_COOKIE[$cookie_name]);
$cookie_post_arr = stripslashes($cookie_post_arr);
$cookie_post_arr = maybe_unserialize($cookie_post_arr);
---------------- CUT HERE ----------------
private function increment_user_posts_views($uid=0, $post_id=0){
if ( headers_sent() ){
// prevent set the cookie after the headers was sent
return;
}
if ( $this->isAUmpPage( $post_id ) || !$post_id ){
return;
}
if ($post_id){
$cookie_name = 'ihc_workflow_restrictions_' . $uid;
if (isset($_COOKIE[$cookie_name])){
$array = sanitize_text_field($_COOKIE[$cookie_name]);
$array = stripslashes($array);
$array = maybe_unserialize($array);
---------------- CUT HERE ----------------
function ihc_init(){
if (isset($_COOKIE['ihc_register'])){
global $ihc_stored_form_values;
$data = maybe_unserialize( stripslashes( sanitize_text_field($_COOKIE['ihc_register']) ) );
---------------- CUT HERE ----------------
public function checkCookies()
{
if( !isset( $_COOKIE['ihc_register'] ) || $_COOKIE['ihc_register'] === '' ){
return ;
}
$data = maybe_unserialize( stripslashes( sanitize_text_field($_COOKIE['ihc_register']) ) );
---------------- CUT HERE ----------------
Most of the affected functions either are attached to a wp_ajax_nopriv or init hook and thus could be reached by unauthenticated users. Since the user’s input from $_POST and $_COOKIE is not sanitized and directly passed to the maybe_unserialize function, this leads to PHP Object Injection and in worst cases could lead to RCE depending on the available gadget chains.
The patch
For the Unauthenticated Privilege Escalation vulnerability, the developer decided to remove or comment out the code for direct assignment of roles from the membership level and use the default role instead. The patch can be seen below:
For the Unauthenticated PHP Object Injection vulnerability, the developer decided to use JSON format instead of using the maybe_unserialize function. The patch can be seen below:
Conclusion
For the custom registration process, always pay attention to the role configuration of the new user. Strictly limit the role to a default role and don’t allow users to arbitrarily set their role when registering a new account.
The deserialization process in PHP is one of the more sensitive processes that could lead to a security issue. In general, we do not recommend using this method to process data that could be partially or fully controlled by user input. We recommend using JSON instead of serialization to process more complex data structures.
Want to learn more about finding and fixing vulnerabilities?
Explore our Academy to master the art of finding and patching vulnerabilities within the WordPress ecosystem. Dive deep into detailed guides on various vulnerability types, from discovery tactics for researchers to robust fixes for developers. Join us and contribute to our growing knowledge base.
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.