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.
| |
Reconnaissance
Nmap
A full TCP scan followed by a service scan shows the classic domain controller fingerprint:
| |
| |
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:
| |
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:
| |
/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:
| |
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:
| |
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:
| |
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:
| |
The result is a long list of username + 32-hex-character (MD5) pairs.
Cracking hashes
The dumped MD5s crack readily against rockyou:
| |
| |
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:
| |
| |
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:
| |
| |
admin/index.php is a local file include on that parameter (blocking only
index.php itself):
| |
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:
| |
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:
| |
| |
Since the payload is eval()’d (not include’d), it needs no <?php tags.
shell.php pulls nc64.exe and returns a shell:
| |
The web server logs both fetches, and a listener catches the callback:
| |
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:
| |
| |
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:
| |
| |
These are different hashes from the main site. Cracking them:
| |
WinRM as nikk37
Spraying the new set against the domain, nikk37 validates - and unlike yoshihide,
nikk37 is a member of Remote Management Users:
| |
| |
| |
The user flag is on nikk37’s desktop:
| |
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:
| |
Both files are pulled down via evil-winrm’s download:
| |
firepwd decrypts the stored logins:
| |
| |
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):
| |
| |
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:
| |
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 -hfails - the current build wants-H ldap://..., and unescaped parentheses in the filter trip the shell (zsh: parse error near ')'). Quoting the filter and using-Hfixes 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:
| |
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:
| |
| |
| |
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:
| |
| |
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:
| |
The root flag lives on the other administrators-group user, Martin:
| |
An unintended alternative also exists:
login.phponstreamio.htbis vulnerable to a stacked, time-based blind SQL injection (WAITFOR DELAY), whichsqlmapcan dump - but the credential-and-ACL path above is the intended route.
Detection and Mitigation
| Stage | Root cause | Mitigation |
|---|---|---|
| SQL injection | Unparameterised query in search.php reaching a LIKE clause | Prepared statements / parameterised queries |
LFI via debug | User-controlled include $_GET['debug'] | Never pass user input to include; use an allow-list of known pages |
| RFI to RCE | eval(file_get_contents($_POST['include'])) | Never fetch and evaluate remote content |
| Secrets in the web root | Hard-coded DB credentials in index.php/register.php | Externalize credentials (vault/env vars), never in source under the web root |
| Credential reuse | Website hashes, backup DB, Firefox logins, and Slack password all fed the same domain accounts | Enforce unique credentials per system/service |
| LAPS exposure | WriteOwner/Owns on a group with ReadLAPSPassword | Audit who can read LAPS attributes and who controls groups granted that read |
Detection signals:
- Repeated UNION-shaped requests against
search.php/login.phpwith 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 aReadLAPSPassword-scoped LDAP search.
Lessons Learned
- PHP on MSSQL. The Windows + PHP combination made MSSQL the likely backend;
@@versionconfirmed it and enabled the UNION dump. Swallowed SQL errors did not make the query safe - user input still reached theLIKEclause. eval(file_get_contents($_POST[...]))is direct RCE. ThedebugLFI merely made the “include-only”master.phpreachable; the RFI-to-evalprimitive inside it did the real work.- Secrets in the web root. Hard-coded DB credentials in
index.php/register.phpwere 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/Ownsedge on a group that couldReadLAPSPasswordcollapsed the whole escalation: take ownership, write aGenericAllACE, add member, readms-Mcs-AdmPwd.
Command Reference
| |