Zdobądź flagę na WordCamp Europe 2022

Opublikowany: 2022-06-13

Podczas WordCamp Europe 2022 przeprowadziliśmy konkurs WordPress Capture The Flag (CTF) w ramach czterech wyzwań.

Chcieliśmy wprowadzić ludzi w uzależniający świat CTF i pozwolić ludziom doświadczyć, jak badacze bezpieczeństwa podchodzą do poszukiwania błędów, takich jak szukanie dziwnych rzeczy w kodzie i łączenie ich w dziwne, czasem sprzeczne z intuicją rzeczy.

Wyzwanie nr 1 – czy masz szczęście?

Wyzwanie #2 – Ominięcie listy zablokowanych?

Wyzwanie #3 – Licencja na zdobycie flagi

Wyzwanie #4 – Licencja na CTF: Część 2

Jeśli chcesz go wypróbować, nadal możesz pobrać pliki wyzwania tutaj:

hackismet-docker.zipPobierz

Wyzwanie #1 – Masz szczęście? (250 punktów)

Odpowiednie fragmenty kodu

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

Jak można to rozwiązać?

To wyzwanie przedstawia punkt końcowy interfejsu API REST, do którego można uzyskać dostęp za pośrednictwem trasy /wp-json/hackismet/am-i-lucky . Został zaprojektowany do odbierania ładunku i parametru żądania skrótu, łączenia request['payload'] z flagą i ciągu 32 losowo zabezpieczonych kryptograficznie bajtów oraz porównywania wynikowego skrótu z request['hash'] .

Po przeczytaniu dokumentacji funkcji crypt() można stwierdzić, że ta funkcja nie jest binarnie bezpieczna (jeszcze!), co oznacza, że ​​bajt zerowy (%00) może zostać użyty do obcięcia ciągu znaków do zaszyfrowania tuż przed flagą i 32 losowe bajty. Dzieje się tak, ponieważ obecna implementacja tej funkcji w PHP jest w zasadzie tylko aliasem podstawowej funkcji C o tej samej nazwie, a łańcuchy C kończą się bajtami null.

Aby uzyskać swoją flagę, wystarczyło obliczyć hash z wiadomością, którą kontrolujesz i sól kryptograficzną użytą w kodzie wtyczki, użyć wynikowego hasza w parametrze „hash” i umieścić wiadomość w „payload” parametr połączony z bajtem null (%00).

Oto jak wyglądał udany exploit:

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

Wyzwanie #2 – Ominięcie listy zablokowanych? (250 punktów)

Odpowiednie fragmenty kodu

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

Jak można to rozwiązać?

To wyzwanie przedstawia punkt końcowy interfejsu API REST, do którego można uzyskać dostęp za pośrednictwem /wp-json/hackismet/get-option/option_key_you_want .

Cel był dość prosty: spróbować ujawnić opcję „hackismet_flag_1”.

Niestety, wywołanie zwrotne uprawnień dla tego punktu końcowego również zrobiło kilka rzeczy, które uniemożliwiły ci po prostu skorzystanie z jakichkolwiek opcji w witrynie:

  • Potwierdzono, że klucz opcji zaczynał się od „hackismet_”.
  • Zapewniło to również, że opcja, którą zamierzałeś pobrać, nie była hackismet_flag_1, gdzie znajdowała się flaga.
  • Aby wszystko wyglądało na trudniejsze, trasa API ograniczyła, które znaki mogą się znaleźć w parametrze trasy option_key, zezwalając tylko na ciągi pasujące do wyrażenia regularnego \w+ .

Funkcja zwrotna „hackismet_validate_option” również użyła funkcji „strtolower” i „trim” w celu normalizacji parametru „option_key”. Miało to udaremnić próby użycia dobrze udokumentowanych zachowań z sortowania „utf8mb4_unicode_ci” MySQL, takich jak fakt, że porównania ciągów nie są rozróżniane wielkością liter i że nie obchodzi go również końcowe spacje w kolumnach VARCHAR.

Inne sztuczki sortowania

Aby rozwiązać to wyzwanie, trzeba było znaleźć inne osobliwości w sposobie, w jaki „utf8mb4_unicode_ci” wykonuje wyszukiwanie ciągów w celu ominięcia sprawdzania w miejscu, a były na to co najmniej dwa sposoby.

Wrażliwość na akcent

Jak wspomniano w oficjalnej dokumentacji MySQL:

W przypadku niebinarnych nazw sortowania, które nie określają czułości akcentu, jest ona określana przez rozróżnianie wielkości liter.

Krótko mówiąc: wrażliwość na akcent to rzecz. Domyślne sortowanie WordPressa korzysta z komponentu „_ci” (oznaczającego „bez uwzględniania wielkości liter”), co oznacza, że ​​sortowanie jest również niewrażliwe na akcenty.

W ten sposób przekazanie „hackismet_flag_1” ominęłoby sprawdzenia w hackismet_validate_option .

Wagi ignorowane

Algorytm porównywania Unicode, który jest używany przez porównywanie utf8mb4_unicode_ci w MySQL do porównywania i sortowania ciągów znaków Unicode, opisuje koncepcję „niemożliwych do zignorowania wag” w następujący sposób:


Wagi, które można zignorować, są pomijane przez reguły, które konstruują klucze sortowania z sekwencji elementów sortowania. W związku z tym ich obecność w elementach sortowania nie wpływa na porównywanie ciągów przy użyciu wynikowych kluczy sortowania . Rozsądne przypisanie pomijalnych wag w elementach sortowania jest ważną koncepcją dla UCA.

Krótko mówiąc, algorytm oblicza wagę dla każdego elementu (znaków) sortowania, a niektóre z nich są zdefiniowane jako mające domyślną wagę zero, co skutecznie sprawia, że ​​algorytm ignoruje je podczas porównywania ciągów.

Istniało wiele sposobów (nad)używania tego zachowania w celu pokonania wyzwania, w tym:

  • Dodanie bajtów null gdzieś wewnątrz ciągu (np hackismet_fl%00ag_1 )
  • Wstawianie nieprawidłowych sekwencji UTF-8 do ciągu (np hackismet_fl%c2%80ag_1 )

Możesz znaleźć wiele innych kombinacji w implementacji UCA w MySQL.

Pomijanie ograniczenia znaków parametru „klucz_opcji”

Zmienna trasy „option_key” została zdefiniowana tak, aby nie przepuszczać niczego innego niż \w+. To był problem. PHP traktuje każdy ciąg jako serię bajtów zamiast znaków Unicode, jak robi to MySQL, więc wysyłając żądanie do „/wp-json/hackismet/get-option/hackismet_flag_1” lub „/wp-json/hackismet/get-option/hackismet_fla %00g_1” nie zadziała.

Aby to ominąć, pomogła nieco oficjalna dokumentacja WordPressa dotycząca pisania punktów końcowych interfejsu API REST, a konkretnie wiersz, w którym jest napisane:

Domyślnie trasy otrzymują wszystkie argumenty przekazane z żądania. Są one łączone w jeden zestaw parametrów , a następnie dodawane do obiektu Request, który jest przekazywany jako pierwszy parametr do punktu końcowego

W praktyce oznacza to, że po odwiedzeniu /wp-json/hackismet/get-option/test?option_key=hackismet_fla%00g_1 parametr option_key zawierałby „hackismet_fla%00g_1”, a nie „test”, co również wymusiłoby wtyczka, aby dać ci flagę.

Wyzwanie #3 – Licencja na zdobycie flagi (500 punktów)

Odpowiednie fragmenty kodu

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

Jak można to rozwiązać?

Ideą stojącą za tym wyzwaniem była symulacja (bardzo) uszkodzonego systemu zarządzania licencjami i walidacji.

Chociaż to wyzwanie miało umożliwić uczestnikom wykorzystanie dość ezoterycznej luki w warunkach wyścigu, subtelne niedopatrzenie projektanta wyzwania sprawiło, że można je rozwiązać za pomocą niezamierzonego, mniej egzotycznego rozwiązania.

Wyzwanie przedstawiało trzy punkty końcowe, chociaż tylko dwa byłyby potrzebne do uzyskania flagi:

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

Punkt końcowy generate-license wypełnił klucz licencyjny specyficzny dla sesji, który następnie zostałby zweryfikowany za pomocą wywołania zwrotnego uprawnienia hackismet_validate_license punktu końcowego access-flag-3 . Niestety, ponieważ nigdy nie udało Ci się zobaczyć, jaki był faktycznie wygenerowany klucz licencyjny, musiałeś znaleźć sposób na całkowite pominięcie sprawdzania licencji, aby uzyskać 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;
    }

Jednym ze sposobów na zrobienie tego było umieszczenie w $request['key'] wartości logicznej „prawda”, a $request['rounds'] wartości zero. Robiąc to, upewniłeś się, że $request['key'] nie zostało zmodyfikowane przez wielokrotne wywołania str_rot13 , a ponieważ walidacja licencji odbywa się za pomocą luźnego operatora porównania PHP, porównanie zawsze zwróci true.

Jednak nie można tego zrobić za pomocą zwykłych parametrów GET lub POST , ponieważ zawierają one tylko ciągi lub tablice. Na szczęście WordPress REST API umożliwia wysyłanie treści żądania JSON, nawet w punktach końcowych, które są zarejestrowane tylko do korzystania z metody GET HTTP. W rezultacie wysłanie następujących próśb dałoby flagę wyzwania:

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'

Wyzwanie #4 – Licencja na CTF: Część 2 (500 punktów)

Odpowiednie fragmenty kodu

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

Jak można to rozwiązać?

To wyzwanie przedstawiało trzy punkty końcowe (i faktycznie wymagało użycia wszystkich trzech do rozwiązania!):

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

Jak widać, są to te same punkty końcowe, co w poprzednim wyzwaniu, jedyną różnicą jest teraz to, że upewniamy się, że $request['key'] jest ciągiem, aby zapobiec problemowi żonglowania typami, o którym wspomnieliśmy w drugim wyzwaniu.

Samoobjaśniająca się trasa delete-license zrobiła dokładnie to, czego można by się spodziewać: usunęła bieżącą licencję z bazy danych. Podobnie access-flag-4 po prostu zwrócił flagę, zakładając, że jej wywołanie zwrotne uprawnień, hackismet_validate_license , pozwoliło na to.

Jak widać z fragmentu kodu hackismet_validate_license , wywołanie zwrotne uprawnień o nazwie get_option jest ustawione dwukrotnie, raz w celu sprawdzenia klucza licencyjnego jest ustawione, a drugie w celu porównania go z tym, który dostarczamy. Oba wywołania są oddzielone pętlą str_rot13, która działa przez tyle rund, ile określono w zmiennej trasy $request['rounds'] .

Umożliwiło to wystąpienie sytuacji wyścigu poprzez wysłanie dużej liczby w zmiennej rounds, aby opóźnić żądanie na tyle długo, abyśmy trafili do punktu końcowego /hackismet/delete-license , skutecznie usuwając licencję przed porównaniem z naszą własną.

Wisienką na torcie jest fakt, że get_option() domyślnie zwraca wartość logiczną false, jeśli nie znajdzie danej opcji. Ponieważ funkcja nigdy nie sprawdza, czy $request['key'] jest pusty, a false == „“ podczas luźnego porównywania różnych typów w PHP, pozwoliłoby to nam całkowicie ominąć kontrole bezpieczeństwa.

Ale to tylko w teorii!

Buforowanie na ratunek!

Jak widać z kodu źródłowego funkcji, get_option buforuje wynik dowolnej opcji, którą pobiera, więc dalsze żądanie tej opcji w tym samym żądaniu HTTP nie spowoduje wysłania dodatkowych oddzielnych zapytań SQL. Samo to uniemożliwia działanie naszego ataku warunków rasowych. Nawet jeśli inne żądanie usunie opcję licencji, podczas gdy my przechodzimy przez wszystkie wywołania str_rot13 , get_option nie będzie wiedziało, ponieważ wynik jest już buforowany dla tego żądania!

Ponownie, patrząc na kod źródłowy, wygląda na to, że jedynym sposobem, aby temu zapobiec, jest to, że wp_installing zwraca… prawda? Jak się okazuje, możemy to zrobić.

Czy WordPress jest już zainstalowany?

Funkcja wp_installing opiera się na stałej WP_INSTALLING w celu określenia, czy WordPress jest aktualnie instalowany, czy aktualizowany. Wyszukiwanie miejsc, w których ta stała jest zdefiniowana, prowadzi do bardzo niewielu wyników, w naszym przypadku najciekawszym jest 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();
}

To, co sprawia, że ​​szczególnie pasuje do naszego celu, to fakt, że jedną z pierwszych rzeczy, które robi, jest uruchomienie require() na wp-blog-header.php.

Krótko mówiąc: kod, który faktycznie uruchamia serwer REST API, jest podpięty do akcji parse_request , więc będzie dostępny tylko wtedy, gdy WordPress wewnętrznie skonfiguruje zmienne zapytania niezbędne do wykonania pracy przez The Loop.

Dzieje się tak tylko wtedy, gdy funkcja wp() jest wywoływana tak, jak w wp-blog-header.php.

Ponieważ wewnętrznie WordPress używa parametru rest_route, aby wiedzieć, którą trasę załadować, dodanie tego parametru do adresu URL to wszystko, czego potrzeba, aby uruchomić API podczas odwiedzania /wp-activate.php.

W związku z tym ostateczny atak wyglądał mniej więcej tak:

  1. Wyślij żądanie do /wp-activate.php?rest_route=/hackismet/access-flag-4/$session_id/$rounds , gdzie $rounds to dość duża liczba, aby to żądanie trwało wystarczająco długo, aby umożliwić wykonanie kroku 2.
  2. Wyślij żądanie do /wp-json/hackismet/delete-license/$session_id , gdy pierwsze żądanie jest zablokowane w pętli str_rot13 .
  3. Poczekaj na zakończenie pierwszego żądania i zdobądź flagę.

Wniosek

Mamy nadzieję, że udział w tej pierwszej edycji konkursu „Zdobądź flagę” odrzutowego sprawił ci tyle samo radości, co my. Nie możemy się doczekać, kiedy zrobimy to ponownie w przyszłości. Aby dowiedzieć się więcej o CTF, wejdź na CTF101.org

Kredyty

Projektant wyzwań: Marc Montpas

Specjalne podziękowania dla Haralda Eilertsena za osobiste rozpowszechnianie informacji na WordCamp Europe oraz zespołu Jetpack Scan za opinie, pomoc i poprawki.