There is no box to attack here: you download an archive and work offline. Every command runs on your own machine. You need impacket, pypykatz, sleuthkit, and python3 with cryptography and Pillow. The KAPE/C/ path below is the root of the extracted archive.
[01] What the collection actually is
It is a KAPE triage: registry hives from C/Windows/System32/config and a user profile from C/Users/vera. No EVTX, no $MFT, no prefetch. When the logs are missing, two surfaces are left: the registry and the browser profile.
$ find KAPE/C -type f | wc -l 456 $ find KAPE/C -type f -printf '%s %p\n' | sort -rn | head -3 104857600 KAPE/C/Users/vera/Documents/backup 74854892 .../Chrome For Testing/User Data/optimization_guide_model_store/... 70254592 KAPE/C/Windows/System32/config/SOFTWARE # il browser e' "Chrome For Testing": la build da automazione, non quella di un umano
$ find KAPE/C -type f | wc -l 456 $ find KAPE/C -type f -printf '%s %p\n' | sort -rn | head -3 104857600 KAPE/C/Users/vera/Documents/backup 74854892 .../Chrome For Testing/User Data/optimization_guide_model_store/... 70254592 KAPE/C/Windows/System32/config/SOFTWARE # the browser is "Chrome For Testing": the automation build, not a human's
The biggest file in the profile is called backup, has no extension, and is exactly 100 MiB long. A file whose size is a round power of two is rarely a backup.
[02] The portal, and a password you cannot read
Chrome's history says where VERA was going: an internal portal, and a /login that answered with an error once.
$ sqlite3 "Default/History" "select url,title from urls" http://bytelotus.thm:8080/ SecureVault Portal http://bytelotus.thm:8080/login Error response $ sqlite3 "Default/Login Data" "select origin_url,username_value from logins" http://bytelotus.thm:8080/|VeraSecretVault
$ sqlite3 "Default/History" "select url,title from urls" http://bytelotus.thm:8080/ SecureVault Portal http://bytelotus.thm:8080/login Error response $ sqlite3 "Default/Login Data" "select origin_url,username_value from logins" http://bytelotus.thm:8080/|VeraSecretVault
The username is in the clear, the password is not. Chrome on Windows stores it as a v10 blob: three tag bytes, twelve of nonce, then ciphertext and a GCM tag. The AES key lives in Local State, and that one is wrapped in DPAPI — which for a local account only unwraps with the user's password.
$ python3 -c 'import sqlite3;print(sqlite3.connect("Login Data").execute("select password_value from logins").fetchone()[0].hex())' 763130c88a72a64f35f63e883ea0a7f6... ^^^^^^ "v10" 12 byte di nonce, poi ciphertext + tag a 16 byte = 56 byte in tutto
$ python3 -c 'import sqlite3;print(sqlite3.connect("Login Data").execute("select password_value from logins").fetchone()[0].hex())' 763130c88a72a64f35f63e883ea0a7f6... ^^^^^^ "v10" 12 nonce bytes, then ciphertext + 16-byte tag = 56 bytes total
[03] Windows hands the password over
Unwrapping DPAPI needs vera's password. There is nothing to crack: the hives are all here, and secretsdump reads LSA secrets too.
$ impacket-secretsdump -sam SAM -system SYSTEM -security SECURITY LOCAL [*] Dumping local SAM hashes (uid:rid:lmhash:nthash) vera:1000:aad3b435b51404eeaad3b435b51404ee:1241186a4aac4f34f4bf7ace71b396a8::: [*] Dumping LSA Secrets [*] DefaultPassword (Unknown User):minivera $ python3 -c 'import hashlib;print(hashlib.new("md4","minivera".encode("utf-16le")).hexdigest())' 1241186a4aac4f34f4bf7ace71b396a8 # combacia con l'hash NT di vera
$ impacket-secretsdump -sam SAM -system SYSTEM -security SECURITY LOCAL [*] Dumping local SAM hashes (uid:rid:lmhash:nthash) vera:1000:aad3b435b51404eeaad3b435b51404ee:1241186a4aac4f34f4bf7ace71b396a8::: [*] Dumping LSA Secrets [*] DefaultPassword (Unknown User):minivera $ python3 -c 'import hashlib;print(hashlib.new("md4","minivera".encode("utf-16le")).hexdigest())' 1241186a4aac4f34f4bf7ace71b396a8 # matches vera's NT hash
DefaultPassword is the LSA secret Windows writes when you enable autologon. It is not a hash: it is the cleartext password, sitting in the SECURITY hive, because the system has to read it back at every boot. Whoever set that kiosk up traded one convenience for the user's entire chain of secrets.
[04] Unwinding DPAPI
From here it is mechanical: password and SID produce the prekeys, the prekeys open the user's masterkey, the masterkey opens Chrome's key, and Chrome's key decrypts the blob.
# il SID e' il nome della cartella sotto AppData/Roaming/Microsoft/Protect/ $ SID=S-1-5-21-2529683458-431225740-1723070931-1000 $ pypykatz dpapi prekey password "$SID" 'minivera' -o prekeys.txt $ pypykatz dpapi masterkey "Protect/$SID/c90719ef-5b98-474e-b934-136d606a702a" \ prekeys.txt -o masterkeys.json $ pypykatz dpapi chrome masterkeys.json "User Data/Local State" \ --logindata "User Data/Default/Login Data" user: VeraSecretVault pass: Wh4t1sV3raD0inG0nTh1sH0st url: http://bytelotus.thm:8080/login
# the SID is the folder name under AppData/Roaming/Microsoft/Protect/ $ SID=S-1-5-21-2529683458-431225740-1723070931-1000 $ pypykatz dpapi prekey password "$SID" 'minivera' -o prekeys.txt $ pypykatz dpapi masterkey "Protect/$SID/c90719ef-5b98-474e-b934-136d606a702a" \ prekeys.txt -o masterkeys.json $ pypykatz dpapi chrome masterkeys.json "User Data/Local State" \ --logindata "User Data/Default/Login Data" user: VeraSecretVault pass: Wh4t1sV3raD0inG0nTh1sH0st url: http://bytelotus.thm:8080/login
Read out loud, the password is the question the room is named after: what is Vera doing on this host.
[05] 100 MiB that is not a file
Back to backup. file just says "data". The entropy says a lot more.
$ python3 - <<'EOF' import math,collections f=open("backup","rb") for i in range(100): b=f.read(1024*1024); c=collections.Counter(b) print(round(-sum((v/len(b))*math.log2(v/len(b)) for v in c.values()),2), end=" ") EOF 8.0 8.0 8.0 8.0 8.0 ... 8.0 # tutti e 100 i blocchi # 8.00 su tutto il file: niente header, niente struttura, niente ripetizioni. # Un archivio compresso avrebbe comunque un header. Questo e' un container.
$ python3 - <<'EOF' import math,collections f=open("backup","rb") for i in range(100): b=f.read(1024*1024); c=collections.Counter(b) print(round(-sum((v/len(b))*math.log2(v/len(b)) for v in c.values()),2), end=" ") EOF 8.0 8.0 8.0 8.0 8.0 ... 8.0 # all 100 blocks # 8.00 across the whole file: no header, no structure, no repetition. # A compressed archive would still have a header. This is a container.
A VeraCrypt volume is indistinguishable from noise by design: no cleartext magic, the salt is the first 64 bytes and everything else is encrypted. So you try the password you just recovered.
$ printf '%s' 'Wh4t1sV3raD0inG0nTh1sH0st' | cryptsetup tcryptDump --veracrypt --key-file=- backup VERACRYPT header information for backup Version: 5 PBKDF2 hash: sha512 Cipher chain: aes Cipher mode: xts-plain64 MK offset: 131072
$ printf '%s' 'Wh4t1sV3raD0inG0nTh1sH0st' | cryptsetup tcryptDump --veracrypt --key-file=- backup VERACRYPT header information for backup Version: 5 PBKDF2 hash: sha512 Cipher chain: aes Cipher mode: xts-plain64 MK offset: 131072
[06] Opening it without root
cryptsetup open wants device-mapper, so root. If you do not have it — or you would rather not mount anything on an analysis box — the format is documented well enough to do it all offline: PBKDF2-HMAC-SHA512 over the salt, AES-XTS over the header, master keys at offset 192 of the decrypted header.
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC head = open("backup","rb").read(512) salt, enc = head[:64], head[64:512] hk = PBKDF2HMAC(hashes.SHA512(), 64, salt, 500_000).derive(b"Wh4t1sV3raD0inG0nTh1sH0st") def xts(key, data, unit): c = Cipher(algorithms.AES(key), modes.XTS(unit.to_bytes(16,"little"))).decryptor() return c.update(data) + c.finalize() dec = xts(hk, enc, 0) assert dec[:4] == b"VERA" keys = dec[192:192+64] # primaria || secondaria start = int.from_bytes(dec[44:52], "big") # 131072
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC head = open("backup","rb").read(512) salt, enc = head[:64], head[64:512] hk = PBKDF2HMAC(hashes.SHA512(), 64, salt, 500_000).derive(b"Wh4t1sV3raD0inG0nTh1sH0st") def xts(key, data, unit): c = Cipher(algorithms.AES(key), modes.XTS(unit.to_bytes(16,"little"))).decryptor() return c.update(data) + c.finalize() dec = xts(hk, enc, 0) assert dec[:4] == b"VERA" keys = dec[192:192+64] # primary || secondary start = int.from_bytes(dec[44:52], "big") # 131072
The header decrypts with data unit 0, and it is tempting to restart from 0 for the data area too. It does not: the XTS data unit number keeps counting from the start of the volume. The first data sector is unit 131072 / 512 = 256. Getting it wrong throws no error: it returns noise, indistinguishable from a wrong password.
$ python3 vcdec.py backup 'Wh4t1sV3raD0inG0nTh1sH0st' vault.img $ file vault.img vault.img: DOS/MBR boot sector, OEM-ID "MSDOS5.0", FAT (32 bit), sectors 204288
$ python3 vcdec.py backup 'Wh4t1sV3raD0inG0nTh1sH0st' vault.img $ file vault.img vault.img: DOS/MBR boot sector, OEM-ID "MSDOS5.0", FAT (32 bit), sectors 204288
[07] Inside the vault
$ fls -r -p vault.img d/d * 4: New folder <- cancellata d/d 5: $RECYCLE.BIN d/d 8: secret_financial_documents r/r 40: secret_financial_documents/important_invoice_byte_lotus.pdf r/r 43: secret_financial_documents/transactions_q3.csv $ icat vault.img 43 Date,Reference,Vendor,Description,Amount,Status 2026-07-02,TXN-10481,Byte Lotus Catering,Staff refreshments,842.16,Approved 2026-07-12,TXN-10531,Internal Adjustment,Image asset correction,0.00,Archived 2026-07-15,TXN-10547,Byte Lotus Resorts,Guest accommodation,3840.00,Approved
$ fls -r -p vault.img d/d * 4: New folder <- deleted d/d 5: $RECYCLE.BIN d/d 8: secret_financial_documents r/r 40: secret_financial_documents/important_invoice_byte_lotus.pdf r/r 43: secret_financial_documents/transactions_q3.csv $ icat vault.img 43 Date,Reference,Vendor,Description,Amount,Status 2026-07-02,TXN-10481,Byte Lotus Catering,Staff refreshments,842.16,Approved 2026-07-12,TXN-10531,Internal Adjustment,Image asset correction,0.00,Archived 2026-07-15,TXN-10547,Byte Lotus Resorts,Guest accommodation,3840.00,Approved
One row is not like the others: zero amount, Archived status, and a description about an image rather than an expense. It is the signpost for where to look.
The deleted folder, on the other hand, is a dead end: its directory entries are gone and the unallocated space is clean. Worth checking rather than assuming — blkls vault.img returns 103 MB without a single useful string.
[08] The flag is made of pixels
The PDF holds no text: it holds an image. Object 1 is a FlateDecode /Image, 636×724, 8 bits per component, and 636 × 724 × 3 = 1,381,392 — exactly the number of bytes that come out of the decompression. It is raw RGB, so you just wrap it.
import re, zlib from PIL import Image d = open("important_invoice_byte_lotus.pdf","rb").read() m = re.search(rb"/Subtype\s*/Image.*?stream\r?\n(.*?)endstream", d, re.S) raw = zlib.decompress(m.group(1)) Image.frombytes("RGB", (636,724), raw).save("invoice.png")
import re, zlib from PIL import Image d = open("important_invoice_byte_lotus.pdf","rb").read() m = re.search(rb"/Subtype\s*/Image.*?stream\r?\n(.*?)endstream", d, re.S) raw = zlib.decompress(m.group(1)) Image.frombytes("RGB", (636,724), raw).save("invoice.png")
Out comes a Byte Lotus Resorts invoice. The first row of the table, where a line item should be, contains the flag.
NO. DESCRIPTION QTY PRICE TOTAL 1. Flag: THM{████████████████████} 1 $100 $100
NO. DESCRIPTION QTY PRICE TOTAL 1. Flag: THM{████████████████████} 1 $100 $100
A flag rendered at 636 px wide is full of traps: l versus 1, 0 versus o. Rather than guessing, measure the glyphs. The same row carries $100 in the same font at the same size: the doubtful character after the A is 2 px wide and 8 tall, identical to the l in "Flag" at the start of the line, so it is a lowercase L. The round glyph is 8 tall, not 6 — digit height, not x-height — so it is a zero, not an o.
The string THM{ never appears anywhere in the collection, in any encoding. There is no grep -r shortcut: the flag only exists after the container is decrypted, and it sits inside an image inside a PDF. This room is built so the chain has to be walked end to end.
[09] Defense
| Weakness | Why it cost everything | Fix |
|---|---|---|
DefaultPassword |
Autologon writes the password in cleartext into the SECURITY hive. Anyone who reads that hive has the password, not a hash to crack. | No autologon on machines that hold secrets. If it is truly needed, use an account with no privileges and no DPAPI material worth taking. |
| Browser-saved passwords | Chrome's encryption is worth exactly as much as the user's password: once that is recovered, the v10 blob opens on its own. | A separate password manager for service credentials, and no saving into the profile of an automation account. |
| Password reuse | The same password opens the portal and the VeraCrypt volume: one recovery, two systems. | Distinct keys for the container and for the application, and a keyfile rather than a passphrase alone where the data deserves it. |
| Triage collection | A KAPE collection carries the hives and the profile: on its own it is enough to reconstruct every secret the user had. | Treat forensic collections as credential material: encrypted at rest, access logged, deleted on a schedule. |
[10] Method note
Twice in this room I mistook encrypted data for corrupted data. First with backup: entropy 8.00 does not mean "broken file", it means "container", and I only saw it by measuring instead of reading what file said. Then with the volume's data area: the wrong XTS numbering returns noise in exactly the same way a wrong password does, and for a few minutes I believed the password was the problem.
The room's comic says VERA was the concierge, the manager and the escalation team, all at once. The forensic half tells the same story from another angle: one host, one account, one secret — and everything else follows from it.