Capturez le drapeau au WordCamp Europe 2022

Publié: 2022-06-13

Pendant WordCamp Europe 2022, nous avons organisé un concours WordPress Capture The Flag (CTF) à travers quatre défis.

Nous voulions initier les gens au monde addictif de CTF et leur faire découvrir comment les chercheurs en sécurité abordent la chasse aux bogues, comme rechercher des bizarreries dans le code et les combiner pour faire des choses étranges, parfois contre-intuitives.

Défi #1 – Êtes-vous chanceux ?

Défi #2 – Contourner la liste de blocage ?

Défi #3 – Licence pour capturer le drapeau

Défi #4 – Licence au CTF : Partie 2

Si vous souhaitez l'essayer, vous pouvez toujours obtenir les fichiers de défi ici :

hackismet-docker.zipTélécharger

Défi #1 – Êtes-vous chanceux ? (250 points)

Extraits de code pertinents

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

Comment pourrait-il être résolu?

Ce défi présentait un point de terminaison d'API REST accessible via la route /wp-json/hackismet/am-i-lucky . Il a été conçu pour recevoir un paramètre de demande de charge utile et de hachage, concaténer request['payload'] au drapeau et une chaîne de 32 octets aléatoires sécurisés par chiffrement, et comparer le hachage résultant avec request['hash'] .

En lisant la documentation de la fonction crypt(), on peut constater que cette fonction n'est pas binairement sûre (encore !), ce qui signifie qu'un octet nul (%00) peut être utilisé pour tronquer la chaîne à hacher juste avant le drapeau et 32 octets aléatoires. En effet, l'implémentation actuelle de cette fonction dans PHP n'est fondamentalement qu'un alias de la fonction C sous-jacente du même nom, et les chaînes C se terminent par des octets nuls.

Pour obtenir votre drapeau, tout ce que vous aviez à faire était de calculer un hachage avec le message que vous contrôlez et le sel cryptographique utilisé dans le code du plugin, d'utiliser le hachage résultant dans le paramètre "hash", et de mettre votre message dans le "payload" paramètre, concaténé avec un octet nul (%00).

Voici à quoi ressemble un exploit réussi :

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

Défi #2 – Contourner la liste de blocage ? (250 points)

Extraits de code pertinents

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

Comment pourrait-il être résolu?

Ce défi présentait un point de terminaison d'API REST accessible via /wp-json/hackismet/get-option/option_key_you_want .

Le but était assez simple : essayer de divulguer l'option « hackismet_flag_1 ».

Malheureusement, le rappel d'autorisation pour ce point de terminaison a également fait quelques choses pour vous empêcher de saisir simplement toutes les options sur le site :

  • Il a validé que la clé d'option commençait par "hackismet_".
  • Cela garantissait également que l'option que vous vouliez récupérer n'était pas hackismet_flag_1, où se trouvait le drapeau.
  • Pour rendre les choses plus difficiles, la route de l'API a limité les caractères qui pouvaient le faire dans le paramètre de route option_key , n'autorisant que les chaînes correspondant à la regex \w+ .

La fonction de rappel « hackismet_validate_option » a également utilisé les fonctions « strtolower » et « trim » pour tenter de normaliser le paramètre « option_key ». Cela visait à contrecarrer les tentatives d'utilisation de comportements bien documentés de la collation "utf8mb4_unicode_ci" de MySQL, comme le fait que les comparaisons de chaînes ne sont pas sensibles à la casse et qu'il ne se soucie pas non plus des espaces de fin dans les colonnes VARCHAR.

Autres astuces de classement

Pour résoudre ce défi, il fallait trouver d'autres particularités dans la façon dont "utf8mb4_unicode_ci" effectue des recherches de chaînes pour contourner les vérifications en place, et il y avait au moins deux façons de le faire.

Sensibilité aux accents

Comme mentionné dans la documentation officielle de MySQL :

Pour les noms de classement non binaires qui ne spécifient pas la sensibilité aux accents, il est déterminé par la sensibilité à la casse.

Bref : la sensibilité aux accents est une chose. Le classement par défaut de WordPress utilise le composant « _ci » (pour « Case-Insensitive »), ce qui signifie que le classement est également Accent-Insensible.

Ainsi, passer "hackismet_flag_1" contournerait les vérifications dans hackismet_validate_option .

Poids ignorés

L'algorithme de classement Unicode, qui est utilisé par le classement utf8mb4_unicode_ci de MySQL pour comparer et trier les chaînes Unicode, décrit le concept de « poids ignorables » comme suit :


Les poids ignorables sont ignorés par les règles qui construisent des clés de tri à partir de séquences d'éléments de classement. Ainsi, leur présence dans les éléments de classement n'a pas d'incidence sur la comparaison des chaînes à l'aide des clés de tri résultantes . L'attribution judicieuse de poids ignorables dans les éléments de classement est un concept important pour l'UCA.

En bref, l'algorithme calcule un poids pour chaque élément de classement (caractères), et certains d'entre eux sont définis comme ayant un poids par défaut de zéro, ce qui fait que l'algorithme les ignore lors des comparaisons de chaînes.

Il y avait plusieurs façons d'(ab)utiliser ce comportement pour relever le défi, notamment :

  • Ajout d'octets nuls quelque part dans la chaîne (par exemple hackismet_fl%00ag_1 )
  • Insertion de séquences UTF-8 invalides dans la chaîne (par exemple hackismet_fl%c2%80ag_1 )

Vous pouvez trouver beaucoup d'autres combinaisons dans l'implémentation de l'UCA par MySQL.

Contournement de la restriction de caractères du paramètre "option_key"

La variable de route "option_key" a été définie pour ne rien laisser passer d'autre que \w+. C'était un problème. PHP traite chaque chaîne comme une série d'octets au lieu de caractères Unicode comme le fait MySQL, donc envoyer une requête à "/wp-json/hackismet/get-option/hackismet_flag_1" ou "/wp-json/hackismet/get-option/hackismet_fla %00g_1" ne fonctionnerait pas.

Pour contourner cela, la documentation officielle de WordPress sur l'écriture des points de terminaison de l'API REST a un peu aidé, en particulier la ligne où il est écrit :

Par défaut, les routes reçoivent tous les arguments transmis à partir de la requête. Ceux-ci sont fusionnés en un seul ensemble de paramètres , puis ajoutés à l'objet Request, qui est transmis en tant que premier paramètre à votre point de terminaison.

Cela signifie en pratique qu'en visitant /wp-json/hackismet/get-option/test?option_key=hackismet_fla%00g_1 , le paramètre option_key contiendrait "hackismet_fla%00g_1", et non "test", ce qui forcerait également le plugin pour vous donner le drapeau.

Défi #3 – Permis de capturer le drapeau (500 points)

Extraits de code pertinents

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

Comment pourrait-il être résolu?

L'idée derrière ce défi était de simuler un système de gestion et de validation des licences (très) défectueux.

Alors que ce défi était destiné à permettre aux participants d'exploiter une vulnérabilité de condition de course assez ésotérique, un oubli subtil du concepteur du défi l'a rendu résoluble en utilisant une solution non intentionnelle et moins exotique.

Le défi présentait trois points finaux, même s'il n'en faudrait que deux pour obtenir le drapeau :

  • /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\-]+)

Le point de terminaison generate-license a rempli une clé de licence spécifique à la session, qui serait ensuite validée à l'aide du rappel d'autorisation hackismet_validate_license du point de terminaison access-flag-3 . Malheureusement, comme vous n'avez jamais pu voir quelle était la clé de licence générée, vous avez dû trouver un moyen de contourner complètement la vérification de licence afin d'obtenir le drapeau.

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

Une façon de faire était d'avoir $request['key'] contenant une valeur booléenne de "true", et $request['rounds'] une valeur de zéro. En faisant cela, vous vous êtes assuré que $request['key'] n'était pas modifié par plusieurs appels à str_rot13 , et puisque la validation de la licence est effectuée à l'aide de l'opérateur de comparaison lâche de PHP, la comparaison renverrait toujours true.

Cependant, vous ne pouvez pas le faire avec des paramètres GET ou POST normaux, car ceux-ci ne contiennent que des chaînes ou des tableaux. Heureusement, l'API REST WordPress vous permet d'envoyer un corps de requête JSON, même sur des points de terminaison qui ne sont enregistrés que pour utiliser la méthode GET HTTP. Par conséquent, l'envoi des requêtes suivantes vous donnerait le drapeau du défi :

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'

Défi #4 – Licence à CTF : Partie 2 (500 points)

Extraits de code pertinents

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! )

Comment pourrait-il être résolu?

Ce défi présentait trois points finaux (et nécessitait en fait d'utiliser les trois pour être résolu !) :

  • /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+)

Comme vous pouvez le voir, ce sont exactement les mêmes points de terminaison que le dernier défi, la seule différence maintenant est que nous nous assurons que $request['key'] est une chaîne pour éviter le problème de jonglerie de type que nous avons mentionné dans l'autre défi.

La route delete-license explicite a fait exactement ce à quoi vous vous attendiez : supprimez la licence actuelle de la base de données. De même, access-flag-4 simplement renvoyé l'indicateur, en supposant que son rappel d'autorisation, hackismet_validate_license , l'a autorisé.

Comme vous pouvez le voir dans l'extrait de code hackismet_validate_license , le rappel d'autorisation appelé get_option deux fois, une fois pour valider une clé de licence est définie et une autre pour la comparer à celle que nous fournissons. Les deux appels sont séparés par une boucle str_rot13 qui s'exécute pendant autant de tours que défini dans la variable de route $request['rounds'] .

Cela a permis qu'une condition de concurrence se produise en envoyant un grand nombre dans la variable rounds pour retarder la demande suffisamment longtemps pour que nous puissions atteindre le /hackismet/delete-license , supprimant ainsi la licence avant qu'elle ne soit comparée à la nôtre.

Le fait que get_option() par défaut un booléen faux s'il ne trouve pas une option donnée est la cerise sur le gâteau. Étant donné que la fonction ne vérifie jamais si $request['key'] est vide, et false == "" lors de la comparaison lâche de différents types en PHP, cela nous permettrait de contourner complètement les contrôles de sécurité.

Mais ce n'est qu'en théorie !

Le cache à la rescousse !

Comme le montre le code source de la fonction, get_option met en cache le résultat de l'option qu'il récupère, de sorte que toute autre requête pour cette option sur la même requête HTTP n'enverra pas de requêtes SQL supplémentaires distinctes. Cela seul empêche notre attaque par conditions de concurrence de fonctionner. Même si une autre requête supprimait l'option de licence pendant que nous parcourions tous ces appels str_rot13 , get_option ne le saurait pas car le résultat est déjà mis en cache pour cette requête !

Encore une fois, en regardant le code source, il semble que le seul moyen d'empêcher que cela se produise est si wp_installing renvoie… vrai ? Il s'avère que nous pouvons le faire faire.

WordPress est-il déjà installé ?

La fonction wp_installing s'appuie sur la constante WP_INSTALLING pour déterminer si WordPress est en train de s'installer ou de se mettre à jour. La recherche des endroits où cette constante est définie conduit à très peu de résultats, le plus intéressant dans notre cas étant 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();
}

Ce qui le rend particulièrement adapté à notre objectif ici, c'est que l'une des premières choses qu'il fait est d'exécuter require() sur wp-blog-header.php.

Pour faire court : le code qui lance réellement le serveur API REST est lié à l'action parse_request , il ne sera donc disponible que lorsque WordPress configurera en interne les variables de requête nécessaires à The Loop pour faire son travail.

Cela ne se produit que si la fonction wp() est appelée comme dans wp-blog-header.php.

Puisque, en interne, WordPress utilise le paramètre rest_route pour savoir quelle route charger, il suffit d'ajouter ce paramètre à l'URL pour lancer l'API lors de la visite de /wp-activate.php.

En tant que tel, l'attaque finale ressemblait à ceci :

  1. Envoyez une requête à /wp-activate.php?rest_route=/hackismet/access-flag-4/$session_id/$rounds$rounds est un assez grand nombre pour que cette requête s'exécute assez longtemps pour vous permettre de faire l'étape 2.
  2. Envoyez une requête à /wp-json/hackismet/delete-license/$session_id pendant que votre première requête est bloquée à la boucle str_rot13 .
  3. Attendez que votre première demande soit terminée et obtenez votre drapeau.

Conclusion

Nous espérons que vous avez eu autant de plaisir à participer à cette première édition du concours Jetpack Capture The Flag que nous en avons eu à l'organiser. Nous avons hâte de le refaire dans le futur. Pour en savoir plus sur CTF, rendez-vous sur CTF101.org

Crédits

Concepteur du défi : Marc Montpas

Un merci spécial à Harald Eilertsen pour avoir fait passer le mot en personne au WordCamp Europe, et à l'équipe Jetpack Scan pour ses commentaires, son aide et ses corrections.