Captura la bandera en WordCamp Europa 2022

Publicado: 2022-06-13

Durante WordCamp Europe 2022, realizamos una competencia Capture The Flag (CTF) de WordPress en cuatro desafíos.

Queríamos presentar a la gente el adictivo mundo de CTF y permitir que la gente experimentara cómo los investigadores de seguridad abordan la búsqueda de errores, como buscar rarezas en el código y combinarlas para hacer cosas extrañas, a veces contrarias a la intuición.

Desafío #1: ¿Tienes suerte?

Desafío n.° 2: ¿Omitir la lista de bloqueo?

Desafío n.º 3: Licencia para capturar la bandera

Desafío n.° 4: Licencia para CTF: Parte 2

Si está interesado en probarlo, aún puede obtener los archivos del desafío aquí:

hackismet-docker.zipDescargar

Desafío #1 - ¿Tienes suerte? (250 puntos)

Fragmentos de código relevantes

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

¿Cómo podría resolverse?

Este desafío presentó un punto final de API REST al que se podía acceder a través de la ruta /wp-json/hackismet/am-i-lucky . Fue diseñado para recibir una carga útil y un parámetro de solicitud hash, concatenar request['payload'] con el indicador y una cadena de 32 bytes aleatorios criptográficamente seguros, y comparar el hash resultante con request['hash'] .

Al leer la documentación de la función crypt(), uno podría encontrar que esta función no es binariamente segura (¡todavía!), lo que significa que se podría usar un byte nulo (%00) para truncar la cadena que se va a codificar justo antes de la bandera y 32 bytes aleatorios. Esto se debe a que la implementación actual de esa función en PHP es básicamente un alias de la función C subyacente del mismo nombre, y las cadenas C terminan con bytes nulos.

Para obtener su bandera, todo lo que tenía que hacer era calcular un hash con el mensaje que controla y la sal criptográfica utilizada en el código del complemento, usar el hash resultante en el parámetro "hash" y poner su mensaje en la "carga útil". parámetro, concatenado con un byte nulo (%00).

Así es como se veía una explotación exitosa:

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

Desafío n.° 2: ¿Omitir la lista de bloqueo? (250 puntos)

Fragmentos de código relevantes

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

¿Cómo podría resolverse?

Este desafío presentó un punto final de API REST al que se podía acceder a través /wp-json/hackismet/get-option/option_key_you_want .

El objetivo era bastante simple: tratar de filtrar la opción "hackismet_flag_1".

Desafortunadamente, la devolución de llamada de permiso para ese punto final también hizo algunas cosas para evitar que simplemente tomara cualquier opción en el sitio:

  • Validó que la clave de opción comenzara con “hackismet_”.
  • También se aseguró de que cualquier opción que intentara recuperar no fuera hackismet_flag_1, donde se encontraba la bandera.
  • Para hacer que las cosas parezcan más difíciles, la ruta de la API limitó qué caracteres podían aparecer en el parámetro de ruta option_key, y solo permitía cadenas que coincidieran con la expresión regular \w+ .

La función de devolución de llamada "hackismet_validate_option" también usó las funciones "strtolower" y "trim" en un intento de normalizar el parámetro "option_key". Esto fue para frustrar los intentos de usar comportamientos bien documentados de la intercalación "utf8mb4_unicode_ci" de MySQL, como el hecho de que las comparaciones de cadenas no distinguen entre mayúsculas y minúsculas, y que tampoco se preocupa por los espacios finales en las columnas VARCHAR.

Otros trucos de colación

Para resolver este desafío, había que encontrar otras peculiaridades en la forma en que “utf8mb4_unicode_ci” realiza búsquedas de cadenas para eludir las comprobaciones en el lugar, y había al menos dos formas de hacerlo.

Sensibilidad al acento

Como se menciona en la documentación oficial de MySQL:

Para los nombres de intercalación no binarios que no especifican la sensibilidad a los acentos, se determina por la distinción entre mayúsculas y minúsculas.

En pocas palabras: la sensibilidad al acento es una cosa. La intercalación predeterminada de WordPress usa el componente "_ci" (para "Insensible a mayúsculas y minúsculas"), lo que significa que la intercalación también es insensible a los acentos.

Por lo tanto, pasar "hackismet_flag_1" omitiría las comprobaciones en hackismet_validate_option .

Pesos ignorables

El algoritmo de intercalación Unicode, que utiliza la intercalación utf8mb4_unicode_ci de MySQL para comparar y clasificar cadenas Unicode, describe el concepto de "pesos ignorables" de la siguiente manera:


Las reglas que construyen claves de ordenación a partir de secuencias de elementos de intercalación pasan por alto los pesos ignorables. Por lo tanto, su presencia en los elementos de intercalación no afecta la comparación de cadenas que utilizan las claves de ordenación resultantes . La asignación juiciosa de pesos ignorables en los elementos de colación es un concepto importante para la UCA.

En pocas palabras, el algoritmo calcula un peso para cada elemento de colación (caracteres), y algunos de ellos se definen con un peso predeterminado de cero, lo que hace que el algoritmo los ignore al realizar comparaciones de cadenas.

Había múltiples formas de (ab)utilizar ese comportamiento para superar el desafío, entre ellas:

  • Agregar bytes nulos en algún lugar dentro de la cadena (por ejemplo hackismet_fl%00ag_1 )
  • Insertar secuencias UTF-8 no válidas dentro de la cadena (por ejemplo hackismet_fl%c2%80ag_1 )

Puede encontrar muchas otras combinaciones en la implementación de MySQL del UCA.

Omisión de la restricción de caracteres del parámetro "option_key"

La variable de ruta "option_key" se definió para no dejar pasar nada más que \w+. Eso fue un problema. PHP trata cada cadena como una serie de bytes en lugar de caracteres Unicode como lo hace MySQL, por lo que envía una solicitud a "/wp-json/hackismet/get-option/hackismet_flag_1" o "/wp-json/hackismet/get-option/hackismet_fla %00g_1” no funcionaría.

Para eludir eso, la documentación oficial de WordPress sobre la escritura de puntos finales de API REST ayudó un poco, específicamente la línea donde dice:

De forma predeterminada, las rutas reciben todos los argumentos que se pasan desde la solicitud. Estos se fusionan en un solo conjunto de parámetros , luego se agregan al objeto Solicitud, que se pasa como el primer parámetro a su punto final

Lo que eso significa en la práctica es que al visitar /wp-json/hackismet/get-option/test?option_key=hackismet_fla%00g_1 , el parámetro option_key contendría "hackismet_fla%00g_1", y no "test", lo que también forzaría la complemento para darle la bandera.

Desafío #3 – Licencia para capturar la bandera (500 puntos)

Fragmentos de código relevantes

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

¿Cómo podría resolverse?

La idea detrás de este desafío era simular un sistema de gestión y validación de licencias (muy) roto.

Si bien este desafío estaba destinado a permitir que los participantes explotaran una vulnerabilidad de condición de carrera bastante esotérica, un descuido sutil del diseñador del desafío hizo que se pudiera resolver utilizando una solución no intencionada y menos exótica.

El desafío presentaba tres puntos finales, aunque solo serían necesarios dos para obtener la bandera:

  • /hackismet/generar-licencia/(?P<id_sesión>[0-9a-f\-]+)/(?<rondas>\d+)
  • /hackismet/access-flag-3/(?P<sesión_id>[0-9a-f\-]+)/(?<rondas>\d+)
  • /hackismet/delete-license/(?P<session_id>[0-9a-f\-]+)

El punto final generate-license completó una clave de licencia específica de la sesión, que luego se validaría mediante la devolución de llamada de permiso hackismet_validate_license del punto final access-flag-3 . Desafortunadamente, como nunca pudo ver cuál era la clave de licencia real generada, tuvo que encontrar una manera de omitir la verificación de licencia por completo para obtener la bandera.

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

Una forma de hacerlo era hacer que $request['key'] contuviera un valor booleano de "true" y $request['rounds'] un valor de cero. Al hacer esto, se aseguró de que $request['key'] no fuera modificado por varias llamadas a str_rot13 y, dado que la validación de la licencia se realiza mediante el operador de comparación flexible de PHP, la comparación siempre devolvería verdadero.

Sin embargo, no podría hacer eso con los parámetros regulares GET o POST , ya que estos solo contienen cadenas o matrices. Afortunadamente, la API REST de WordPress le permite enviar un cuerpo de solicitud JSON, incluso en puntos finales que solo están registrados para usar el método GET HTTP. Como resultado, enviar las siguientes solicitudes le daría la bandera del desafío:

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'

Desafío #4 – Licencia para CTF: Parte 2 (500 puntos)

Fragmentos de código relevantes

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

¿Cómo podría resolverse?

Este desafío presentaba tres puntos finales (¡y en realidad requería usar los tres para resolverlo!):

  • /hackismet/generar-licencia/(?P<id_sesión>[0-9a-f\-]+)/(?P<rondas>\d+)
  • /hackismet/delete-license/(?P<session_id>[0-9a-f\-]+)
  • /hackismet/access-flag-4/(?P<id_sesión>[0-9a-f\-]+)/(?P<rondas>\d+)

Como puede ver, esos son los mismos puntos finales que el último desafío, la única diferencia ahora es que nos aseguramos de que $request['key'] sea una cadena para evitar el problema de malabarismo de tipos que mencionamos en el otro desafío.

La ruta autoexplicativa de delete-license hizo exactamente lo que esperaba: eliminar la licencia actual de la base de datos. De manera similar, access-flag-4 simplemente devolvió la bandera, suponiendo que su devolución de llamada de permiso, hackismet_validate_license , permitiera que sucediera.

Como puede ver en el fragmento de código hackismet_validate_license , la devolución de llamada de permiso llamada get_option se establece dos veces, una para validar una clave de licencia y otra para compararla con la que estamos proporcionando. Ambas llamadas están separadas por un bucle str_rot13 que se ejecuta durante tantas rondas como se define en la variable de ruta $request['rounds'] .

Esto hizo posible que ocurriera una condición de carrera al enviar un gran número en la variable de rondas para retrasar la solicitud lo suficiente como para que pudiéramos alcanzar el punto /hackismet/delete-license , eliminando efectivamente la licencia antes de compararla con la nuestra.

El hecho de que get_option() por defecto devuelva un valor booleano falso si no encuentra una opción determinada es la guinda del pastel. Dado que la función nunca verifica si $request['key'] está vacío, y false == ““ al comparar diferentes tipos en PHP, esto nos permitiría omitir por completo los controles de seguridad.

¡Pero esto es solo en teoría!

Almacenamiento en caché al rescate!

Como se puede ver en el código fuente de la función, get_option almacena en caché el resultado de cualquier opción que esté recuperando, por lo que cualquier solicitud adicional de esa opción en la misma solicitud HTTP no enviará consultas SQL adicionales por separado. Esto por sí solo evita que funcione nuestro ataque de condición de carrera. Incluso si otra solicitud eliminara la opción de licencia mientras repasamos todas esas llamadas str_rot13 , ¡get_option no lo sabría debido a que el resultado ya está almacenado en caché para esa solicitud!

Nuevamente, mirando el código fuente, parece que la única forma de evitar que eso suceda es si wp_installing regresa... ¿verdad? Resulta que podemos hacer que haga eso.

¿Ya está instalado WordPress?

La función wp_installing se basa en la constante WP_INSTALLING para determinar si WordPress se está instalando o actualizando. Buscar lugares donde se define esta constante arroja muy pocos resultados, siendo el más interesante en nuestro 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();
}

Lo que lo hace particularmente apropiado para nuestro propósito aquí es que una de las primeras cosas que hace es ejecutar require() en wp-blog-header.php.

Para resumir: el código que realmente inicia el servidor REST API está vinculado a la acción parse_request , por lo que solo estará disponible cuando WordPress configure internamente las variables de consulta necesarias para que The Loop haga su trabajo.

Esto solo ocurre si se llama a la función wp() como si estuviera en wp-blog-header.php.

Dado que, internamente, WordPress usa el parámetro rest_route para saber qué ruta cargar, agregar ese parámetro a la URL es todo lo que se necesita para iniciar la API mientras visita /wp-activate.php.

Como tal, el ataque final se veía así:

  1. Envíe una solicitud a /wp-activate.php?rest_route=/hackismet/access-flag-4/$session_id/$rounds donde $rounds es un número bastante grande para que esta solicitud se ejecute lo suficiente como para permitirle realizar el paso n.º 2.
  2. Envíe una solicitud a /wp-json/hackismet/delete-license/$session_id mientras su primera solicitud está bloqueada en el ciclo str_rot13 .
  3. Espere a que termine su primera solicitud y obtenga su bandera.

Conclusión

Esperamos que se haya divertido tanto participando en esta primera edición de la competencia Jetpack Capture The Flag como nosotros llevándola a cabo. Esperamos volver a hacer esto en algún momento en el futuro. Para obtener más información sobre CTF, visite CTF101.org

Créditos

Diseñador del desafío: Marc Montpas

Un agradecimiento especial a Harald Eilertsen por correr la voz en persona en WordCamp Europe y al equipo de Jetpack Scan por sus comentarios, ayuda y correcciones.