User testing of --chrome-process-scan on a VMware Win10 Edge session
exposed the heuristic's structural limits. The password matcher captured
URL-path fragments as usernames (`internal/`, `api/v1/`), CJK noise from
random bytes read as UTF-16, and chrome.dll auth-flow constants
(login.microsoft.com etc) — 71 reported "passwords", 0 real. The cookie
matcher fared similarly: 99k hits whose top hosts looked real
(americanexpress.com, youtube.com, apartments.com) but whose individual
rows were dominated by UUID-prefixed concatenated hostnames, JS-token
names (`typeof`, `viz.mojom.GpuHostMessageHeader`) and minified-JS
values (`Symbol&&Symbol.`, `||void 0===t||t`).
Tighter and tighter filters reduced the noise (TLD allowlist; RFC 6265
token-shape names; URL-fragment + auth-noise + JS-keyword rejection;
mixed-class value enforcement; dedup) but each pass either still leaked
thousands of false positives or rejected real cookies. The structural
problem is that chrome process memory contains too many sequences that
look like `domain\0name\0value` without being one; the heuristic has no
way to tell a CanonicalCookie instance from a string-table entry.
Strip both extractors from process_scan. The flag now walks every
running chromium process through the page-table region enumerator and
emits one info-level log line per process ("PID/image/MiB resident")
plus a discovery total — useful as "a browser was active when the
snapshot was taken; here's how much RAM it had touched" intel, no fake
data. ChromeFindings stays empty for the memory side, so disk-side
results aren't polluted.
The fix going forward is the per-Chrome-version `CookieMonster` locator
signature (chrome.dll destructor pattern → vtable → heap scan → RB-tree
walk). The CanonicalCookie struct layouts and tree walker remain in
`cookie_monster.rs` ready for that work — only the locator pattern is
missing, and adding it is non-noisy (it either finds a real
CookieMonster instance or it doesn't).
Tests: 86/86 pass. VMware Win10 hybrid baseline (83 cookies + 1
password from disk) preserved with --chrome-process-scan toggled either
way. Docs updated to match: README "in-process memory scan" section
becomes "in-process discovery", architecture.md adds the rationale,
examples.md replaces the fake-results sample with a real -v discovery
trace.
README:
- Reframe the chrome module as four extraction vectors (was three): add
the in-process scan (--chrome-process-scan) alongside disk-only,
hybrid and memory-only. Note the heuristic-noise tradeoff and the
upcoming per-Chrome-version locator that will replace it.
- Mention Edge ≤ 147 plaintext-in-memory disclosure (Rønning, April
2026) as the concrete motivation for the in-process vector.
- Add Acknowledgements entries: ChromeKatz (Meckazin) for the in-process
scan inspiration + CanonicalCookie struct layouts we ported,
xaitax/Chrome-App-Bound-Encryption-Decryption as the v20 format
reference (we intentionally took a different offline-friendly path),
DonPAPI and dploot as DPAPI chain references.
- Call out what we deliberately do *not* implement: live COM IElevator
abuse and debugger-based ABE bypasses (VoidStealer et al), since
vmkatz is an offline forensic tool.
docs/architecture.md:
- Update the src/chrome/ file tree with process_scan.rs and
cookie_monster.rs, and the new src/paging/regions.rs.
- Add a new "In-process memory scan (opt-in)" subsection explaining the
page-walk → ProcessMemory → heuristic pipeline, the role of the TLD
allowlist in plausible_cookie, and the cookie_monster.rs layouts that
the future locator signature will unwrap.
docs/examples.md:
- New "In-process scan (--chrome-process-scan, opt-in)" example with
the per-process scan log lines and the Edge memory-resident vault
use-case.
src/chrome/mod.rs:
- Refresh the module-level rustdoc with the in-process scan entrypoint
and the two new submodules (process_scan, cookie_monster).
Wire up the chrome memory vector that was scaffolded but never reached
from the CLI runner. New `chrome::process_scan::scan_chromium_processes`
walks every chrome.exe / msedge.exe / brave.exe / vivaldi.exe / opera.exe
in the snapshot, dumps each one's mapped userland through the page-walk
region enumerator + `ProcessMemory`, and runs the existing `heuristic`
password / cookie scanners over the bytes.
CLI: `--chrome-process-scan` (off by default). The disk-side decrypt
remains the high-fidelity path; the in-process scan is heuristic and
noisy, so it's opt-in. Findings are tagged `ChromeSource::Memory { pid,
process }` and merged into the same `ChromeFindings` document the disk
path produces, so renderers don't change.
Heuristic tightening: `plausible_cookie` now requires a common TLD on
the host (small allowlist of ~45 entries) and a token-shape cookie name
(RFC 6265 token chars, must start with a letter or underscore, bounded
length). On a VMware Win10 hybrid the TLD filter alone halved the false-
positive cookie count; remaining noise needs the per-version signature
work to go away properly. Updated two existing tests that used
`target.example` / `t.example` hostnames now that the heuristic gates on
TLD.
End-to-end status:
- Default flag set: VMware Win10 hybrid = 83 cookies + 1 password,
Proxmox VULN-WIN11 hybrid = 15 cookies + 0 passwords (baselines
unchanged).
- `--chrome-process-scan` on VMware Win10: +99k heuristic cookies + 71
heuristic passwords from msedge.exe processes. Useful as a "what's in
memory right now" signal even though many entries are false positives
until the signature locator lands.
86/86 lib tests pass.
Add `chrome::cookie_monster` with the precise in-process struct layouts
the upcoming signature-based locator unwraps:
- `OptimizedString` — MSVC `std::string` short-string-optimization
(23-byte inline buffer + length byte; heap
pointer + separate length when len > 22)
- `ProcessBoundString` — Chrome 130 / Edge 130 cookie value with
process-bound CryptProtectMemory encryption
(we report `<encrypted in-process>` since we
can't reverse it offline)
- `RbNode` / `RbRoot` — MSVC `std::map` red-black tree nodes for the
per-CookieMonster cookie store
- `CookieVariant` — Chrome 124 / Chrome 130 / Chrome 130 PB /
Edge 130 / Edge 130 PB / Legacy with the
matching field offsets (partition_key size
differs per browser, plus the value field
flips between OptimizedString and PB)
- `read_optimized_string`, `read_cookie`, `walk_cookie_tree` — readers
The per-Chrome-major-version locator pattern (ChromeKatz's 152-byte
destructor-bytes signature, patched with chrome.dll base) needs IDA
RE per release and is queued as a follow-up. Until then,
`chrome::process_scan` uses the existing heuristic ASCII / UTF-16
scanners; exposing these structs now means the future signature work
only adds the locator, not the readers.
Walk a process's page tables top-down (PML4 → PDPT → PD → PT) and emit
every present 4 KiB page in the canonical low half (PML4 indices 0..256)
as contiguous `MappedRegion`s. 1 GiB huge pages and 2 MiB large pages
contribute single regions; adjacent 4 KiB pages are coalesced.
Used by the upcoming chrome in-process scanner to feed the heuristic
pattern matchers without doing a brute-force 128 TiB virtual-address
scan. Capped at 4 GiB total bytes returned per process to prevent
runaway allocations on corrupt page tables.
Gated by `#[cfg(feature = "chrome")]` so the default build is unchanged.
The earlier doc pass described the chrome module as "partial — disk
masterkey derivation in progress" but every stage now lands end-to-end:
- README: replace the WIP blurb with three concrete decryption paths
(disk-only, hybrid, memory-only), the `--chrome-password` flag, the
ABE auto-key extraction note, and validation status across Win10 and
Win11 22H2/24H2/25H2 and Win Server 2019/2022/2025.
- docs/architecture.md: full file tree of `src/chrome/`, the three-stage
decrypt chain (DPAPI MK → Chrome v10 → v20 ABE), and the candidate-retry
rationale (`decrypt_blob` has no HMAC verify, so wrong MKs silently
produce garbage and we pick by layer-shape validation).
- docs/examples.md: real sample output for disk-only, disk-only with
`--chrome-password`, hybrid mem+disk, and `--chrome-json`.
- docs/tested-targets.md: chrome rows for the validated VMs (VMware Win10
hybrid 83 cookies; Proxmox VULN-WIN11 hybrid 15 cookies with IV
recovery; FUZZ-DBG fleet 52 each; ESXi Acronis archive 6-18 cookies).
- src/chrome/mod.rs: replace the placeholder stub doc with a module-level
overview of the three entrypoints + every submodule, and drop the dead
`run_placeholder` fn that the CLI never called.
No behavior change.
Two clarity passes, no behavior change:
- src/chrome/abe.rs: split `unwrap_app_bound_with_resolvers`. The old
version held a 60-line `try_layer` closure that took a `&dyn Fn`
validator plus two separate `validate_layerN` closures. Now there's
an `AbeLayer { User, System }` enum that owns `label()` and
`validates()`, and a free `decrypt_layer` fn that takes the layer
enum directly. The top-level `unwrap_app_bound_with_resolvers` is
back to a single short function whose body is "build the candidate
collector and dispatch the two layers."
- src/main.rs: extract the hybrid disk-open block into `run_chrome_hybrid`.
The inline form used an immediately-invoked closure for the `?`
early-return — concise but unusual; a named helper is easier to read
and lifts the "open disk once" invariant into the function signature.
VMware Win10 (83 cookies) and Proxmox VULN-WIN11 (15 cookies) unchanged.
83/83 lib tests still pass.
`find_iv_near_handles` (the .data-section fallback path used when the
key-init pattern doesn't match) ran its own ad-hoc rejection checks and
missed pointer-table-shaped candidates. On Win7-x64 it accepted a 16-byte
slice of two consecutive heap pointers as an "IV" (high bytes 0x07ff…
above canonical-x64 user space, so the existing pointer mask said "not
a pointer"), silently corrupting every cred decrypt.
Route both the recovery path and the data fallback through a single
`looks_like_iv` and add a structured-data check: if bytes 1..8 of the
first half equal bytes 9..16 of the second, the candidate is a pointer
pair, not random IV bytes. Drop the duplicate inline checks now that
`looks_like_iv` is authoritative.
The hybrid flow used to open the disk twice — once via
`build_keyrings_from_disk_with_passwords` (which internally calls
`extract_disk_secrets` → `open_disk`) and again via
`run_disk_with_keyring` (which calls `discover_profiles` → `open_disk`).
Each open creates a fresh File handle, so on filesystems that briefly
serialize sequential opens (or hold a residual exclusive lock after
close), the second open can fail with EBUSY and abort the chrome step
even though the first succeeded.
Open the disk exactly once in main.rs and thread the reader through the
new `run_reader_with_keyring` + `build_keyrings_from_reader` helpers in
the runner. Removes the second `open()` syscall entirely and yields
cleaner ownership: the disk handle lives for the full hybrid call and
is dropped at the closure boundary.
The .data scan introduced for paged-out IV globals had no upper bound on
radius, so on builds where no plausible IV lives in the same .data page
as the BCrypt handles it would walk the entire section and return the
first 16 bytes that passed the loose pointer/zero filters — often a
UTF-16 string fragment from a hostname or path literal.
Cap the search at 0x800 bytes (every observed IV-vs-handle delta has
been <= 0x60), reject candidates that look like UTF-16-LE ASCII (>=6 of
the 8 odd-position bytes are 0x00 with printable evens), and require
>=8 distinct byte values for entropy. When no plausible candidate is
in range, return None — the offset set is then skipped, which is
correct: better to fail-fast than to silently use a junk IV that
corrupts every cred decrypt.
decrypt_blob returns garbage (not an error) on a wrong MK because
AES-CBC succeeds regardless of key correctness — so the previous
.or_else() chain in unwrap_app_bound_with_resolvers silently used
the wrong source when the same GUID lived in both mem-cached and
disk-extracted keyrings with differing bytes.
Collect every available candidate per GUID and validate the output
shape per layer: layer1 result must itself parse as a DPAPI blob,
layer2 result must start with a small header_len u32. First candidate
that produces a valid-shaped output wins.
Defensive even now that the underlying mem/disk mismatch is fixed at
its source in the lsass IV path — keeps the chain robust against
other sources of MK divergence (per-MK delivery skew, partial cache
walks, etc).
The IV global lives in lsasrv.dll's .data section. When that page is
paged out, virt-mem reads return 16 zero bytes silently — every 3DES-CBC
cred decrypt then corrupts block 0 (first 8 bytes) while bytes 8+
decrypt correctly because CBC self-syncs on subsequent blocks. The bug
went undetected because key-size validation accepted the offset set
before checking IV plausibility.
After reading the IV global, detect all-zeros and recover via a .data
scan anchored at the h3DesKey global: walk outward in 8-byte steps,
pick the first plausible 16-byte non-zero, non-pointer, high-entropy
candidate. On affected memory dumps the recovered IV byte-matches the
ground-truth derived from disk-extracted masterkeys.
Adds a CLI flag for supplying password candidates that aren't in LSA — common
in pivoting / cracking / vuln-lab scenarios where you know the password but
the target hasn't auto-logon. Candidates are merged into the same list that
holds LSA DefaultPassword + every _SC_* ServicePassword, then tried per MK
file.
Threaded through both code paths:
- run_disk_with_passwords / build_keyrings_from_disk_with_passwords (path API)
- run_reader (reader API used by run_vmfs)
Use:
vmkatz --chrome --chrome-password user --chrome-password vagrant <disk>
On a real vuln-lab Win11 image with `user` user (NT('user') = 57d583aa...)
this decrypted 15 v20 cookies (bing.com session, etc.) that were locked
because LSA had neither DefaultPassword nor any _SC_* service passwords.
Two coupled improvements unlock chrome extraction on VMFS-backed flat VMDKs:
1. Refactor chrome::runner so discovery and keyring construction can run on an
already-open disk reader (the run_vmfs path opens its own Box<dyn DiskImage>
and can't reopen by path):
- new pub fn run_reader<R: Read+Seek>(reader, &DiskSecrets) -> DiscoverySummary
- new internal discover_profiles_in_reader and build_keyrings_with_secrets
- existing path-based run_disk / build_keyrings_from_disk now delegate to
the reader variants
In main.rs::run_vmfs, after SAM/LSA extraction finishes, conditionally call
chrome::runner::run_reader with the same disk handle and the secrets we
just extracted. No re-open, no second NTFS partition scan.
2. Try every plaintext password recovered from LSA, not just DefaultPassword.
_SC_* service-account passwords frequently match the password of the
account that created the service. On a real ESXi-hosted MoveIT server this
recovered the moveitsvc account's 3 user DPAPI master keys via the LSA
_SC_MOVEitCentral secret. Same on a LAB-ACRONIS image: 3 MKs for an
internal service user via the _SC_MICAdmin LSA secret.
Adds src/chrome/abe_keys.rs with a small PE/COFF reader (no new deps) plus a
pattern-scanner that walks `.rdata` looking for the 3-entry Chrome ABE key
table (56-byte structs, version bytes 1/2/3, 32-byte AES key per entry). The
runner now opportunistically scans Program Files\<browser>\Application\<ver>\
elevation_service.exe (Chrome, Brave, Vivaldi, Opera) during the existing NTFS
walk and builds a BrowserKeyMap from whichever browsers are installed. The
hardcoded Chrome 135 keys remain as the fallback when no binary is found or
the scan returns empty.
Plumbs the keymap through abe::unwrap_app_bound_key,
unwrap_app_bound_with_resolvers, decrypt_aes_encrypted_key, and
disk::extract_from_disk / derive_keys. Adds a decrypt_aes_encrypted_key_with_fallback
helper so legacy callers keep working.
Verified on a Windows 10 VMware disk: same 83 cookies decrypted as before,
log line "[chrome] extracted 3 ABE keys from chrome Program Files\Google\
Chrome\Application\135.0.7049.115\elevation_service.exe" confirms the scanner
found the live binary, and a /tmp/elev_chrome135.exe roundtrip test in
tests/chrome_abe_keys.rs asserts the three extracted keys match the
hardcoded fallback byte-for-byte.
Test suite goes from 92 to 99 passing.
Final piece of the v20 chain: Chrome's inner-layer 32-byte v20 key is encrypted
with AES-256-GCM using one of three static keys embedded in elevation_service.exe.
The key is selected by a version byte at the start of the inner cipher field:
cipher = version(1) || nonce(12) || ct(32) || tag(16) // 61 bytes
v20_key = AES-256-GCM-decrypt(STATIC_KEY[version], nonce, ct, tag, aad=empty)
Chrome 135.0.7049.115 stores three keys at .rdata:0x1401EC040 in a 56-byte
struct array { u8 version; u8[15] meta; u8[32] aes_key; u8[8] _; }. The
decryption code in sub_1400369C0 zero-inits a 32-byte buffer and XORs the
matching node's key bytes into it (control-flow obfuscation; the bytes
loaded ARE the key). Found via IDA reverse-engineering.
The previously-shipped CHROME_FLAG1_KEY public value (from public RE writeups)
turned out to be wrong in its second 16 bytes — replaced.
Also commit:
- `tools/extract_file.rs` — helper bin that walks an NTFS partition and
extracts a file by path (used to grab elevation_service.exe from a
disk image without mounting).
- Make the NTFS helpers in `sam::` `pub` instead of `pub(crate)` so external
bins like `extract_file` can use them.
Validated on the VMware Win10 image: 83 v20 cookies decrypted across both
Chrome and Edge (was 35 before), including google.com, github.com,
adobe.com sessions and an intranet myHRBrowser cookie.
Three coordinated fixes wire the full v20 chain on-disk:
1. Walk `Protect\S-1-5-18\User\` in addition to the parent S-1-5-18 dir.
Chrome's elevation_service.exe writes the v20 outer-layer DPAPI wrap to
the user-context SYSTEM masterkey dir, which lives at this subpath and
was previously skipped.
2. Use the correct DPAPI_SYSTEM half per subpath: machine-context MKs
(parent S-1-5-18) decrypt with DPAPI_SYSTEM.user_key, while user-context
MKs (S-1-5-18\User) decrypt with DPAPI_SYSTEM.machine_key. Microsoft's
naming is the opposite of what you'd expect; verified empirically.
3. Rewrite the ABE inner-layer parse. After the two DPAPI layers, the
"aes_encrypted_key" is NOT [flag(1)][nonce(12)][ct][tag] as I guessed;
it's a length-prefixed struct:
header_len(u32 LE) || flag(u8) || install_path
cipher_len(u32 LE) || cipher
PKCS#7 padding to a 16-byte boundary
For Edge flag=2 the cipher field IS the raw 32-byte v20 key — Edge
skips the inner GCM layer entirely. Chrome flag=2 wraps it under a
Chromium-version-specific static key (flag-1 key is the only one we
ship); plug a known key into decrypt_aes_encrypted_key_with_key when
available.
Also: ABE resolvers now consult both user and system keyrings for each
layer, since the outer DPAPI layer references an MK that lives in the
SYSTEM keyring on these installs.
Result on a real VMware Windows 10 image: 35 Edge v20 cookies decrypt
including bing.com session, MSN, and an SAP single sign-on token, all
tagged [DiskAbe] confirming the App-Bound chain ran end-to-end.
The masterkey-file decrypt is NOT PBKDF2 (RFC 2898). Microsoft's DPAPI uses
a non-standard variant where each iteration's PRF input is the accumulated
XOR rather than the previous round's PRF output. Standard PBKDF2 from the
`pbkdf2` crate produces a different (wrong) AES key, so AES-CBC decrypt of
the masterkey ciphertext yields garbage — bad enough to slip past the
non-validating slice extraction we had before.
This commit:
- Adds dpapi_derive_key_sha512 / _sha1 — direct implementations of the
impacket MasterKey.deriveKey algorithm (see comment in the function).
- Replaces the pbkdf2_hmac call sites with the new derives.
- Adds HMAC-SHA512/SHA1 validation of the decrypted masterkey against the
hmacSalt + hmac block at the cleartext head, so wrong pre-keys are rejected
with a clean error instead of returning random bytes.
- Extracts the masterkey from cleartext[-64..] (impacket convention; the
earlier code took cleartext[..64] which was wrong).
- Adds decrypt_local_user_masterkey_pw: Win10+ local user chain takes the
actual password via SHA1(UTF-16-LE password) — the NTLM-hash-only chain
only works for domain users.
- Wires LSA DefaultPassword discovery in chrome::runner::build_keyrings_from_disk
so password-protected local profiles decrypt automatically when the password
is available from auto-logon.
- Switches the SYSTEM masterkey pre-key from DPAPI_SYSTEM.machine_key to
user_key — Windows uses the user_key half for the S-1-5-18 context despite
the confusing naming.
- Generalizes run_disk_with_keyring to take distinct user / system resolver
types so callers can compose HybridKeyring + ComposedResolver freely.
Validated on a VMware Windows 10 image (LSA DefaultPassword = "blahblah",
local user, NTLM hash bbf7d152...): Edge profile now produces a decrypted
saved password for the airbus.onelogin.com login, sourced entirely from
disk without a memory snapshot. All 92 tests pass.
Two related corrections to the DPAPI blob decrypt and v10 cookie decrypt:
1. DPAPI BLOB AES-256-CBC uses an all-zero IV, not bytes derived from the
session key. The session key (HMAC-SHA512 output) is purely the AES key
material — Microsoft uses zero IV. Previous code took session[32..48] as
the IV which produced a v10 key that didn't match Chrome's actual key.
Verified against impacket: same input → same plaintext.
2. v10/v11 GCM decrypt now supports optional AAD via decrypt_v10_aad. Modern
Chrome cookies may bind ciphertext to the host string. extract_cookies
tries empty AAD first, then host-as-AAD on failure.
Synthetic roundtrip test updated to use zero IV.
SQLite type affinity allows raw binary bytes to land in a TEXT-typed column,
and Chromium does exactly this: cookies tables store v10-prefixed encrypted
ciphertext as TEXT. The previous decoder called String::from_utf8_lossy and
replaced every non-UTF-8 byte with U+FFFD, corrupting the ciphertext beyond
any possibility of decryption. Change Value::Text to carry Vec<u8> and decode
to &str lazily via as_text; add an as_bytes accessor that returns the raw
bytes from either TEXT or BLOB columns.
Validated on a real Chrome Cookies table: cookie values now decrypt cleanly
where they previously failed v10 GCM auth.
When both a memory snapshot and a --disk are supplied, build keyrings from
both sources and compose them: try the LSASS-cached user MK cache first,
fall back to the on-disk NT-hash-derived chain. Pass the disk-extracted
SYSTEM MK keyring as the system resolver for v20 App-Bound Encryption.
Generalize run_disk_with_keyring to accept distinct user/system resolver
types so callers can mix HybridKeyring with ComposedResolver.
Implements the cryptographic chain that decrypts DPAPI masterkey files from disk
without a memory snapshot, enabling Chrome / Edge offline decryption in the
disk-only flow.
- sam::dpapi_masterkey: add decrypt_local_user_masterkey (HMAC-SHA1 pre-key over
NT hash + UTF-16LE SID), decrypt_system_masterkey (DPAPI_SYSTEM machine_key),
and decrypt_masterkey_with_prekey core dispatching between AES-256/SHA-512
(mode 15900) and 3DES/SHA-1 (mode 15300) via PBKDF2 + CBC.
- chrome:🏃 add build_keyrings_from_disk that walks SAM + LSA, then
enumerates every accessible user and S-1-5-18 Protect directory and decrypts
each MK file. run_disk now consumes both keyrings via extract_from_disk so
--chrome --disk yields decrypted v10/v11 (and ABE v20 when MKs available).
- Add pbkdf2 dep (hmac feature only, default-features off) gated behind sam.
- 2 round-trip tests for the user-local and SYSTEM AES paths.
When --chrome is passed with both a memory snapshot and --disk, collect every
cleartext DPAPI master key surfaced by the LSASS extractor (Credential.dpapi)
into a HybridKeyring and run chrome disk extraction against that keyring.
Chrome / Edge profiles whose Local State references one of the LSASS-cached
MK GUIDs now decrypt the per-profile v10 AES-GCM key directly, without
needing to crack the user's NTLM hash.
New runner entrypoints:
- run_disk_with_keyring(disk, user_resolver, system_resolver)
- keyring_from_pairs(pairs) — adapter for any (guid, key_bytes) iterator
- ArtifactsTree — in-memory FileTree backed by already-discovered profile
artifacts so the orchestrator does not re-walk NTFS
Discovery summary now also surfaces the MK GUID per profile so operators can
tell at a glance whether a given run has the keys it needs. Profiles whose
MK GUID is not in the keyring fall back to discovery-only output.
What still needs work: SYSTEM-DPAPI MK decryption (Chrome v127+ v20 cookies),
and on-disk user MK decryption from NTLM hash (for profiles whose MK is not
cached in LSASS at snapshot time).
Chromium's schema declares encrypted_value as BLOB but SQLite type affinity
sometimes lands the bytes as TEXT, especially for Edge's Cookies table where
v20-prefixed ciphertext is stored as TEXT. Previous code's find_map(as_blob)
picked the first empty BLOB column and missed the actual encrypted value.
New is_chrome_blob helper picks the first column (BLOB or TEXT) whose bytes
start with a v10/v11/v20 prefix. Validated against a real Edge profile where
all 35 cookies are stored as TEXT-typed v20 blobs.
DPAPI's protocol derives the session key as HMAC-SHA512(SHA1(masterkey), salt).
LSASS exposes both the raw 64-byte master key AND its SHA-1 specifically for
this reason. The previous implementation passed the raw 64-byte key as the HMAC
input, which only round-tripped because the synthetic test built the blob with
the same wrong key. Validated against real Edge Local State blobs on a Windows
10 VM with cleartext masterkey sourced from LSASS — derived v10 AES-GCM key
now matches the value Chrome encrypts cookies with.
Test updated to use the correct algorithm so it actually exercises the spec.
- lznt1_displacement_bits: the tier-bump comparison must be `<=`, not
`<`. At exact power-of-two positions (16, 32, 64, ...) the old code
returned one bit too few, silently corrupting decompressed Hyper-V
VMRS RAM pages when a back-reference landed on one of those offsets.
- carve_kerberos_tickets: kvno cap of 20 was too strict. Microsoft
recommends periodic krbtgt resets and many enterprises rotate often,
pushing real kvnos past 20. Raise the ceiling to 1000 while keeping
kvno != 0 to reject obvious garbage from wrong-offset guesses.
Windows Server VMs on VMware/ESXi overwhelmingly use FLAT or VMFS
extents (raw disk data in a -flat.vmdk sibling) rather than SPARSE
extents. Previously the descriptor parser blindly called open_extent
on any RW line, which expected the KDMV sparse magic and failed with
"Invalid magic: 0xD08EC033" (the MBR boot code bytes) when the
underlying file was a flat extent.
- Parse the extent type field (SPARSE / FLAT / VMFS / VMFSRAW /
VMFSRDM / VMFSRDMP / ZERO) and the optional sector offset
- Add open_flat_extent() for direct raw-sector reads
- Handle flat extents in read_virtual (direct seek+read)
- Iterate flat extents in 1 MiB chunks in scan_all_grains
- Extract quoted filename robustly so names containing NBSP or
other whitespace survive (VMware keeps the user-provided VM name
verbatim, often with non-breaking spaces on ESXi)
Verified on ESXi 8 against a Windows Server 2022 VMFS flat VMDK:
SAM hashes, LSA secrets, and DPAPI master keys all extract.
- rc4(): guard against empty key to prevent division-by-zero panic
- hive: only treat values as inline when high bit is set (not data_size <= 4)
- bootkey: scan at 4-byte alignment instead of 8 (cells are 4-byte aligned)
- vmdk: parse RDONLY and NOACCESS extents, not just RW
- ntfs_reader: seek before each chunk in resilient read to fix cursor desync
Two bugs in carve_kerberos_tickets that silently missed most tickets:
1. Scan stride was step_by(8), but several TicketOffsets variants place
ticket_enc_type at a 4-aligned-but-not-8-aligned offset:
- Win10 1607+ x64: 0x124 (% 8 = 4)
- Win10 1607+ x86: 0x9C (% 8 = 4)
- Win10 1507 x86: 0x8C (% 8 = 4)
With standard 16-byte pool alignment the enc_type field lands at an
address that's 4 mod 8 — never hit by an 8-byte stride starting from
a page-aligned chunk. This silently missed every carved ticket on
modern Windows. The AVL walk path still worked, masking the bug.
Fixed by scanning step_by(4).
2. Chunks were non-overlapping. When enc_type was found near the start
of a chunk, the backward fields (flags at enc_type - 0x84 for 1607+)
fell into the previous chunk, failed the bounds check, and the
candidate was skipped. Fixed with 0x200 overlap between chunks;
dedup handles the doubled candidates.
step_by(4) doubles the scan cost but correctness > speed.
Clippy + all 32 tests pass.
The default cross 0.2.5 images for arm-*/armv7-* have an old glibc
that fails when build scripts (e.g. generic-array) use modern rustc.
Using :main edge images resolves this.
Verified locally: all four ARM targets build clean (armv7 + arm,
gnu + musl). Binaries are ~2.5 MB each.
Release matrix now covers:
- Linux x86_64 (gnu + musl/ESXi)
- Linux aarch64 (gnu + musl, native ARM runners)
- Linux armv7 hf (gnu + musl, cross-compiled)
- Linux arm hf / armv5/v6 (gnu + musl, cross-compiled)
- macOS x86_64 (macos-13) and aarch64 (macos-latest)
- Windows x86_64 and aarch64 (windows-11-arm)
Uses cross-rs for the armv7/arm cross-compilation targets.
Release job only runs on tag pushes so workflow_dispatch can test
builds without publishing a release.
- vmkatz_loader.py moved to tools/vmkatz_loader.py
- Release workflow includes the loader in the esxi-x86_64 tar.gz
- Updated README and docs/esxi.md paths
README trimmed from 524 to 175 lines. Moved to docs/:
- docs/esxi.md — ESXi deployment, VIB bypass, VMFS raw access
- docs/examples.md — Example output (LSASS, hashcat, NTDS, pagefile)
- docs/architecture.md — Module layout and how it works
- docs/tested-targets.md — Test matrix and known limitations
README now focuses on: intro, what it extracts, supported inputs,
quick start, output formats, ESXi basics, build features, and links.
- README: new section explaining vmkatz_loader.py usage when
execInstalledOnly is enabled, with command to check VIB status
- Bug template: add ESXi VIB protection field asking for
execInstalledOnly value and ESXi version
Loads vmkatz into anonymous mmap pages with PROT_EXEC, bypassing
execInstalledOnly on ESXi. VMkernel blocks execve on unsigned binaries
but allows PROT_EXEC on anonymous mappings. Python is VIB-signed.
The loader parses ELF64 headers, maps PT_LOAD segments, applies
R_X86_64_RELATIVE relocations for PIE, builds a proper initial
stack with argc/argv/envp/auxv (AT_PHDR, AT_PHNUM, AT_PAGESZ,
AT_RANDOM for musl TLS init), and jumps to _start via trampoline.
Usage: python3 vmkatz_loader.py /tmp/vmkatz [args...]
Scans LSASS virtual memory for orphaned _KERB_TICKET_INFO structures
that are no longer in the AVL session tree (freed sessions, logged-out
users with memory still resident). Recovers tickets missed by the
normal AVL tree walk.
Approach: scan for valid etype values at ticket_enc_type offsets, then
validate surrounding structure (kvno, ticket_length, flags, service
name pointer) before calling extract_single_ticket for full parsing.
Tries all known TicketOffsets variants (Win7/8/10/11). Deduplicates
against tickets already found by the structured extraction.
Runs automatically after the normal Kerberos provider extraction.
When BitLocker FVEK keys are extracted from a VM memory snapshot,
vmkatz can now use them to decrypt the corresponding encrypted disk
for SAM/NTDS extraction in the same run.
New modules:
- sam/aes_xts.rs: AES-XTS-128/256 sector-level decryption
- sam/bitlocker_decrypt.rs: transparent Read+Seek wrapper that
decrypts sectors on-the-fly, validates FVEK by checking for
NTFS signature in decrypted sector 0
Flow: snapshot → extract FVEK → open disk → detect BitLocker →
try each FVEK candidate → decrypt partition → extract credentials.
Works in directory mode (auto-discovers snapshot + disk) and with
explicit --disk flag.
Scans physical memory for Windows pool tags to extract Full Volume
Encryption Keys, enabling offline decryption of BitLocker volumes:
- FVEc pool tag (Win7): AES-128/256-CBC, Elephant Diffuser modes
- Cngb pool tag (Win8+): AES-128/256-XTS modes
- Dual-copy validation for Cngb, entropy checks for both
- Tries both x64 and x86 structure offsets automatically
Output: hex FVEK in text/csv/hashcat formats, dislocker-compatible
.fvek files via --bitlocker-fvek <dir>. Works in both normal and
--carve modes (physical scan, no LSASS context needed).
MappedFile now has a Pread variant that uses read_exact_at() instead
of memory mapping. When mmap returns EINVAL (unsupported by kernel),
vmkatz falls back to file I/O with a clear warning message.
Layers updated to use read_at() for PhysicalMemory::read_phys:
- VMware: pread for runtime reads, header parsed from first 8MB buffer
- Hyper-V: pread for all reads
- QEMU ELF: pread for runtime reads, ELF header parsed from 1MB buffer
- QEMU savevm: requires mmap (stream parsing needs full slice access)
Zero host RAM impact — pread reads directly from disk to caller buffer.
- qemu/savevm.rs: read_be_u32/read_be_u64 now use .get() instead of
direct indexing — returns 0 instead of panicking on truncated input
- disk/vhdx.rs: guard against chunk_ratio=0 which would cause division
by zero on crafted VHDX files with oversized block_size
- main.rs: suppress dead_code warning for QemuSavevm variant in
non-qemu single-feature builds
When no text descriptor exists and only -sNNN extent files are present,
the discovery code now sorts extents before picking the first one.
Previously it used filesystem iteration order which was non-deterministic.
The Vec<u8> fallback was added for ESXi kernels where mmap failed on
VMFS-5 files. Now that mmap_file() handles block devices and edge cases,
the fallback is unnecessary and dangerous — it would silently try to
allocate hundreds of GB for large VMEM files if mmap ever failed.
Mmap::map() uses fstat which returns 0 for block devices (LVM volumes).
Added utils::mmap_file() that uses seek to get real size and passes it
explicitly to MmapOptions. Replaced all Mmap::map() calls across:
- qemu/savevm.rs (was already fixed inline, now uses shared helper)
- qemu/layer.rs (ELF core dumps)
- hyperv/layer.rs (.bin/.raw dumps)
- vmware/layer.rs (fallback path also used metadata().len())
- hyperv/vmrs.rs (used metadata().len() for size)
- vbox/layer.rs (used metadata().len() for size)
Also added utils::file_size() for non-mmap size queries.
The parser previously rejected VMFS-5 (majorVersion < 24). Now handles
both VMFS-5 and VMFS-6 with version-dependent branching for:
- File descriptor size (2048 vs 2*mdAlignment) and layout
- Block pointers (32-bit vs 64-bit)
- Address encoding (FB vs SFB/LFB, different bit layouts)
- Directory format (flat 140-byte entries vs block-based 288-byte)
- Pointer blocks (4KB/1024 ptrs vs 64KB/8192 ptrs)
- Resource metadata (no rfmd signature in VMFS-5)
- SFD bootstrap offset formula
- TBZ bits (VMFS-6 only, not in 32-bit addresses)
Verified on both VMFS-5 and VMFS-6 datastores.
Block devices (e.g. /dev/pve/vm-*-state-*) were incorrectly routed
to SAM disk extraction instead of QEMU savevm parsing. Two fixes:
- Check QEVM magic on block devices before assuming SAM mode
- Use read_exact instead of mmap for is_qemu_savevm() detection
- Use explicit size via seek(End) for mmap in QemuSavevmLayer::open(),
since fstat returns 0 for block devices
- Show account status in SAM text output: (DISABLED), (NO PASSWORD),
(BLANK PASSWORD) from per-user ACB flags in registry F value
- Add modification date to DPAPI master key text output from NTFS mtime
- Deduplicate DPAPI keys: show only most recent per user (--all for all)
- Filter NTFS DOS 8.3 short names in list_directory() — fixes S-1-5-~1
SID leak from short name aliases
- Add cargo test step to CI workflow
The hashcat mode depends on context (local vs domain user), not just the
crypto version. Domain users use NTLM pre-key derivation (modes 15310/15910)
while local users use SHA1 pre-key (modes 15300/15900). The context is
determined by whether a domain key section exists in the master key file.
Previously all hashes were reported as 15300 or 15900 regardless of context.
Previously, permission errors, mount failures, or I/O issues during VM
file discovery were silently swallowed with if-let-Ok patterns. On a NAS
with partial mounts or restricted permissions, this caused VMs to be
silently missed with no indication of the problem.
Now all I/O errors in discover.rs produce log::warn! messages:
- read_dir failures on scan directories
- metadata/stat failures on individual files
- file open failures in magic-byte checks (ELF, VMDK descriptor)
Added file_size() helper to centralize metadata reads with warnings.
- Add QEMU/Proxmox savevm and VMware embedded .vmsn to supported inputs
- Add ESXi 6.7 and Proxmox savevm test results to tested targets table
- Document BitLocker detection and QEMU savevm limitations
- Update target OS range to include Windows Server 2003
- Add Proxmox example to quick start
- Update module architecture and feature descriptions
Two bugs fixed:
1. LSA key derivation: MD5 input was salt+bootkey×1000 instead of
bootkey+salt×1000 (wrong order per impacket/MS-LSAD spec)
2. Secret decryption: used RC4 instead of DES-ECB with rotating
7-byte key segments (SystemFunction005 / [MS-LSAD] Section 5.1.2)
Implemented:
- DES-ECB decrypt with rotating key (des_ecb_decrypt_rotating)
- transformKey: 7-byte to 8-byte DES key expansion ([MS-LSAD] 5.1.3)
- LSA_SECRET_XP parsing: Length(4) + Version(4) + Secret(Length)
Tested on Windows Server 2003 R2 SP2 VHDX: DPAPI_SYSTEM keys and all
LSA secrets now decrypt correctly.
Windows Server 2003 R2 SP2 reports PolRevision 0x10007 (modern) but uses
the legacy PolSecretEncryptionKey scheme. Try PolEKList first; if not found,
fall back to PolSecretEncryptionKey instead of failing.
Check for the -FVE-FS- OEM ID in partition boot records before
attempting NTFS parsing. When BitLocker is detected, skip the
partition and display a clear error message instead of failing
silently with "No registry hives found".
Applied to SAM extraction, LSA secrets, and NTDS.dit extraction paths.
VMware layer:
- Fix base_offset not set when .vmsn has region tags but no separate
.vmem file (embedded memory). Reads were at wrong file offset.
- Tested on ESXi 6.7 with Win Server 2016 embedded .vmsn snapshot.
Display:
- Don't display Kerberos/WDigest passwords for machine accounts (username
ending with $) — they are binary blobs, not useful plaintext. The
Kerberos keys (AES256, RC4=NT hash) are the exploitable forms.
- Don't count machine passwords in the summary line.
- Truncate long hex passwords to "(hex, N bytes) first32bytes..." when
displayed for other non-printable passwords.
New parser (src/qemu/savevm.rs):
- Parse QEVM magic, RAM block list, and page entries (PAGE/ZERO)
- MMIO gap remapping for q35+UEFI (below_4g=0x80000000)
- Skip non-RAM device sections (dirty-bitmap, etc.) via forward scanning
- HashMap-based deduplication for dirty page iterations (last-write-wins)
- Auto-detection via QEVM magic in format dispatcher
System process discovery improvements:
- Validate candidates by translating Flink VA to physical and checking the
linked EPROCESS has a printable ImageFileName and valid kernel-mode Flink
- Rejects stale EPROCESS remnants with corrupted page tables
Tested on Proxmox VMs: DC25 (Win Server 2025, 4GB) and WKS11 (Win11, 8GB)
- Merge 8 duplicated _x86 functions into single arch-aware implementations:
walk_avl_tree, read_kerb_external_name, extract_kerb_password,
extract_tickets_from_list, extract_single_ticket, detect_kerb_offsets,
extract_kerb_keys, extract_kerberos_credentials
- All structure offsets computed from arch.ptr_size() and arch.ustr_size()
- Delete extract_kerberos_credentials_arch wrapper and all _x86 variants
- Net reduction: -580 lines
- Merge extract_msv_sessions_arch and extract_msv_credentials_arch into the
main functions, selecting offset variants by arch at runtime
- Make all walkers arch-aware: walk_session_buckets, walk_session_list,
walk_msv_list, walk_hash_table, find_inline_hash_table,
find_credentials_ptr_in_entry
- Delete 258 lines of duplicated x86 code (extract_msv_sessions_arch,
extract_msv_credentials_arch, try_extract_primary_cred_x86)
- x86 now gets scoring, enrichment, multi-pass credential scanning, and
SHA1 validation — previously x64-only features
- Add variant_order_for_build_arch for x86 build-aware variant ordering
- Add read_ptr_from_buf, walk_list, read_data_section, scan_data_for_list_head
to types.rs, eliminating ~200 lines of duplicated boilerplate across providers
- Unify kerberos.rs: merge 8 duplicated _x86 functions into arch-aware versions
(walk_avl_tree, extract_single_ticket, read_kerb_external_name, etc.), -440 lines
- Fix CloudAP: read account name from toName_ptr (+0x58) instead of hashName (+0x68)
which is a SHA hash, not the readable UPN. Add 3 offset variants (1507-1607,
1703-1709, 1803+) with auto-detection. Remove useless hashName fallback.
- Fix walk_session_buckets using x64-only read_win_unicode_string and read_virt_u64
for pointers — now uses arch-aware read_ustring and read_ptr