Cattura la bandiera al WordCamp Europe 2022

Pubblicato: 2022-06-13

Durante WordCamp Europe 2022, abbiamo organizzato un concorso WordPress Capture The Flag (CTF) attraverso quattro sfide.

Volevamo far conoscere alle persone il mondo avvincente di CTF e consentire alle persone di sperimentare come i ricercatori della sicurezza affrontano la caccia ai bug, come cercare stranezze nel codice e combinarle per fare cose strane, a volte controintuitive.

Sfida n. 1 – Sei fortunato?

Sfida n. 2 – Esclusione dalla block list?

Sfida n. 3 – Licenza per catturare la bandiera

Sfida n. 4 – Licenza a CTF: Parte 2

Se sei interessato a provarlo, puoi comunque ottenere i file della sfida qui:

hackismet-docker.zipDownload

Sfida #1 – Sei fortunato? (250 punti)

Snippet di codice rilevanti

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

Come potrebbe essere risolto?

Questa sfida ha presentato un endpoint API REST a cui è possibile accedere tramite il percorso /wp-json/hackismet/am-i-lucky . È stato progettato per ricevere un payload e un parametro di richiesta hash, concatenare request['payload'] al flag e una stringa di 32 byte casuali crittograficamente protetti e confrontare l'hash risultante con request['hash'] .

Dopo aver letto la documentazione della funzione crypt(), si potrebbe scoprire che questa funzione non è binaria sicura (ancora!), il che significa che un byte nullo (%00) potrebbe essere utilizzato per troncare la stringa da hash subito prima del flag e 32 byte casuali. Questo perché l'attuale implementazione di quella funzione in PHP è fondamentalmente solo un alias della funzione C sottostante con lo stesso nome e le stringhe C terminano con byte nulli.

Per ottenere il tuo flag, tutto ciò che dovevi fare era calcolare un hash con il messaggio che controlli e il sale crittografico utilizzato nel codice del plug-in, utilizzare l'hash risultante nel parametro "hash" e inserire il tuo messaggio nel "payload" parametro, concatenato con un byte nullo (%00).

Ecco come si presentava un exploit di successo:

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

Sfida n. 2 – Esclusione dalla block list? (250 punti)

Snippet di codice rilevanti

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

Come potrebbe essere risolto?

Questa sfida ha presentato un endpoint API REST a cui è possibile accedere tramite /wp-json/hackismet/get-option/option_key_you_want .

L'obiettivo era piuttosto semplice: provare a far trapelare l'opzione "hackismet_flag_1".

Sfortunatamente, il callback dell'autorizzazione per quell'endpoint ha anche fatto alcune cose per impedirti di afferrare semplicemente qualsiasi opzione sul sito:

  • Ha convalidato che la chiave di opzione iniziava con "hackismet_".
  • Ha anche assicurato che l'opzione che intendevi recuperare non fosse hackismet_flag_1, dove si trovava il flag.
  • Per far sembrare le cose più difficili, la route API ha limitato i caratteri che potevano crearla nel parametro option_key route, consentendo solo stringhe corrispondenti alla regex \w+ .

La funzione di callback "hackismet_validate_option" utilizzava anche le funzioni "strtolower" e "trim" nel tentativo di normalizzare il parametro "option_key". Questo per contrastare i tentativi di utilizzare comportamenti ben documentati dalle regole di confronto "utf8mb4_unicode_ci" di MySQL, come il fatto che i confronti di stringhe non fanno distinzione tra maiuscole e minuscole e che non si preoccupa nemmeno degli spazi finali nelle colonne VARCHAR.

Altri trucchi per la collazione

Per risolvere questa sfida, è stato necessario trovare altre particolarità nel modo in cui "utf8mb4_unicode_ci" esegue ricerche di stringhe per aggirare i controlli in atto, e c'erano almeno due modi per farlo.

Sensibilità all'accento

Come menzionato nella documentazione ufficiale di MySQL:

Per i nomi di confronto non binari che non specificano la sensibilità all'accento, è determinata dalla distinzione tra maiuscole e minuscole.

In poche parole: la sensibilità all'accento è una cosa. Le regole di confronto predefinite di WordPress utilizzano il componente "_ci" (per "senza distinzione tra maiuscole e minuscole"), il che significa che anche le regole di confronto sono insensibili all'accento.

Pertanto, passare "hackismet_flag_1" ignorerebbe i controlli in hackismet_validate_option .

Pesi Ignorabili

L'algoritmo di confronto Unicode, utilizzato dal confronto utf8mb4_unicode_ci di MySQL per confrontare e ordinare le stringhe Unicode, descrive il concetto di "pesi ignorabili" come segue:


I pesi ignorabili vengono passati dalle regole che costruiscono chiavi di ordinamento da sequenze di elementi di confronto. Pertanto, la loro presenza negli elementi di confronto non influisce sul confronto delle stringhe utilizzando le chiavi di ordinamento risultanti . L'assegnazione giudiziosa di pesi ignorabili negli elementi di confronto è un concetto importante per l'UCA.

In breve, l'algoritmo calcola un peso per ogni elemento di confronto (caratteri) e alcuni di essi sono definiti come aventi un peso predefinito pari a zero, il che in effetti fa sì che l'algoritmo li ignori quando esegue confronti di stringhe.

C'erano diversi modi per (ab)usare quel comportamento per vincere la sfida, tra cui:

  • Aggiunta di byte nulli da qualche parte all'interno della stringa (ad esempio hackismet_fl%00ag_1 )
  • Inserimento di sequenze UTF-8 non valide all'interno della stringa (es. hackismet_fl%c2%80ag_1 )

Puoi trovare molte altre combinazioni nell'implementazione di MySQL dell'UCA.

Bypassare la restrizione dei caratteri del parametro "option_key".

La variabile di percorso "option_key" è stata definita per non far passare nient'altro che \w+. Quello era un problema. PHP tratta ogni stringa come una serie di byte invece di caratteri unicode come fa MySQL, quindi inviando una richiesta a “/wp-json/hackismet/get-option/hackismet_flag_1” o “/wp-json/hackismet/get-option/hackismet_fla %00g_1” non funzionerebbe.

Per aggirarlo, la documentazione ufficiale di WordPress sulla scrittura di endpoint API REST ha aiutato un po', in particolare la riga in cui dice:

Per impostazione predefinita, le rotte ricevono tutti gli argomenti passati dalla richiesta. Questi vengono uniti in un unico set di parametri , quindi aggiunti all'oggetto Request, che viene passato come primo parametro al tuo endpoint

Ciò significa in pratica che visitando /wp-json/hackismet/get-option/test?option_key=hackismet_fla%00g_1 , il parametro option_key conterrà "hackismet_fla%00g_1" e non "test", che forzerebbe anche il plugin per darti la bandiera.

Sfida n. 3 – Licenza per catturare la bandiera (500 punti)

Snippet di codice rilevanti

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

Come potrebbe essere risolto?

L'idea alla base di questa sfida era simulare un sistema di convalida e gestione delle licenze (molto) danneggiato.

Sebbene questa sfida avesse lo scopo di consentire ai partecipanti di sfruttare una vulnerabilità di condizione razziale piuttosto esoterica, una sottile svista da parte del progettista della sfida l'ha resa risolvibile utilizzando una soluzione non intenzionale e meno esotica.

La sfida presentava tre punti finali, anche se solo due sarebbero stati necessari per ottenere la bandiera:

  • /hackismet/genera-licenza/(?P<id_sessione>[0-9a-f\-]+)/(?<round>\d+)
  • /hackismet/access-flag-3/(?P<id_sessione>[0-9a-f\-]+)/(?<round>\d+)
  • /hackismet/delete-license/(?P<id_sessione>[0-9a-f\-]+)

L'endpoint generate-license ha popolato una chiave di licenza specifica per la sessione, che sarebbe stata quindi convalidata utilizzando il callback dell'autorizzazione hackismet_validate_license dell'endpoint access-flag-3 . Sfortunatamente, poiché non hai mai visto quale fosse la chiave di licenza effettiva generata, hai dovuto trovare un modo per aggirare del tutto il controllo della licenza per ottenere la bandiera.

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

Un modo per farlo era fare in modo che $request['key'] contenga un valore booleano di "true" e $request['rounds'] un valore pari a zero. In questo modo, ti sei assicurato che $request['key'] non sia stato modificato da più chiamate a str_rot13 e poiché la convalida della licenza viene eseguita utilizzando l'operatore di confronto libero di PHP, il confronto sarebbe sempre restituito true.

Tuttavia, non è possibile farlo con i normali parametri GET o POST , poiché questi contengono sempre e solo stringhe o array. Fortunatamente, l'API REST di WordPress ti consente di inviare un corpo di richiesta JSON, anche su endpoint registrati solo per utilizzare il metodo GET HTTP. Di conseguenza, l'invio delle seguenti richieste ti darebbe la bandiera della sfida:

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'

Sfida #4 – Licenza a CTF: Parte 2 (500 punti)

Snippet di codice rilevanti

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

Come potrebbe essere risolto?

Questa sfida presentava tre punti finali (e in realtà richiedeva l'utilizzo di tutti e tre per essere risolta!):

  • /hackismet/genera-licenza/(?P<id_sessione>[0-9a-f\-]+)/(?P<round>\d+)
  • /hackismet/delete-license/(?P<id_sessione>[0-9a-f\-]+)
  • /hackismet/access-flag-4/(?P<id_sessione>[0-9a-f\-]+)/(?P<round>\d+)

Come puoi vedere, quelli sono gli stessi endpoint dell'ultima sfida, l'unica differenza ora è che ci assicuriamo che $request['key'] sia una stringa per prevenire il problema di giocoleria dei tipi menzionato nell'altra sfida.

L'autoesplicativo percorso di delete-license ha fatto esattamente quello che ti aspetteresti: rimuovere la licenza corrente dal database. Allo stesso modo, access-flag-4 ha semplicemente restituito il flag, supponendo che il suo callback di autorizzazione, hackismet_validate_license , consentisse che accadesse.

Come puoi vedere dallo snippet di codice hackismet_validate_license , il callback di autorizzazione chiamato get_option due volte, una volta per convalidare una chiave di licenza è impostata e un'altra per confrontarla effettivamente con quella che stiamo fornendo. Entrambe le chiamate sono separate da un ciclo str_rot13 che viene eseguito per tutti i round definiti nella variabile di percorso $request['rounds'] .

Ciò ha reso possibile il verificarsi di una race condition inviando un numero elevato nella variabile round per ritardare la richiesta abbastanza a lungo da consentirci di raggiungere l' /hackismet/delete-license , eliminando effettivamente la licenza prima che venga confrontata con la nostra.

Il fatto che get_option() restituisca per impostazione predefinita un booleano false se non trova una determinata opzione è la ciliegina sulla torta. Poiché la funzione non controlla mai se $request['key'] è vuota e false == ““ quando si confrontano liberamente diversi tipi in PHP, questo ci consentirebbe di bypassare completamente i controlli di sicurezza.

Ma questo è solo in teoria!

Caching in soccorso!

Come si può vedere dal codice sorgente della funzione, get_option memorizza nella cache il risultato di qualsiasi opzione stia recuperando, quindi qualsiasi ulteriore richiesta per quell'opzione sulla stessa richiesta HTTP non invierà ulteriori query SQL separate. Questo da solo impedisce al nostro attacco di race condition di funzionare. Anche se un'altra richiesta eliminasse l'opzione di licenza mentre stiamo scorrendo tutte quelle chiamate str_rot13 , get_option non lo saprebbe perché il risultato è già memorizzato nella cache per quella richiesta!

Ancora una volta, guardando il codice sorgente, sembra che l'unico modo per evitare che ciò accada è se wp_installing restituisce... vero? A quanto pare, possiamo farcela.

WordPress è già installato?

La funzione wp_installing si basa sulla costante WP_INSTALLING per determinare se WordPress sta attualmente installando o aggiornandosi. La ricerca di luoghi in cui questa costante è definita porta a pochissimi risultati, il più interessante nel nostro caso è 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();
}

Ciò che lo rende particolarmente adatto al nostro scopo qui, è che una delle prime cose che fa è eseguire require() su wp-blog-header.php.

Per farla breve: il codice che avvia effettivamente il server API REST è agganciato all'azione parse_request , quindi sarà disponibile solo quando WordPress imposterà internamente le variabili di query necessarie affinché The Loop faccia il suo lavoro.

Ciò si verifica solo se la funzione wp() viene chiamata come in wp-blog-header.php.

Poiché, internamente, WordPress utilizza il parametro rest_route per sapere quale percorso caricare, aggiungere quel parametro all'URL è tutto ciò che serve per avviare l'API mentre si visita /wp-activate.php.

In quanto tale, l'attacco finale era simile a questo:

  1. Invia una richiesta a /wp-activate.php?rest_route=/hackismet/access-flag-4/$session_id/$rounds dove $rounds è un numero abbastanza grande per far durare questa richiesta abbastanza a lungo da permetterti di fare il passaggio #2.
  2. Invia una richiesta a /wp-json/hackismet/delete-license/$session_id mentre la tua prima richiesta è bloccata nel ciclo str_rot13 .
  3. Attendi che la tua prima richiesta finisca e prendi la tua bandiera.

Conclusione

Ci auguriamo che vi siate divertiti a partecipare a questa prima edizione del concorso Jetpack Capture The Flag come lo abbiamo fatto noi. Non vediamo l'ora di farlo di nuovo in futuro. Per saperne di più su CTF, controlla CTF101.org

Crediti

Designer della sfida: Marc Montpas

Un ringraziamento speciale a Harald Eilertsen per aver sparso la voce di persona a WordCamp Europe e al team di Jetpack Scan per feedback, aiuto e correzioni.