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; } |