Skip to main content

Usando o event loop do Node.js para ataques de temporização

Escrito por

16 de fevereiro de 2016

0 minutos de leitura

Há pouco mais de 3 anos, eu, alguns amigos e eu criamos um grupo chamado pasten para participar da competição do Chaos Computer Club Capture The Flag (CTF). É um CTF no estilo Jeopardy, em que as equipes participantes precisam resolver desafios relacionados à segurança em várias categorias, como exploração, engenharia reversa, web, perícia forense e criptografia. CTFs são divertidos e educativos, e recomendo muito participar. Fica ainda melhor quando você vence, como nós vencemos este ano!

A competição deste ano incluiu, entre outros desafios, um aplicativo Node.js vulnerável a um ataque de temporização interessante, que explorava o event loop específico do Node.js. Neste post, vamos analisar o desafio, explicar o risco e mostrar como um invasor pode descobrir e explorar uma vulnerabilidade desse tipo. Espero que isso também ajude você a evitar vulnerabilidades semelhantes no seu próprio código.

Ataques de temporização

Antes de mergulharmos no desafio, vamos explicar o que é um ataque de temporização.

Imagine um serviço que, quando você informa a senha errada para um e-mail existente, responde: “A quinta letra da sua senha está errada. Tente novamente.” Parece absurdo, não é? Dar esse tipo de informação permite que um invasor descubra a senha por força bruta, um caractere de cada vez, tornando a invasão trivial. No entanto, é exatamente isso que acontece quando usamos uma comparação ingênua de strings para verificar senhas ou tokens de autenticação.

A comparação nativa de strings, incluindo o operador == do JavaScript, normalmente percorre duas strings (de mesmo tamanho), comparando um caractere por vez e parando quando encontra uma diferença. Portanto, ao comparar foo com bar, o loop seria executado uma vez; já ao comparar foo com fox, compararia 3 caracteres, levando mais tempo.

Exemplo de função de autenticação vulnerável:

function isAuthenticated(user, token) {
  var correctToken = FetchUserTokenFromDB(user);
  return token === correctToken;
}

A comparação de um único caractere é muito rápida, mas ainda demora o suficiente. Pesquisas mostram que um invasor consegue medir eventos com precisão de 15 a 100 µs pela internet e de 100 ns em uma rede local. Com essas técnicas, os invasores podem aproveitar esses pequenos atrasos, que acabam equivalendo a informar ao usuário qual caractere está errado.

Esse tipo de ataque é chamado de ataque de temporização e pode ser realizado sempre que a entrada afeta o tempo de processamento. Para evitá-lo, precisamos fazer com que a comparação das strings leve o mesmo tempo, independentemente da senha informada. Uma opção é aplicar xoring às duas senhas e verificar se o resultado é zero.

Imune a ataques de temporização:

var mismatch = 0;
for (var i = 0; i < a.length; ++i) {
  mismatch |= (a.charCodeAt(i) ^ b.charCodeAt(i));
}
return mismatch;

O desafio: Sequence Hunt

Com essas informações em mãos, vamos analisar o desafio. Tudo começou com um link para uma página com o seguinte conteúdo:

Sequence HuntBem-vindo à minha caça às sequências de inteiros. Você precisa calcular cinco números entre 0 e 100 implementando os algoritmos abaixo. Quando achar que tem a solução correta, envie-os como parâmetros GET aqui.Você também pode obter algumas informações sobre você aqui.Espero muita carga, então criei tudo em torno de IOLoops assíncronos.Divirta-se!

[EDIT]Alguém me enviou isto. Como cada verificação leva bastante tempo, agora o servidor espera até que tenham se passado três segundos desde a chegada da solicitação, para evitar esses ataques.[/EDIT]

AlgoritmosTODO publicar algoritmos

O texto aqui aponta para /check, onde precisamos enviar os parâmetros corretos para obter a flag, e IOLoops leva a /info, que mostra o seguinte:

você está em 37.120.106.140contagem de solicitações no seu IOLoop: 1

O que sabemos até agora?

  1. Há um algoritmo desconhecido que recebe 5 números entre 0 e 100

  2. Para obter a flag, precisamos informar os números corretos.

  3. O tempo de execução do algoritmo provavelmente depende da entrada, o que pode permitir um ataque de temporização.

  4. O servidor sempre espera pelo menos 3 segundos antes de enviar a resposta, para dificultar ataques de temporização.

Agora precisamos descobrir como capturar a flag. Como o desafio se chamava Sequence Hunt, vou usar sequencehunt.com como domínio fictício nos exemplos, mas não faça ataques a esse domínio (que não tem relação com o desafio)! Você pode executar o aplicativo de exemplo vulnerável que criei. Ele deve se comportar de forma parecida com o do CTF.

Explorando

Uma verificação rápida confirma que uma solicitação check realmente leva cerca de 3 segundos para retornar, independentemente dos valores informados. Isso significa que não é possível testar todas as combinações por força bruta. Seria necessário testar 100^5 (10 bilhões) de combinações, o que levaria milhares de anos…

$ curl "https://sequencehunt.com/check?val0=1&val1=1&val2=1&val3=1&val4=1"
you are 37.120.106.140
At least one value is wrong!

$ time curl "https://sequencehunt.com/check?val0=1&val1=1&val2=1&val3=1&val4=1"
you are 37.120.106.140
At least one value is wrong!
0.00s user
0.01s system
0% cpu
3.174 total

Ao explorar a página /info, notamos que cada solicitação /check aumenta a contagem do IOLoop — e a própria solicitação /info também.

$ curl "https://sequencehunt.com/info"
you are 37.120.106.140<br>request count on your IOLoop: 1

$ curl "https://sequencehunt.com/info"
you are 37.120.106.140<br>request count on your IOLoop: 2

$ curl "https://sequencehunt.com/info"
you are 37.120.106.140<br>request count on your IOLoop: 3

$ curl "https://sequencehunt.com/check?val0=1&val1=1&val2=1&val3=1&val4=1"
you are 37.120.106.140<br>At least one value is wrong!

$ curl "https://sequencehunt.com/info"
you are 37.120.106.140<br>request count on your IOLoop: 5

Enviar solicitações /check com vários números aleatórios não produz diferenças de tempo estatisticamente significativas. Pelo visto, o algoritmo leva menos de 3 segundos para ser executado, então todas as solicitações simplesmente retornam após o mínimo especificado de 3 segundos.

No entanto, percebemos que uma solicitação para /info demora muito mais para responder quando há uma solicitação /check em andamento — um indício relacionado ao tempo que podemos investigar. Para entender o próximo passo, vamos falar um pouco sobre o event loop do Node.js.

Event loop do Node.js

Projetado para oferecer escalabilidade, o Node.js (e o JavaScript em geral) é um framework assíncrono e orientado a eventos. Quando um trecho de código precisa executar uma ação potencialmente bloqueante, como abrir um arquivo ou gravar na rede, ele registra uma função de callback, inicia a ação correspondente e termina. Quando a ação é concluída (por exemplo, quando o arquivo é aberto), o event loop chama o evento de callback.

O modelo orientado a eventos é extremamente escalável, pois as threads nunca ficam paradas “esperando”. Porém, ele traz um problema quando uma função demora para ser concluída. Como o servidor Node.js executa apenas uma thread por núcleo (por padrão), uma função demorada ocupa o núcleo inteiro enquanto as demais aguardam na fila. No navegador, é por isso que você pode encontrar a mensagem de erro “o script está demorando muito para ser executado”. No servidor, as solicitações simplesmente ficam travadas.

Para entender melhor o event loop do Node.js, veja a ótima palestra que Philip Roberts apresentou na JSConf ou leia um destes artigos.

Resolvendo o enigma

No nosso caso, o event loop nos forneceu as informações de tempo de que precisávamos. Enquanto /check estava em execução, o event loop não retomava o controle, fazendo a solicitação /info ficar travada. Em contrapartida, a função setTimeout simplesmente registra um evento agendado e cede o controle, permitindo que as solicitações /info sejam processadas rapidamente. Publiquei no GitHub o código de um servidor simples que demonstra esse efeito, caso você queira testá-lo localmente.

Agora que tínhamos uma forma de obter informações de tempo (também conhecida como canal lateral), só precisávamos enviar uma solicitação /check e, sem esperar pela resposta, enviar uma solicitação /info, medindo quanto tempo ela levava para retornar.

Diagrama de sequência que mostra um invasor cronometrando solicitações de verificação e respostas enquanto o loop de eventos do servidor está bloqueado e setTimeout atrasa a resposta.

Para automatizar a busca e compensar as variações da rede, o próximo passo foi escrever um script em Python que, dado um conjunto de entradas:

  • Abre n threads

  • Envia uma solicitação check e mede quanto tempo uma solicitação /info paralela leva para retornar

  • Calcula a média dos tempos e retorna o resultado

Usei n=5, e foi o suficiente. Esse número pode ser maior, dependendo da qualidade da conexão com a internet e das características de tempo do algoritmo. Estes são os resultados da primeira execução — observe a diferença significativa quando o valor é 4

[0, 0, 0, 0, 0]: 0.4933500289916992
[1, 1, 1, 1, 1]: 0.3603970050811768
[2, 2, 2, 2, 2]: 0.4297104358673095
[3, 3, 3, 3, 3]: 0.4705570697784423
[4, 4, 4, 4, 4]: 0.9154952526092529 <<<<<<<<
[5, 5, 5, 5, 5]: 0.4637355804443359
[6, 6, 6, 6, 6]: 0.3557830333709716
[7, 7, 7, 7, 7]: 0.4418128013610840
[8, 8, 8, 8, 8]: 0.4297045707702637

Como a diferença de tempo só aparecia quando 4 estava na primeira posição, o próximo passo foi percorrer as demais posições dos parâmetros. O segundo dígito encontrado foi 12

[4, 10, 10, 10, 10]: 0.840841388702392
[4, 11, 11, 11, 11]: 0.859339499473571
[4, 12, 12, 12, 12]: 1.228700304031372 <<<<<<<<
[4, 13, 13, 13, 13]: 0.907831811904907

E assim por diante, até encontrarmos todos os parâmetros corretos: [4, 12, 77, 98, 35]

$ curl 'https://sequencehunt.com/check?val0=4&val1=12&val2=77&val3=98&val4=35'
you are 37.120.106.140
Congratulations, here is your prize:
32C3_round_and_round_and_round_IT_goes

Flag capturada.

Resumo

Ataques de temporização são uma ameaça real e afetam tanto empresas pequenas quanto grandes (como o Google). Mesmo pequenas diferenças no tempo de execução podem ser detectadas com uma amostra relativamente pequena (em escala de internet) e usadas por invasores para obter informações e aprimorar ataques de força bruta. No Node.js, especificamente, o event loop pode servir como outro sinal em ataques de temporização.

Algumas dicas para proteger seu aplicativo contra esse tipo de falha:

Por fim, se quiser mostrar suas habilidades e encarar esses desafios, tente participar do Capture The Flag do CCC no próximo ano!

Comece a resolver desafios de capture the flag

Aprenda a resolver desafios de capture the flag assistindo sob demanda ao nosso workshop virtual introdutório.