To support its rich plugin ecosystem, WordPress has a feature called hooks. These hooks, with names like wp_enqueue_scripts or publish_post, allow plugins to ‘hook’ (as the name suggests) almost every part of the WordPress lifecycle. Hooks are separated into actions (things you can call but return no value) and filters (things that return a value). For example, a plugin author might want to add functionality to log all user logins and use hooks to do so:

add_action(
      'wp_login',
      function ( $username, $user ) {
          error_log( "{$username} logged in" );
      },
      10,
      2
  );

Action hooks can also be called manually with do_action(). When logging a user in, rather than just calling some internal ->wpLogin() function, WordPress uses do_action('wp_login', $username, $user_obj). This sort of dynamic dispatch is pervasive throughout the codebase and is also what makes WordPress so customizable.

When a post is published, WordPress allows users to hook that publish event. They do so by calling:

do_action(
    "{$new_status}_{$post->post_type}",
    $post->ID,
    $post
);

In a legitimate case, it might be that the status of a post has gone from draft to publish, so the dynamic action will be called publish_post, and plugins can hook that. Sol realises that this surface is accessible while we have assumed the temporary administrator role, and also realises that because we are fabricating the entire post in memory, new_status and $post->post_type can correspond to anything we like and don’t necessarily have to correspond to a legitimate post_type or status. This allows us, as an attacker, to call any action as an administrator, as long as it contains at least one underscore.

The major problem is that $post->ID is under our control, but it’s only an ID. Additionally, $post is a WP_Post object. That means that we have almost no control over the arguments that we provide to the actions. Sol again comes up with a strategy to solve this: it targets the parse_request hook. This hook is called at the very start of the request lifecycle, before almost anything is done. The result is that calling parse_request will replay the entire Batch API request from the beginning, except that now, we still have our temporarily assumed administrator role.

Now that we have all the pieces, let’s craft the exploit.