29 Jun 2023

Now Fixed Role Change Vulnerability in Ultimate Member Was Zero-Day

On Tuesday, a new version of the WordPress plugin Ultimate Member was released. The changelog for that version, 2.6.4, didn’t mention a security fix, but there was an upgrade notice for that version, which reads “This version fixes a security related bug. Upgrade immediately.” Unfortunately, it looks like upgrade notices in the readme.txt for plugins, like that one, is only shown on the WordPress Updates admin page, /wp-admin/update-core.php.

Yesterday, another version was released, 2.6.5, which had a changelog entry that is fairly clear as to what was at issue:

  • Fixed: A privilege escalation vulnerability used through UM Forms. Known in the wild that vulnerability allowed strangers to create administrator-level WordPress users. Please update immediately and check all administrator-level users on your website.

Though in our looking over this so far, it appears that 2.6.5 didn’t really add anymore to the fix. The fix isn’t ideal either, as we detail below.

While not mentioned by the developer, the existence of exploitation of this vulnerability before it was fixed was at least reported to the developer by the web host Tiger Technologies, who had investigated a website exploited through it. Most security providers don’t even figure that out, so that is a web host going the extra mile

We tested and confirmed that our firewall plugin for WordPress protected against exploitation of this vulnerability, even before we knew about the vulnerability, as part of its protection against zero-day vulnerabilities.

Bypassing Security Check

The exploitation of this vulnerability involved what, at first glance, seems odd, as it involves what appears to an errant backslash in the malicious payload. Looking at the underlying code explains why that is.

A WordPress user’s role is stored in a user_meta database table entry. The plugin has code that allows updating user_meta entries and it tries to restrict updating the one that stores the user role. That happens in the function update_profile() in the file /includes/core/class-user.php. Here is how that looked as of 2.6.3:

2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
function update_profile( $changes ) {
 
	$args['ID'] = $this->id;
 
	/**
	 * UM hook
	 *
	 * @type filter
	 * @title um_before_update_profile
	 * @description Change update profile changes data
	 * @input_vars
	 * [{"var":"$changes","type":"array","desc":"User Profile Changes"},
	 * {"var":"$user_id","type":"int","desc":"User ID"}]
	 * @change_log
	 * ["Since: 2.0"]
	 * @usage
	 * <!--?php add_filter( 'um_before_update_profile', 'function_name', 10, 2 ); ?-->
	 * @example
	 * <!--?php * add_filter( 'um_before_update_profile', 'my_before_update_profile', 10, 2 ); * function my_before_update_profile( $changes, $user_id ) { * // your code here * return $changes; * } * ?-->
	 */
	$changes = apply_filters( 'um_before_update_profile', $changes, $args['ID'] );
 
	foreach ( $changes as $key => $value ) {
		if ( in_array( $key, $this->banned_keys ) ) {
			continue;
		}
 
		if ( ! in_array( $key, $this->update_user_keys ) ) {
			if ( $value === 0 ) {
				update_user_meta( $this->id, $key, '0' );
			} else {
				update_user_meta( $this->id, $key, $value );
			}

The code stops the update process if the entry to be updated is one a set of “banned_keys”. Those are as follows:

87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
$this->banned_keys = array(
	'metabox',
	'postbox',
	'meta-box',
	'dismissed_wp_pointers',
	'session_tokens',
	'screen_layout',
	'wp_user-',
	'dismissed',
	'cap_key',
	$wpdb->get_blog_prefix() . 'capabilities',
	'managenav',
	'nav_menu',
	'user_activation_key',
	'level_',
	$wpdb->get_blog_prefix() . 'user_level',
);

That includes the role entry, $wpdb->get_blog_prefix() . ‘capabilities’, which with the default WordPress database prefix would be “wp_capabilities”. The attacker was adding a backslash to that, so something like “wp_ca\pabilities”. Doing that invalidates the restriction on updating that entry.

The backslash will later be removed by the WordPress user function update_metadata() before WordPress does the update:

$meta_key     = wp_unslash( $meta_key );

So the backslash stops the restriction on updating the value, but doesn’t stop it from being updated by WordPress.

A better approach would be to only allow specified user_meta entries to be updated, instead of trying to block some from being updated, since as this shows, it might be possible to bypass things.

The change made was add a callback function to the action hook when WordPress is updating the user_meta entry, which occurs after the backslash is removed, to again try to restrict what can be updated:

166
add_action( 'update_user_metadata', array( &$this, 'avoid_banned_keys' ), 10, 3 );
178
179
180
181
182
183
184
185
186
187
188
public function avoid_banned_keys( $check, $object_id, $meta_key ) {
	if ( false === $this->updating_process ) {
		return $check;
	}
 
	if ( in_array( $meta_key, $this->banned_keys, true ) ) {
		$check = false;
	}
 
	return $check;
}

Plugin Security Scorecard Grade for Ultimate Member

Checked on November 23, 2024
C+

See issues causing the plugin to get less than A+ grade

Leave a Reply

Your email address will not be published. Required fields are marked *