Capturați steagul la WordCamp Europe 2022

Publicat: 2022-06-13

În timpul WordCamp Europe 2022, am organizat o competiție WordPress Capture The Flag (CTF) în patru provocări.

Am vrut să le prezentăm oamenilor în lumea care provoacă dependență a CTF și să-i lăsăm pe oameni să experimenteze modul în care cercetătorii în securitate abordează vânătoarea de erori, cum ar fi să caute ciudățenii în cod și să le combinăm pentru a face lucruri ciudate, uneori contraintuitive.

Provocarea #1 – Ești norocos?

Provocarea #2 – Ocolirea listei de blocuri?

Provocarea nr. 3 – Licență pentru a captura steagul

Provocarea #4 – Licență pentru CTF: Partea 2

Dacă sunteți interesat să îl încercați, puteți obține în continuare fișierele de provocare aici:

hacksmet-docker.zipDownload

Provocare #1 – Ești norocos? (250 puncte)

Fragmente de cod relevante

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

Cum ar putea fi rezolvat?

Această provocare a prezentat un punct final API REST care putea fi accesat prin ruta /wp-json/hackismet/am-i-lucky . A fost conceput pentru a primi un parametru de cerere de sarcină utilă și hash, concatenează request['payload'] la steag și un șir de 32 de octeți aleatori securizați criptografic și pentru a compara hashul rezultat cu request['hash'] .

Citind documentația funcției crypt(), s-ar putea constata că această funcție nu este sigură în binar (încă!), ceea ce înseamnă că un octet nul (%00) ar putea fi folosit pentru a trunchia șirul de caractere care urmează să fie hașat chiar înainte de flag și 32 de octeți aleatori. Acest lucru se datorează faptului că implementarea curentă a acelei funcții în PHP este practic doar un alias al funcției C subiacente cu același nume, iar șirurile C se termină cu octeți nuli.

Pentru a obține steagul dvs., tot ce trebuia să faceți a fost să calculați un hash cu mesajul pe care îl controlați și sarea criptografică folosită în codul pluginului, să utilizați hashul rezultat în parametrul „hash” și să vă puneți mesajul în „payload” parametru, concatenat cu un octet nul (%00).

Iată cum arăta un exploit de succes:

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

Provocarea #2 – Ocolirea listei de blocuri? (250 puncte)

Fragmente de cod relevante

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

Cum ar putea fi rezolvat?

Această provocare a prezentat un punct final API REST care putea fi accesat prin /wp-json/hackismet/get-option/option_key_you_want .

Scopul a fost destul de simplu: încercați să scurgeți opțiunea „hacksismet_flag_1”.

Din păcate, permisiunea de apel invers pentru acel punct final a făcut și câteva lucruri pentru a vă împiedica să utilizați pur și simplu orice opțiune de pe site:

  • S-a validat că cheia de opțiune începe cu „hacksmet_”.
  • De asemenea, s-a asigurat că orice opțiune pe care intenționați să o preluați nu era hacksmet_flag_1, unde se afla steagul.
  • Pentru a face lucrurile să pară mai dificile, ruta API a limitat caracterele care ar putea fi incluse în parametrul de ruta opțiune_key, permițând doar șiruri care se potrivesc cu expresia regex \w+ .

Funcția de apel invers „hacksmet_validate_option” a folosit și funcțiile „strtolower” și „trim” în încercarea de a normaliza parametrul „option_key”. Acest lucru a fost pentru a zădărnici încercările de a utiliza comportamente bine documentate din colarea MySQL „utf8mb4_unicode_ci”, cum ar fi faptul că comparațiile de șiruri nu sunt sensibile la majuscule și că nici nu îi pasă de spațiile finale din coloanele VARCHAR.

Alte trucuri de colare

Pentru a rezolva această provocare, a trebuit să găsească alte particularități în modul în care „utf8mb4_unicode_ci” efectuează căutări de șiruri pentru a ocoli verificările existente și existau cel puțin două moduri de a face acest lucru.

Sensibilitate la accent

După cum se menționează în documentația oficială MySQL:

Pentru numele de colare nebinare care nu specifică sensibilitatea la accent, aceasta este determinată de sensibilitatea majusculelor.

Pe scurt: sensibilitatea la accent este un lucru. Colaţionarea implicită a WordPress utilizează componenta „_ci” (pentru „Insensibilitate la majuscule şi minuscule”), ceea ce înseamnă că colaţionarea este, de asemenea , insensibilă la accent.

Astfel, trecerea „hackismet_flag_1” ar ocoli verificările din hackismet_validate_option .

Greutăți ignorabile

Algoritmul de colare Unicode, care este folosit de colarea MySQL utf8mb4_unicode_ci pentru a compara și sorta șirurile Unicode descrie conceptul de „greutăți ignorabile” după cum urmează:


Greutățile ignorabile sunt trecute de regulile care construiesc cheile de sortare din secvențe de elemente de colare. Astfel, prezența lor în elementele de colare nu afectează compararea șirurilor de caractere folosind cheile de sortare rezultate . Atribuirea judicioasă a ponderilor ignorabile în elementele de colare este un concept important pentru UCA.

Pe scurt, algoritmul calculează o pondere pentru fiecare element de colare (caractere), iar unele dintre ele sunt definite ca având o pondere implicită de zero, ceea ce face ca algoritmul să le ignore atunci când face comparații de șiruri.

Au existat mai multe moduri de (ab)utilizare a acestui comportament pentru a depăși provocarea, inclusiv:

  • Adăugarea octeților nuli undeva în interiorul șirului (de exemplu hackismet_fl%00ag_1 )
  • Se inserează secvențe UTF-8 nevalide în interiorul șirului (de ex hackismet_fl%c2%80ag_1 )

Puteți găsi o mulțime de alte combinații în implementarea de către MySQL a UCA.

Ocolirea restricției de caractere a parametrului „option_key”.

Variabila de rută „option_key” a fost definită pentru a nu lăsa să treacă altceva decât \w+. Asta a fost o problemă. PHP tratează fiecare șir ca o serie de octeți în loc de caractere unicode, așa cum o face MySQL, deci trimite o solicitare către „/wp-json/hackismet/get-option/hackismet_flag_1” sau „/wp-json/hackismet/get-option/hackismet_fla %00g_1” nu ar funcționa.

Pentru a ocoli acest lucru, documentația oficială a WordPress despre scrierea punctelor finale REST API a ajutat puțin, în special linia în care scrie:

În mod implicit, rutele primesc toate argumentele transmise de la cerere. Acestea sunt îmbinate într-un singur set de parametri , apoi adăugate la obiectul Solicitare, care este transmis ca prim parametru la punctul final.

Ceea ce înseamnă în practică este că, la vizitarea /wp-json/hackismet/get-option/test?option_key=hackismet_fla%00g_1 , parametrul option_key va conține „hackismet_fla%00g_1”, și nu „test”, care ar forța și plugin pentru a vă oferi steagul.

Provocarea #3 – Licență pentru a captura steagul (500 de puncte)

Fragmente de cod relevante

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

Cum ar putea fi rezolvat?

Ideea din spatele acestei provocări a fost de a simula un sistem de gestionare și validare a licențelor (foarte) stricat.

În timp ce această provocare a fost menită să permită participanților să exploateze o vulnerabilitate destul de ezoterică a condiției de rasă, o trecere subtilă din partea designerului provocării a făcut-o rezolvabilă folosind o soluție neintenționată, mai puțin exotică.

Provocarea a prezentat trei puncte finale, chiar dacă doar două ar fi necesare pentru a obține steagul:

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

Punctul final generate-license a populat o cheie de licență specifică sesiunii, care urma să fie apoi validată utilizând apelul invers al permisiunii hackismet_validate_license a punctului final access-flag-3 . Din păcate, deoarece nu ați putut vedea niciodată care este cheia de licență generată efectiv, a trebuit să găsiți o modalitate de a ocoli verificarea licenței pentru a obține steag.

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

O modalitate de a face asta a fost ca $request['key'] să conţină o valoare booleană de „true”, iar $request['rounds'] o valoare zero. Făcând acest lucru, v-ați asigurat că $request['key'] nu a fost modificat de apeluri multiple către str_rot13 și, deoarece validarea licenței se face folosind operatorul de comparație liber al PHP, comparația va returna întotdeauna adevărată.

Cu toate acestea, nu ați putea face asta cu parametrii GET sau POST obișnuiți, deoarece aceștia conțin întotdeauna numai șiruri sau matrice. Din fericire, WordPress REST API vă permite să trimiteți un corp de solicitare JSON, chiar și pe punctele finale care sunt înregistrate doar pentru a utiliza metoda GET HTTP. Drept urmare, trimiterea următoarelor solicitări ți-ar oferi steagul provocării:

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'

Provocarea #4 – Licență pentru CTF: Partea 2 (500 de puncte)

Fragmente de cod relevante

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

Cum ar putea fi rezolvat?

Această provocare a prezentat trei puncte finale (și de fapt a necesitat utilizarea tuturor celor trei pentru a fi rezolvate!):

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

După cum puteți vedea, acestea sunt aceleași puncte finale ca și ultima provocare, singura diferență acum este că ne asigurăm că $request['key'] este un șir pentru a preveni problema jonglarii cu tipurile pe care am menționat-o în cealaltă provocare.

Ruta delete-license care se explică de la sine a făcut exact ceea ce v-ați aștepta să: eliminați licența curentă din baza de date. În mod similar, access-flag-4 a returnat pur și simplu steag-ul, presupunând că permisiunea sa de apel invers, hackismet_validate_license , a permis acest lucru.

După cum puteți vedea din fragmentul de cod hackismet_validate_license , permisiunea de apel invers numit get_option două ori, o dată pentru a valida o cheie de licență este setată și alta pentru a o compara efectiv cu cea pe care o oferim. Ambele apeluri sunt separate printr-o buclă str_rot13 care rulează pentru atâtea runde câte sunt definite în variabila de rută $request['rounds'] .

Acest lucru a făcut posibil ca o condiție de cursă să apară prin trimiterea unui număr mare în variabila runde pentru a întârzia solicitarea suficient de mult pentru ca noi să atingem punctul final /hackismet/delete-license , ștergând efectiv licența înainte de a fi comparată cu a noastră.

Faptul că get_option() returnează implicit un boolean false dacă nu găsește o anumită opțiune este cireasa de pe tort. Deoarece funcția nu verifică niciodată dacă $request['key'] este gol, și false == „“ atunci când comparăm vag diferite tipuri în PHP, acest lucru ne-ar permite să ocolim complet verificările de securitate.

Dar asta este doar în teorie!

Cache pentru salvare!

După cum se poate vedea din codul sursă al funcției, get_option memorează în cache rezultatul oricărei opțiuni pe care o preia, astfel încât orice cerere ulterioară pentru acea opțiune pe aceeași cerere HTTP nu va trimite interogări SQL separate suplimentare. Numai acest lucru împiedică atacul nostru privind starea de rasă să funcționeze. Chiar dacă o altă solicitare ar șterge opțiunea de licență în timp ce parcurgem toate acele apeluri str_rot13 , get_option nu ar ști, deoarece rezultatul a fost deja stocat în cache pentru acea cerere!

Din nou, uitându-ne la codul sursă, se pare că singura modalitate de a preveni acest lucru este dacă wp_installing returnează... adevărat? După cum se dovedește, putem face asta.

Este instalat încă WordPress?

Funcția wp_installing se bazează pe constanta WP_INSTALLING pentru a determina dacă WordPress se instalează în prezent sau se actualizează singur. Căutarea locurilor unde este definită această constantă duce la foarte puține rezultate, cel mai interesant în cazul nostru fiind 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();
}

Ceea ce îl face deosebit de potrivit pentru scopul nostru aici, este că unul dintre primele lucruri pe care le face este să ruleze require() pe wp-blog-header.php.

Pe scurt: codul care lansează de fapt serverul API REST este conectat la acțiunea parse_request , deci va fi disponibil numai atunci când WordPress setează intern variabilele de interogare necesare pentru ca The Loop să-și facă treaba.

Acest lucru se întâmplă numai dacă funcția wp() este apelată așa cum este în wp-blog-header.php.

Deoarece, pe plan intern, WordPress folosește parametrul rest_route pentru a ști ce rută să încarce, adăugarea acelui parametru la adresa URL este tot ce este nevoie pentru a lansa API-ul în timp ce accesați /wp-activate.php.

Ca atare, atacul final a arătat cam așa:

  1. Trimiteți o solicitare la /wp-activate.php?rest_route=/hackismet/access-flag-4/$session_id/$rounds unde $rounds este un număr destul de mare pentru ca această solicitare să ruleze suficient de lungă pentru a vă permite să faceți pasul #2.
  2. Trimiteți o solicitare la /wp-json/hackismet/delete-license/$session_id în timp ce prima dvs. solicitare este blocată în bucla str_rot13 .
  3. Așteptați ca prima cerere să se termine și obțineți steagul.

Concluzie

Sperăm că v-ați distrat la fel de mult participând la această primă ediție a competiției Jetpack Capture The Flag, așa cum ne-am desfășurat noi. Așteptăm cu nerăbdare să facem acest lucru din nou cândva în viitor. Pentru a afla mai multe despre CTF, accesați CTF101.org

credite

Designer provocări: Marc Montpas

Mulțumiri speciale lui Harald Eilertsen pentru răspândirea în persoană la WordCamp Europe și echipei Jetpack Scan pentru feedback, ajutor și corecții.