// before you start

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
// this is the room

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 detail that costs you an hour

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
// reading it without getting it wrong

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.

// note

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.