Don’t throw in the towel just yet!!!!
My story
CVE-2024-9161 is documented as a Broken Access Control (authorization) issue. During a bug-bounty test, I inadvertently found a Remote Code Execution (RCE) vector that abuses this weakness. This indicates the impact goes beyond the MITRE-described inability to only add, remove, or modify metadata and scheme fields.
Detailed Analysis of CVE-2024-9161
Why is this unauthenticated behavior happening?
//src: includes/rest/class-shared.php
register_rest_route(
$this->namespace,
'/updateMeta',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'update_metadata' ],
'args' => $this->get_update_metadata_args(),
'permission_callback' => [ '\\RankMath\\Rest\\Rest_Helper', 'get_object_permissions_check' ],
]
);
register_rest_route(
$this->namespace,
'/updateSchemas',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'update_schemas' ],
'args' => $this->get_update_schemas_args(),
'permission_callback' => [ '\\RankMath\\Rest\\Rest_Helper', 'get_object_permissions_check' ],
]
);
Rankmath has registered two REST APIs, /updateMeta and /updateSchemas. The permission_callback points to rest-helper, so let’s dig deeper to see what’s inside
// src: includes/rest/class-rest-helper.php
public static function get_object_permissions_check( $request ) {
$object_id = $request->get_param( 'objectID' );
$object_type = $request->get_param( 'objectType' );
if ( in_array( $object_type, [ 'post', 'term', 'user' ], true ) ) {
$method = "get_{$object_type}_permissions_check";
return self::$method( $request );
}
return false;
}
The objectType parameter, taken from user input, must be one of three values: post, term, or user. Accordingly, $method will call one of the three functions corresponding to the objectType value (get_post_permissions_check(), get_user_permissions_check(), or get_term_permissions_check()).
- The get_post_permissions_check function will be ruled out because it verifies whether current_user_can has the edit_post capability. Since the user is not logged in, the check will obviously fail.
// src: includes/rest/class-rest-helper.php
public static function get_post_permissions_check( $request ) {
[...truncated...]
if (
current_user_can( $post_type->cap->edit_post, $post->ID ) ||
current_user_can( $post_type->cap->edit_others_posts )
) {
return true;
}
return new WP_Error(
'rest_cannot_edit',
__( 'Sorry, you are not allowed to edit this post.', 'rank-math' ),
[ 'status' => rest_authorization_required_code() ]
);
}
- The get_user_permissions_check function performs no checks and returns immediately. It returns ‘on’, which is effectively equivalent to true — therefore the permission check is bypassed
// src: includes/rest/class-rest-helper.php
public static function get_user_permissions_check( $request ) {
return Helper::get_settings( 'titles.author_add_meta_box' );
}
...........................................................................
// src: includes/class-installer.php
$titles = [ .....
'author_add_meta_box' => 'on',
.....]
- The get_term_permissions_check function calls get_term(); after querying the database we observe two values: 1 and 2. With objectID == 1 → category. The function then checks whether that category value exists in the array returned by get_accessible_taxonomies(). With debugging enabled we can see the category value is present in that array, so using objectID = 1 we can bypass this function
// src: includes/rest/class-rest-helper.php
public static function get_term_permissions_check( $request ) {
$term = self::get_term( $request->get_param( 'objectID' ) );
if ( is_wp_error( $term ) ) {
return $term;
}
if ( ! in_array( $term->taxonomy, array_keys( Helper::get_accessible_taxonomies() ), true ) ) {
return new WP_Error(
'rest_cannot_edit',
__( 'Sorry, you are not allowed to edit this term.', 'rank-math' ),
[ 'status' => rest_authorization_required_code() ]
);
}
return true;
}
...........................................................................
...........................................................................
// Query database
MariaDB [wordpress]> select * from wp_term_taxonomy;
+------------------+---------+----------+-------------+--------+-------+
| term_taxonomy_id | term_id | taxonomy | description | parent | count |
+------------------+---------+----------+-------------+--------+-------+
| 1 | 1 | category | | 0 | 3 |
| 2 | 2 | wp_theme | | 0 | 1 |
+------------------+---------+----------+-------------+--------+-------+
...........................................................................
...........................................................................
//src: includes/helpers/class-taxonomy.php
public static function get_accessible_taxonomies() {
static $accessible_taxonomies;
if ( isset( $accessible_taxonomies ) && did_action( 'wp_loaded' ) ) {
return $accessible_taxonomies;
}
$accessible_taxonomies = get_taxonomies( [ 'public' => true ], 'objects' );
$accessible_taxonomies = self::filter_exclude_taxonomies( $accessible_taxonomies );
if ( ! is_array( $accessible_taxonomies ) ) {
$accessible_taxonomies = [];
}
return $accessible_taxonomies;
}
Therefore, by abusing get_user_permissions_check and get_term_permissions_check, we can successfully bypass authentication and invoke the other functions
Remote Code Execution
After review the auth checks, we move on to the two invoked functions — update_metadata() and update_schemas() — however, I will analyze update_metadata() because it leads directly to RCE.
public function update_metadata( WP_REST_Request $request ) {
[...TRUNCATED...]
$sanitizer = Sanitize::get();
foreach ( $meta as $meta_key => $meta_value ) {
// Delete schema by meta id.
if ( Str::starts_with( 'rank_math_delete_', $meta_key ) ) {
// First, delete the "shortcut" to the new schema.
$schema = \get_metadata_by_mid( $object_type, absint( \str_replace( 'rank_math_delete_schema-', '', $meta_key ) ) ); (1)
if ( ! empty( $schema->meta_value ) ) {
// Maybe unserialize the schema.
$schema = \maybe_unserialize( $schema->meta_value ); (2)
[...TRUNCATED...]
}
$this->update_meta( $object_type, $object_id, $meta_key, $sanitizer->sanitize( $meta_key, $meta_value ) ); (3)
}
return [
'slug' => $new_slug,
'schemas' => DB::get_schemas( $object_id ),
];
}
At (1) it retrieves the metadata value by the user-supplied ID corresponding to rank_math_delete_schema-, then calls get_metadata_by_mid() to fetch the value from the database.
function get_metadata_by_mid( $meta_type, $meta_id ) {
[...TRUNCATED...]
$id_column = ( 'user' === $meta_type ) ? 'umeta_id' : 'meta_id';
// query database lấy metadata
$meta = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table WHERE $id_column = %d", $meta_id ) );
if ( empty( $meta ) ) {
return false;
}
if ( isset( $meta->meta_value ) ) {
// sẽ unserialize trước khi trả về giá trị
$meta->meta_value = maybe_unserialize( $meta->meta_value );
}
return $meta;
}
At (2) it calls maybe_unserialize. If we can control this value, it will result in deserialization.
At (3) we can overwrite any metadata value we want — which gives us what we need to trigger an insecure deserialization.
To get RCE: invoke update_metadata to store a metadata entry with the malicious object, then remove that metadata
Conclusion
I also looked for Rank Math gadget chains to complete a clean chain, but—possibly due to some gaps in my knowledge—I couldn’t find a suitable gadget chain within Rank Math. Therefore, this bug could be combined with gadget chains from other plugins or with older WordPress versions (we can use phpggc to search for existing gadget chains). In short: when you find a bug, try to dig as deep as possible
