Capture a bandeira no WordCamp Europe 2022

Publicados: 2022-06-13

Durante o WordCamp Europe 2022, realizamos uma competição WordPress Capture The Flag (CTF) em quatro desafios.

Queríamos apresentar as pessoas ao mundo viciante do CTF e permitir que as pessoas experimentassem como os pesquisadores de segurança abordam a caça a bugs, como procurar esquisitices no código e combiná-las para fazer coisas estranhas, às vezes contra-intuitivas.

Desafio #1 – Você tem sorte?

Desafio nº 2 – Bypass da lista de bloqueio?

Desafio #3 – Licença para Capturar a Bandeira

Desafio nº 4 – Licença para CTF: Parte 2

Se você estiver interessado em experimentá-lo, você ainda pode obter os arquivos de desafio aqui:

hackismet-docker.zipBaixar

Desafio #1 – Você tem sorte? (250 pontos)

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

Como poderia ser resolvido?

Esse desafio apresentou um endpoint de API REST que pode ser acessado por meio da rota /wp-json/hackismet/am-i-lucky . Ele foi projetado para receber uma carga útil e um parâmetro de solicitação de hash, concatenar request['payload'] ao sinalizador e uma string de 32 bytes aleatórios criptograficamente seguros e comparar o hash resultante com request['hash'] .

Ao ler a documentação da função crypt(), pode-se descobrir que esta função não é segura para binários (ainda!), significando que um byte nulo (%00) pode ser usado para truncar a string a ser hash logo antes do sinalizador e 32 bytes aleatórios. Isso ocorre porque a implementação atual dessa função no PHP é basicamente apenas um alias da função C subjacente de mesmo nome, e as strings C terminam com bytes nulos.

Para obter seu sinalizador, tudo o que você precisava fazer era calcular um hash com a mensagem que você controla e o sal criptográfico usado no código do plugin, usar o hash resultante no parâmetro “hash” e colocar sua mensagem no “payload” parâmetro, concatenado com um byte nulo (%00).

Veja como era uma exploração bem-sucedida:

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

Desafio nº 2 – Bypass da lista de bloqueio? (250 pontos)

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

Como poderia ser resolvido?

Esse desafio apresentou um endpoint da API REST que pode ser acessado via /wp-json/hackismet/get-option/option_key_you_want .

O objetivo era bem simples: tentar vazar a opção “hackismet_flag_1”.

Infelizmente, o retorno de chamada de permissão para esse endpoint também fez algumas coisas para impedir que você simplesmente pegasse qualquer opção no site:

  • Ele validou que a chave de opção começava com “hackismet_”.
  • Também garantiu que qualquer opção que você pretendia recuperar não fosse hackismet_flag_1, onde a bandeira estava localizada.
  • Para tornar as coisas mais difíceis, a rota da API limitava quais caracteres poderiam ser incluídos no parâmetro de rota option_key, permitindo apenas strings correspondentes ao \w+ regex.

A função de retorno de chamada “hackismet_validate_option” também usou as funções “strtolower” e “trim” na tentativa de normalizar o parâmetro “option_key”. Isso foi para frustrar tentativas de usar comportamentos bem documentados do agrupamento “utf8mb4_unicode_ci” do MySQL, como o fato de que comparações de strings não diferenciam maiúsculas de minúsculas e que também não se importa com espaços à direita em colunas VARCHAR.

Outros truques de agrupamento

Para resolver esse desafio, era preciso encontrar outras peculiaridades na maneira como “utf8mb4_unicode_ci” faz buscas de strings para contornar as verificações em vigor, e havia pelo menos duas maneiras de fazê-lo.

Sensibilidade do acento

Conforme mencionado na documentação oficial do MySQL:

Para nomes de agrupamento não binários que não especificam a distinção entre acentos, isso é determinado pela distinção entre maiúsculas e minúsculas.

Resumindo: a sensibilidade ao sotaque é uma coisa. A ordenação padrão do WordPress usa o componente “_ci” (para “Insensível a maiúsculas e minúsculas”), o que significa que a ordenação também não diferencia acentos.

Assim, passar “hackismet_flag_1” ignoraria as verificações em hackismet_validate_option .

Pesos Ignoráveis

O Algoritmo Unicode Collation, que é usado pelo agrupamento utf8mb4_unicode_ci do MySQL para comparar e classificar strings Unicode, descreve o conceito de “pesos ignoráveis” da seguinte forma:


Pesos ignoráveis ​​são passados ​​pelas regras que constroem chaves de classificação a partir de sequências de elementos de agrupamento. Assim, sua presença em elementos de ordenação não afeta a comparação de strings usando as chaves de classificação resultantes . A atribuição criteriosa de pesos ignoráveis ​​em elementos de agrupamento é um conceito importante para o UCA.

Resumindo, o algoritmo calcula um peso para cada elemento de agrupamento (caracteres), e alguns deles são definidos como tendo um peso padrão de zero, o que efetivamente faz com que o algoritmo os ignore ao fazer comparações de strings.

Havia várias maneiras de (ab)usar esse comportamento para vencer o desafio, incluindo:

  • Adicionando bytes nulos em algum lugar dentro da string (por exemplo hackismet_fl%00ag_1 )
  • Inserindo sequências UTF-8 inválidas dentro da string (por exemplo hackismet_fl%c2%80ag_1 )

Você pode encontrar muitas outras combinações na implementação do UCA no MySQL.

Ignorando a restrição de caracteres do parâmetro “option_key”

A variável de rota “option_key” foi definida para não deixar passar nada além de \w+. Isso era um problema. PHP trata cada string como uma série de bytes em vez de caracteres unicode como o MySQL faz, então enviar uma solicitação para “/wp-json/hackismet/get-option/hackismet_flag_1” ou “/wp-json/hackismet/get-option/hackismet_fla %00g_1” não funcionaria.

Para contornar isso, a documentação oficial do WordPress sobre como escrever endpoints da API REST ajudou um pouco, especificamente a linha onde diz:

Por padrão, as rotas recebem todos os argumentos passados ​​da solicitação. Eles são mesclados em um único conjunto de parâmetros e, em seguida, adicionados ao objeto Request, que é passado como o primeiro parâmetro para seu endpoint

O que isso significa na prática é que ao visitar /wp-json/hackismet/get-option/test?option_key=hackismet_fla%00g_1 , o parâmetro option_key conteria “hackismet_fla%00g_1”, e não “test”, o que também forçaria o plugin para lhe dar a bandeira.

Desafio #3 – Licença para Capturar a Bandeira (500 pontos)

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

Como poderia ser resolvido?

A ideia por trás desse desafio era simular um sistema de validação e gerenciamento de licenças (muito) quebrado.

Embora esse desafio fosse para permitir que os participantes explorassem uma vulnerabilidade de condição de raça bastante esotérica, uma supervisão sutil do designer do desafio o tornou solucionável usando uma solução não intencional e menos exótica.

O desafio apresentou três endpoints, embora apenas dois fossem necessários para obter o sinalizador:

  • /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\-]+)

O endpoint generate-license preencheu uma chave de licença específica da sessão, que seria validada usando o retorno de chamada de permissão hackismet_validate_license do endpoint access-flag-3 . Infelizmente, como você nunca conseguiu ver qual era a chave de licença gerada real, você teve que encontrar uma maneira de ignorar completamente a verificação de licença para obter o sinalizador.

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

Uma maneira de fazer isso era ter $request['key'] contendo um valor booleano de “true”, e $request['rounds'] um valor de zero. Ao fazer isso, você garantiu que $request['key'] não foi modificado por várias chamadas para str_rot13 , e como a validação da licença é feita usando o operador de comparação flexível do PHP, a comparação sempre retornaria true.

No entanto, você não pode fazer isso com parâmetros GET ou POST regulares, pois eles contêm apenas strings ou arrays. Felizmente, a API REST do WordPress permite que você envie um corpo de solicitação JSON, mesmo em endpoints registrados apenas para usar o método GET HTTP. Como resultado, enviar as solicitações a seguir forneceria a sinalização do desafio:

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'

Desafio nº 4 – Licença para CTF: Parte 2 (500 pontos)

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

Como poderia ser resolvido?

Este desafio apresentou três endpoints (e, na verdade, exigiu o uso de todos os três para ser resolvido!):

  • /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 você pode ver, esses são os mesmos endpoints do último desafio, a única diferença agora é que garantimos que $request['key'] seja uma string para evitar o problema de malabarismo de tipos que mencionamos no outro desafio.

A rota delete-license autoexplicativa fez exatamente o que você esperava: removeu a licença atual do banco de dados. Da mesma forma, access-flag-4 simplesmente retornou o sinalizador, assumindo que seu retorno de chamada de permissão, hackismet_validate_license , permitiu que isso acontecesse.

Como você pode ver no snippet de código hackismet_validate_license , o retorno de chamada de permissão chamado get_option duas vezes, uma para validar uma chave de licença é definida e outra para realmente compará-la com a que estamos fornecendo. Ambas as chamadas são separadas por um loop str_rot13 que é executado por quantas rodadas forem definidas na variável de rota $request['rounds'] .

Isso possibilitou que uma condição de corrida ocorresse enviando um grande número na variável rounds para atrasar a solicitação por tempo suficiente para atingirmos o ponto de extremidade /hackismet/delete-license , excluindo efetivamente a licença antes de ser comparada com a nossa.

O fato de o padrão get_option() retornar um booleano false se não encontrar uma determinada opção é a cereja do bolo. Uma vez que a função nunca verifica se $request['key'] está vazio, e false == ““ ao comparar livremente diferentes tipos no PHP, isso nos permitiria ignorar completamente as verificações de segurança.

Mas isso é apenas na teoria!

Cache para o resgate!

Como pode ser visto no código-fonte da função, get_option armazena em cache o resultado de qualquer opção que esteja recuperando, portanto, qualquer solicitação adicional para essa opção na mesma solicitação HTTP não enviará consultas SQL separadas adicionais. Isso por si só impede que nosso ataque de condição de corrida funcione. Mesmo se outra solicitação excluísse a opção de licença enquanto estamos percorrendo todas as chamadas str_rot13 , get_option não saberia devido ao resultado já estar armazenado em cache para essa solicitação!

Novamente, olhando para o código-fonte, parece que a única maneira de evitar que isso aconteça é se wp_installing retornar… true? Como se vê, podemos fazê-lo fazer isso.

O WordPress já está instalado?

A função wp_installing depende da constante WP_INSTALLING para determinar se o WordPress está sendo instalado ou se atualizando. A busca por lugares onde esta constante está definida leva a pouquíssimos resultados, sendo o mais interessante no nosso 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();
}

O que o torna particularmente adequado para o nosso propósito aqui, é que uma das primeiras coisas que ele faz é executar require() em wp-blog-header.php.

Para encurtar a história: o código que realmente inicia o servidor da API REST é vinculado à ação parse_request , portanto, só estará disponível quando o WordPress configurar internamente as variáveis ​​de consulta necessárias para que o The Loop faça seu trabalho.

Isso só ocorre se a função wp() for chamada como em wp-blog-header.php.

Como, internamente, o WordPress usa o parâmetro rest_route para saber qual rota carregar, basta adicionar esse parâmetro à URL para iniciar a API enquanto visita /wp-activate.php.

Assim, o ataque final ficou assim:

  1. Envie uma solicitação para /wp-activate.php?rest_route=/hackismet/access-flag-4/$session_id/$rounds onde $rounds é um número bem grande para fazer essa solicitação durar o suficiente para permitir que você execute a etapa #2.
  2. Envie uma solicitação para /wp-json/hackismet/delete-license/$session_id enquanto sua primeira solicitação está bloqueada no loop str_rot13 .
  3. Aguarde seu primeiro pedido terminar e pegue sua bandeira.

Conclusão

Esperamos que você tenha se divertido tanto participando desta primeira edição da competição Jetpack Capture The Flag quanto nos divertimos com ela. Estamos ansiosos para fazer isso novamente em algum momento no futuro. Para saber mais sobre CTF, confira CTF101.org

Créditos

Designer do desafio: Marc Montpas

Agradecimentos especiais a Harald Eilertsen por divulgar pessoalmente no WordCamp Europe e à equipe Jetpack Scan por feedback, ajuda e correções.