Add transparent BitLocker disk decryption using FVEK from memory

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.
This commit is contained in:
NK
2026-03-21 15:58:32 +01:00
parent e6d6ef36df
commit d9b35a1eab
4 changed files with 1029 additions and 6 deletions
+157 -6
View File
@@ -761,6 +761,38 @@ fn run_vmfs(device_path: &Path, vmdk_path: Option<&str>, args: &Args) -> anyhow:
Ok(())
}
/// Print DiskSecrets (SAM + LSA + cached creds) using the current output format.
#[cfg(feature = "sam")]
fn print_disk_secrets(secrets: &vmkatz::sam::DiskSecrets, args: &Args) {
let c = get_colors(args);
match args.format.as_str() {
"ntlm" => print_sam_ntlm(&secrets.sam_entries),
"csv" => print_sam_csv(&secrets.sam_entries),
"hashcat" => print_sam_hashcat(&secrets.sam_entries),
"brief" => print_sam_brief(&secrets.sam_entries),
_ => print_sam_text(&secrets.sam_entries, c),
}
if !secrets.lsa_secrets.is_empty() {
match args.format.as_str() {
"csv" => print_lsa_csv(&secrets.lsa_secrets),
"hashcat" => {} // LSA secrets not applicable for hashcat
_ => print_lsa_secrets(&secrets.lsa_secrets, c),
}
}
export_dpapi_backup_keys(&secrets.lsa_secrets);
if !secrets.cached_credentials.is_empty() {
match args.format.as_str() {
"csv" => print_dcc2_csv(&secrets.cached_credentials),
"hashcat" => print_dcc2_hashcat(&secrets.cached_credentials),
_ => print_cached_credentials(&secrets.cached_credentials, c),
}
}
}
#[cfg(feature = "sam")]
fn run_sam(input_path: &Path, args: &Args) -> anyhow::Result<()> {
#[cfg(feature = "ntds.dit")]
@@ -1550,10 +1582,58 @@ fn run_directory(dir: &Path, args: &Args) -> anyhow::Result<()> {
}
}
// Extract BitLocker FVEK keys from memory snapshots for transparent disk decryption.
// This is done as a separate pass so we can pass keys to the SAM extraction phase.
#[cfg(all(
feature = "sam",
any(
feature = "vmware",
feature = "vbox",
feature = "qemu",
feature = "hyperv"
)
))]
let bitlocker_keys: Vec<vmkatz::lsass::bitlocker::BitLockerKey> = {
let mut keys = Vec::new();
for file in &discovery.lsass_files {
let snapshot_keys = extract_bitlocker_keys_from_snapshot(file);
if !snapshot_keys.is_empty() {
log::info!(
"BitLocker: {} FVEK candidate(s) from {}",
snapshot_keys.len(),
file.display()
);
keys.extend(snapshot_keys);
}
}
// Deduplicate by FVEK content
keys.sort_by(|a, b| a.fvek.cmp(&b.fvek));
keys.dedup_by(|a, b| a.fvek == b.fvek && a.method == b.method);
keys
};
#[cfg(feature = "sam")]
for file in &discovery.disk_files {
let name = file.file_name().unwrap_or_default().to_string_lossy();
eprintln!("\n[*] SAM: {}", name);
// Try BitLocker-aware extraction if we have FVEK keys from memory
#[cfg(any(
feature = "vmware",
feature = "vbox",
feature = "qemu",
feature = "hyperv"
))]
if !bitlocker_keys.is_empty() {
match vmkatz::sam::extract_disk_secrets_with_bitlocker(file, &bitlocker_keys) {
Ok(secrets) => {
print_disk_secrets(&secrets, args);
continue;
}
Err(e) => {
log::info!("BitLocker-aware extraction failed for {}: {}", name, e);
}
}
}
if let Err(e) = run_sam(file, args) {
eprintln!("[!] {}: {:#}", name, e);
}
@@ -2431,12 +2511,15 @@ fn export_kerberos_tickets(credentials: &[Credential], args: &Args) {
feature = "qemu",
feature = "hyperv"
))]
fn extract_and_output_bitlocker<L: PhysicalMemory>(layer: &L, args: &Args) {
fn extract_and_output_bitlocker<L: PhysicalMemory>(
layer: &L,
args: &Args,
) -> Vec<vmkatz::lsass::bitlocker::BitLockerKey> {
let t_bl = std::time::Instant::now();
let keys = vmkatz::lsass::bitlocker::extract_bitlocker_keys(layer);
if keys.is_empty() {
log::debug!("BitLocker: no FVEK candidates found ({:?})", t_bl.elapsed());
return;
return keys;
}
eprintln!(
"[+] BitLocker: {} FVEK candidate(s) found ({:?})",
@@ -2448,15 +2531,30 @@ fn extract_and_output_bitlocker<L: PhysicalMemory>(layer: &L, args: &Args) {
println!(
"\n{}[+] BitLocker FVEK ({} candidate(s)):{}\n",
c.green, keys.len(), c.reset
c.green,
keys.len(),
c.reset
);
for (i, key) in keys.iter().enumerate() {
println!(" {}Candidate #{}{}", c.bold, i + 1, c.reset);
println!(" Cipher : {}{}{}", c.yellow, key.cipher, c.reset);
println!(" FVEK : {}{}{}", c.yellow, hex::encode(&key.fvek), c.reset);
println!(
" Cipher : {}{}{}",
c.yellow, key.cipher, c.reset
);
println!(
" FVEK : {}{}{}",
c.yellow,
hex::encode(&key.fvek),
c.reset
);
if !key.tweak.is_empty() {
println!(" Tweak : {}{}{}", c.yellow, hex::encode(&key.tweak), c.reset);
println!(
" Tweak : {}{}{}",
c.yellow,
hex::encode(&key.tweak),
c.reset
);
}
println!(" Pool tag: {} @ 0x{:x}", key.pool_tag, key.phys_addr);
println!(
@@ -2475,6 +2573,59 @@ fn extract_and_output_bitlocker<L: PhysicalMemory>(layer: &L, args: &Args) {
if let Some(dir) = &args.bitlocker_fvek {
export_bitlocker_fvek(&keys, dir);
}
keys
}
/// Extract BitLocker FVEK keys from a memory snapshot file (without full LSASS extraction).
///
/// Opens the snapshot in the appropriate format, scans physical memory for
/// pool tags, and returns any FVEK candidates found.
#[cfg(any(
feature = "vmware",
feature = "vbox",
feature = "qemu",
feature = "hyperv"
))]
fn extract_bitlocker_keys_from_snapshot(
path: &Path,
) -> Vec<vmkatz::lsass::bitlocker::BitLockerKey> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let format = detect_lsass_format(path, ext, false);
// Open the memory layer and extract BitLocker keys
macro_rules! try_layer {
($make_layer:expr) => {
match $make_layer {
Ok(layer) => {
return vmkatz::lsass::bitlocker::extract_bitlocker_keys(&layer);
}
Err(e) => {
log::info!("BitLocker key extraction: failed to open {}: {}", path.display(), e);
return Vec::new();
}
}
};
}
match format {
#[cfg(feature = "vbox")]
LsassFormat::VBox => try_layer!(VBoxLayer::open(path)),
#[cfg(feature = "qemu")]
LsassFormat::QemuElf => try_layer!(QemuElfLayer::open(path)),
#[cfg(feature = "qemu")]
LsassFormat::QemuSavevm => try_layer!(vmkatz::qemu::QemuSavevmLayer::open(path)),
#[cfg(feature = "hyperv")]
LsassFormat::HypervBin => try_layer!(HypervLayer::open(path)),
#[cfg(feature = "hyperv")]
LsassFormat::HypervVmrs => try_layer!(vmkatz::hyperv::VmrsLayer::open(path)),
#[cfg(feature = "vmware")]
LsassFormat::Vmware => try_layer!(VmwareLayer::open(path)),
_ => Vec::new(),
}
}
/// Write dislocker-compatible FVEK files to disk.
+315
View File
@@ -0,0 +1,315 @@
//! AES-XTS sector-level decryption for BitLocker volume decryption.
//!
//! Implements AES-XTS-128 and AES-XTS-256 as used by BitLocker (Win8+).
//! Uses raw AES ECB operations from the `aes` crate — not CBC wrappers.
use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit};
use aes::{Aes128, Aes256};
use crate::error::{Result, VmkatzError};
/// Sector size for BitLocker XTS decryption.
const SECTOR_SIZE: usize = 512;
/// AES block size.
const BLOCK_SIZE: usize = 16;
/// Multiply tweak by x in GF(2^128) (little-endian representation).
///
/// This is the standard XTS tweak update: shift left by 1 bit,
/// and if the carry bit was set, XOR with the reduction polynomial 0x87.
pub(crate) fn gf128_mul_x(tweak: &mut [u8; 16]) {
let mut carry = 0u8;
for byte in tweak.iter_mut() {
let new_carry = *byte >> 7;
*byte = (*byte << 1) | carry;
carry = new_carry;
}
if carry != 0 {
tweak[0] ^= 0x87;
}
}
/// Decrypt a single 512-byte sector using AES-XTS.
///
/// `key` must be 32 bytes (AES-128-XTS: two 16-byte keys) or
/// 64 bytes (AES-256-XTS: two 32-byte keys).
///
/// `sector` must be exactly 512 bytes and is decrypted in place.
///
/// `sector_number` is the sector index used to derive the XTS tweak value.
pub fn aes_xts_decrypt_sector(key: &[u8], sector: &mut [u8], sector_number: u64) -> Result<()> {
if sector.len() != SECTOR_SIZE {
return Err(VmkatzError::DecryptionError(format!(
"AES-XTS: sector size must be {} bytes, got {}",
SECTOR_SIZE,
sector.len()
)));
}
match key.len() {
32 => xts_decrypt_128(key, sector, sector_number),
64 => xts_decrypt_256(key, sector, sector_number),
n => Err(VmkatzError::DecryptionError(format!(
"AES-XTS: key must be 32 or 64 bytes, got {}",
n
))),
}
}
/// AES-128-XTS decryption: key1 and key2 are each 16 bytes.
fn xts_decrypt_128(key: &[u8], sector: &mut [u8], sector_number: u64) -> Result<()> {
let key1 = &key[..16];
let key2 = &key[16..32];
let cipher1 = Aes128::new(GenericArray::from_slice(key1));
let cipher2 = Aes128::new(GenericArray::from_slice(key2));
xts_decrypt_inner(&cipher1, &cipher2, sector, sector_number)
}
/// AES-256-XTS decryption: key1 and key2 are each 32 bytes.
fn xts_decrypt_256(key: &[u8], sector: &mut [u8], sector_number: u64) -> Result<()> {
let key1 = &key[..32];
let key2 = &key[32..64];
let cipher1 = Aes256::new(GenericArray::from_slice(key1));
let cipher2 = Aes256::new(GenericArray::from_slice(key2));
xts_decrypt_inner(&cipher1, &cipher2, sector, sector_number)
}
/// Generic XTS decryption core that works with any AES key size.
fn xts_decrypt_inner<C1, C2>(
cipher1: &C1,
cipher2: &C2,
sector: &mut [u8],
sector_number: u64,
) -> Result<()>
where
C1: BlockDecrypt,
C2: BlockEncrypt,
{
// Step 1: Encrypt the sector number as the initial tweak
let mut tweak = [0u8; 16];
tweak[..8].copy_from_slice(&sector_number.to_le_bytes());
let mut tweak_block = GenericArray::clone_from_slice(&tweak);
cipher2.encrypt_block(&mut tweak_block);
tweak.copy_from_slice(&tweak_block);
// Step 2: Decrypt each 16-byte block with XTS
let num_blocks = SECTOR_SIZE / BLOCK_SIZE;
for i in 0..num_blocks {
let offset = i * BLOCK_SIZE;
let block = &mut sector[offset..offset + BLOCK_SIZE];
// XOR with tweak (pre-decrypt)
for (b, t) in block.iter_mut().zip(tweak.iter()) {
*b ^= *t;
}
// AES decrypt
let mut aes_block = GenericArray::clone_from_slice(block);
cipher1.decrypt_block(&mut aes_block);
block.copy_from_slice(&aes_block);
// XOR with tweak (post-decrypt)
for (b, t) in block.iter_mut().zip(tweak.iter()) {
*b ^= *t;
}
// Advance tweak for next block
gf128_mul_x(&mut tweak);
}
Ok(())
}
/// Decrypt a buffer of contiguous sectors using AES-XTS.
///
/// `data` length must be a multiple of 512. Sectors are numbered sequentially
/// starting from `first_sector_number`.
pub fn aes_xts_decrypt_sectors(
key: &[u8],
data: &mut [u8],
first_sector_number: u64,
) -> Result<()> {
if !data.len().is_multiple_of(SECTOR_SIZE) {
return Err(VmkatzError::DecryptionError(format!(
"AES-XTS: data length {} is not a multiple of sector size {}",
data.len(),
SECTOR_SIZE
)));
}
let num_sectors = data.len() / SECTOR_SIZE;
for i in 0..num_sectors {
let offset = i * SECTOR_SIZE;
let sector = &mut data[offset..offset + SECTOR_SIZE];
aes_xts_decrypt_sector(key, sector, first_sector_number + i as u64)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gf128_mul_x_no_carry() {
let mut tweak = [0u8; 16];
tweak[0] = 0x01; // x^0
gf128_mul_x(&mut tweak);
assert_eq!(tweak[0], 0x02); // x^1
}
#[test]
fn test_gf128_mul_x_with_carry() {
let mut tweak = [0u8; 16];
tweak[15] = 0x80; // MSB set = carry
gf128_mul_x(&mut tweak);
// After shift: all zeros. Carry set, so XOR 0x87 into byte[0]
assert_eq!(tweak[0], 0x87);
assert_eq!(tweak[15], 0x00);
}
#[test]
fn test_xts_decrypt_wrong_sector_size() {
let key = [0u8; 32];
let mut sector = [0u8; 256]; // wrong size
let result = aes_xts_decrypt_sector(&key, &mut sector, 0);
assert!(result.is_err());
}
#[test]
fn test_xts_decrypt_wrong_key_size() {
let key = [0u8; 48]; // invalid
let mut sector = [0u8; 512];
let result = aes_xts_decrypt_sector(&key, &mut sector, 0);
assert!(result.is_err());
}
#[test]
fn test_xts_roundtrip_128() {
// Encrypt then decrypt should give back the original plaintext.
// We'll encrypt manually and then verify decryption.
let key = [0x42u8; 32];
let plaintext = [0xABu8; 512];
// Encrypt: same as decrypt but use BlockEncrypt for cipher1
let key1 = &key[..16];
let key2 = &key[16..32];
let cipher1 = Aes128::new(GenericArray::from_slice(key1));
let cipher2 = Aes128::new(GenericArray::from_slice(key2));
let sector_number: u64 = 42;
let mut tweak = [0u8; 16];
tweak[..8].copy_from_slice(&sector_number.to_le_bytes());
let mut tweak_block = GenericArray::clone_from_slice(&tweak);
cipher2.encrypt_block(&mut tweak_block);
tweak.copy_from_slice(&tweak_block);
let mut ciphertext = plaintext;
for i in 0..(512 / 16) {
let offset = i * 16;
let block = &mut ciphertext[offset..offset + 16];
for (b, t) in block.iter_mut().zip(tweak.iter()) {
*b ^= *t;
}
let mut aes_block = GenericArray::clone_from_slice(block);
cipher1.encrypt_block(&mut aes_block);
block.copy_from_slice(&aes_block);
for (b, t) in block.iter_mut().zip(tweak.iter()) {
*b ^= *t;
}
gf128_mul_x(&mut tweak);
}
// Now decrypt and verify
let mut decrypted = ciphertext;
aes_xts_decrypt_sector(&key, &mut decrypted, sector_number).unwrap();
assert_eq!(&decrypted[..], &plaintext[..]);
}
#[test]
fn test_xts_roundtrip_256() {
let key = [0x55u8; 64];
let plaintext = [0xCDu8; 512];
let key1 = &key[..32];
let key2 = &key[32..64];
let cipher1 = Aes256::new(GenericArray::from_slice(key1));
let cipher2 = Aes256::new(GenericArray::from_slice(key2));
let sector_number: u64 = 100;
let mut tweak = [0u8; 16];
tweak[..8].copy_from_slice(&sector_number.to_le_bytes());
let mut tweak_block = GenericArray::clone_from_slice(&tweak);
cipher2.encrypt_block(&mut tweak_block);
tweak.copy_from_slice(&tweak_block);
let mut ciphertext = plaintext;
for i in 0..(512 / 16) {
let offset = i * 16;
let block = &mut ciphertext[offset..offset + 16];
for (b, t) in block.iter_mut().zip(tweak.iter()) {
*b ^= *t;
}
let mut aes_block = GenericArray::clone_from_slice(block);
cipher1.encrypt_block(&mut aes_block);
block.copy_from_slice(&aes_block);
for (b, t) in block.iter_mut().zip(tweak.iter()) {
*b ^= *t;
}
gf128_mul_x(&mut tweak);
}
let mut decrypted = ciphertext;
aes_xts_decrypt_sector(&key, &mut decrypted, sector_number).unwrap();
assert_eq!(&decrypted[..], &plaintext[..]);
}
#[test]
fn test_xts_multi_sector() {
let key = [0x37u8; 32];
let plaintext = [0x99u8; 1024]; // 2 sectors
// Encrypt both sectors
let mut ciphertext = plaintext;
for s in 0..2u64 {
let key1 = &key[..16];
let key2 = &key[16..32];
let cipher1 = Aes128::new(GenericArray::from_slice(key1));
let cipher2 = Aes128::new(GenericArray::from_slice(key2));
let mut tweak = [0u8; 16];
tweak[..8].copy_from_slice(&s.to_le_bytes());
let mut tweak_block = GenericArray::clone_from_slice(&tweak);
cipher2.encrypt_block(&mut tweak_block);
tweak.copy_from_slice(&tweak_block);
let sector = &mut ciphertext[(s as usize * 512)..((s as usize + 1) * 512)];
for i in 0..(512 / 16) {
let offset = i * 16;
let block = &mut sector[offset..offset + 16];
for (b, t) in block.iter_mut().zip(tweak.iter()) {
*b ^= *t;
}
let mut aes_block = GenericArray::clone_from_slice(block);
cipher1.encrypt_block(&mut aes_block);
block.copy_from_slice(&aes_block);
for (b, t) in block.iter_mut().zip(tweak.iter()) {
*b ^= *t;
}
gf128_mul_x(&mut tweak);
}
}
let mut decrypted = ciphertext;
aes_xts_decrypt_sectors(&key, &mut decrypted, 0).unwrap();
assert_eq!(&decrypted[..], &plaintext[..]);
}
}
+361
View File
@@ -0,0 +1,361 @@
//! BitLocker transparent decryption wrapper.
//!
//! Wraps a `Read + Seek` disk reader and transparently decrypts BitLocker-encrypted
//! sectors using an FVEK extracted from memory. The decrypted stream appears as a
//! plain NTFS volume to downstream parsers (NTFS hive reader, etc.).
//!
//! Supports AES-XTS-128 (method 0x8004) and AES-XTS-256 (method 0x8005).
use std::io::{self, Read, Seek, SeekFrom};
use super::aes_xts;
/// Sector size for BitLocker decryption.
const SECTOR_SIZE: u64 = 512;
/// A decrypting reader that transparently decrypts BitLocker-encrypted sectors.
///
/// Reads from the underlying reader at `partition_offset + position`, decrypts
/// each 512-byte sector with AES-XTS, and presents a plain-text view.
pub struct BitLockerReader<R: Read + Seek> {
inner: R,
/// Byte offset of the BitLocker partition on the disk image.
partition_offset: u64,
/// Full XTS key (32 bytes for AES-128-XTS, 64 bytes for AES-256-XTS).
xts_key: Vec<u8>,
/// Current read position relative to partition start.
position: u64,
}
impl<R: Read + Seek> BitLockerReader<R> {
/// Create a new BitLocker decrypting reader.
///
/// `partition_offset` is the byte offset of the encrypted partition on disk.
/// `xts_key` is the full AES-XTS key (key1 || key2): 32 bytes for XTS-128, 64 for XTS-256.
pub fn new(inner: R, partition_offset: u64, xts_key: Vec<u8>) -> Self {
Self {
inner,
partition_offset,
xts_key,
position: 0,
}
}
/// Try to decrypt sector 0 and check for NTFS signature at offset 3.
///
/// Returns true if the decrypted first sector starts with "NTFS" at byte 3,
/// indicating a valid NTFS boot sector and correct FVEK.
pub fn validate_ntfs_signature(&mut self) -> bool {
let mut sector = [0u8; SECTOR_SIZE as usize];
// Read encrypted sector 0
if self.inner.seek(SeekFrom::Start(self.partition_offset)).is_err() {
return false;
}
if self.inner.read_exact(&mut sector).is_err() {
return false;
}
// Decrypt sector 0 (sector number = 0)
if aes_xts::aes_xts_decrypt_sector(&self.xts_key, &mut sector, 0).is_err() {
return false;
}
// Check for "NTFS" OEM ID at offset 3 in the NTFS boot sector
sector.get(3..7) == Some(b"NTFS")
}
}
impl<R: Read + Seek> Read for BitLockerReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
let mut total_read = 0usize;
while total_read < buf.len() {
let current_pos = self.position;
let sector_number = current_pos / SECTOR_SIZE;
let offset_in_sector = (current_pos % SECTOR_SIZE) as usize;
// Read the full encrypted sector from the underlying reader
let disk_offset = self.partition_offset + sector_number * SECTOR_SIZE;
self.inner.seek(SeekFrom::Start(disk_offset)).map_err(|e| {
io::Error::other(
format!("BitLocker seek to sector {}: {}", sector_number, e),
)
})?;
let mut sector_buf = [0u8; SECTOR_SIZE as usize];
match self.inner.read_exact(&mut sector_buf) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof && total_read > 0 => {
// Partial read at end of volume
break;
}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
return Ok(0); // EOF
}
Err(e) => return Err(e),
}
// Decrypt the sector
aes_xts::aes_xts_decrypt_sector(&self.xts_key, &mut sector_buf, sector_number)
.map_err(|e| {
io::Error::other(
format!("BitLocker XTS decrypt sector {}: {}", sector_number, e),
)
})?;
// Copy the relevant portion to the output buffer
let available = SECTOR_SIZE as usize - offset_in_sector;
let remaining = buf.len() - total_read;
let to_copy = available.min(remaining);
buf[total_read..total_read + to_copy]
.copy_from_slice(&sector_buf[offset_in_sector..offset_in_sector + to_copy]);
total_read += to_copy;
self.position += to_copy as u64;
}
Ok(total_read)
}
}
impl<R: Read + Seek> Seek for BitLockerReader<R> {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
let new_position = match pos {
SeekFrom::Start(offset) => offset,
SeekFrom::Current(delta) => {
if delta >= 0 {
self.position.checked_add(delta as u64)
} else {
self.position.checked_sub((-delta) as u64)
}
.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "Seek position overflow")
})?
}
SeekFrom::End(_) => {
// We don't know the partition size easily, so pass through to inner.
// This is rarely used by NTFS parsers (they use SeekFrom::Start).
let abs = self.inner.seek(pos)?;
let relative = abs.saturating_sub(self.partition_offset);
self.position = relative;
return Ok(relative);
}
};
self.position = new_position;
Ok(new_position)
}
}
/// Build the full AES-XTS key from a `BitLockerKey`.
///
/// For XTS modes (0x8004, 0x8005), the key is key1 || key2 where:
/// - key1 = data encryption key
/// - key2 = tweak encryption key
///
/// The Cngb extraction stores only one AES key in `fvek` (the two internal
/// copies are validated to be equal). For XTS we need both halves, so this
/// function tries multiple strategies:
/// 1. If fvek is already the full size (32/64 bytes), use as-is
/// 2. If fvek is half size (16/32 bytes), duplicate it (fvek || fvek)
///
/// Returns `None` for unsupported encryption methods (Diffuser/CBC).
pub fn build_xts_key(key: &crate::lsass::bitlocker::BitLockerKey) -> Option<Vec<u8>> {
let expected_full_len = match key.method {
0x8004 => 32, // AES-128-XTS: 2 x 16 bytes
0x8005 => 64, // AES-256-XTS: 2 x 32 bytes
_ => return None, // CBC/Diffuser modes not yet supported
};
let expected_half = expected_full_len / 2;
if key.fvek.len() == expected_full_len {
// Already the full XTS key
Some(key.fvek.clone())
} else if key.fvek.len() == expected_half {
// Half key — duplicate for both data and tweak
// This is common with Cngb extraction where only one AES key is captured.
// The real tweak key may differ; the caller should validate with NTFS signature.
let mut full = Vec::with_capacity(expected_full_len);
full.extend_from_slice(&key.fvek);
full.extend_from_slice(&key.fvek);
Some(full)
} else {
log::warn!(
"BitLocker: unexpected FVEK length {} for method 0x{:04x} (expected {} or {})",
key.fvek.len(),
key.method,
expected_half,
expected_full_len,
);
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
/// Create a fake "encrypted" volume by XTS-encrypting known plaintext.
fn make_encrypted_ntfs_volume(key: &[u8], partition_offset: u64) -> Vec<u8> {
use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockEncrypt, KeyInit};
use aes::Aes128;
// Create a 2-sector volume with NTFS signature
let mut plaintext = vec![0u8; 1024];
// NTFS boot sector: "NTFS" at offset 3
plaintext[0] = 0xEB; // JMP short
plaintext[1] = 0x52;
plaintext[2] = 0x90;
plaintext[3..7].copy_from_slice(b"NTFS");
plaintext[7] = 0x20; // space
// Fill rest with recognizable pattern
for (i, byte) in plaintext[8..512].iter_mut().enumerate() {
*byte = ((i + 8) & 0xFF) as u8;
}
for (i, byte) in plaintext[512..1024].iter_mut().enumerate() {
*byte = ((i + 512 + 0x55) & 0xFF) as u8;
}
// Encrypt each sector with AES-XTS
let key1 = &key[..16];
let key2 = &key[16..32];
let cipher1 = Aes128::new(GenericArray::from_slice(key1));
let cipher2 = Aes128::new(GenericArray::from_slice(key2));
let mut encrypted = plaintext.clone();
for s in 0..2u64 {
let sector = &mut encrypted[(s as usize * 512)..((s as usize + 1) * 512)];
let mut tweak = [0u8; 16];
tweak[..8].copy_from_slice(&s.to_le_bytes());
let mut tweak_block = GenericArray::clone_from_slice(&tweak);
cipher2.encrypt_block(&mut tweak_block);
tweak.copy_from_slice(&tweak_block);
for i in 0..(512 / 16) {
let offset = i * 16;
let block = &mut sector[offset..offset + 16];
for (b, t) in block.iter_mut().zip(tweak.iter()) {
*b ^= *t;
}
let mut aes_block = GenericArray::clone_from_slice(block);
cipher1.encrypt_block(&mut aes_block);
block.copy_from_slice(&aes_block);
for (b, t) in block.iter_mut().zip(tweak.iter()) {
*b ^= *t;
}
super::super::aes_xts::gf128_mul_x(&mut tweak);
}
}
// Build disk image: padding + encrypted partition
let mut disk = vec![0u8; partition_offset as usize];
disk.extend_from_slice(&encrypted);
disk
}
#[test]
fn test_bitlocker_reader_validates_ntfs() {
let key = [0x42u8; 32]; // AES-128-XTS key
let partition_offset = 1048576u64; // 1 MB
let disk = make_encrypted_ntfs_volume(&key, partition_offset);
let cursor = Cursor::new(disk);
let mut reader = BitLockerReader::new(cursor, partition_offset, key.to_vec());
assert!(reader.validate_ntfs_signature());
}
#[test]
fn test_bitlocker_reader_rejects_wrong_key() {
let key = [0x42u8; 32];
let partition_offset = 1048576u64;
let disk = make_encrypted_ntfs_volume(&key, partition_offset);
let cursor = Cursor::new(disk);
let wrong_key = [0x99u8; 32];
let mut reader = BitLockerReader::new(cursor, partition_offset, wrong_key.to_vec());
assert!(!reader.validate_ntfs_signature());
}
#[test]
fn test_bitlocker_reader_read_and_seek() {
use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockEncrypt, KeyInit};
use aes::Aes128;
let key = [0x42u8; 32];
let partition_offset = 512u64; // Small offset for simplicity
// Create plaintext
let mut plaintext = vec![0u8; 1024];
for (i, byte) in plaintext.iter_mut().enumerate() {
*byte = (i & 0xFF) as u8;
}
plaintext[3..7].copy_from_slice(b"NTFS");
// Encrypt
let key1 = &key[..16];
let key2 = &key[16..32];
let cipher1 = Aes128::new(GenericArray::from_slice(key1));
let cipher2 = Aes128::new(GenericArray::from_slice(key2));
let mut encrypted = plaintext.clone();
for s in 0..2u64 {
let sector = &mut encrypted[(s as usize * 512)..((s as usize + 1) * 512)];
let mut tweak = [0u8; 16];
tweak[..8].copy_from_slice(&s.to_le_bytes());
let mut tweak_block = GenericArray::clone_from_slice(&tweak);
cipher2.encrypt_block(&mut tweak_block);
tweak.copy_from_slice(&tweak_block);
for i in 0..(512 / 16) {
let off = i * 16;
let block = &mut sector[off..off + 16];
for (b, t) in block.iter_mut().zip(tweak.iter()) {
*b ^= *t;
}
let mut aes_block = GenericArray::clone_from_slice(block);
cipher1.encrypt_block(&mut aes_block);
block.copy_from_slice(&aes_block);
for (b, t) in block.iter_mut().zip(tweak.iter()) {
*b ^= *t;
}
super::super::aes_xts::gf128_mul_x(&mut tweak);
}
}
let mut disk = vec![0u8; partition_offset as usize];
disk.extend_from_slice(&encrypted);
let cursor = Cursor::new(disk);
let mut reader = BitLockerReader::new(cursor, partition_offset, key.to_vec());
// Read first 16 bytes
let mut buf = [0u8; 16];
reader.read_exact(&mut buf).unwrap();
assert_eq!(&buf[3..7], b"NTFS");
assert_eq!(&buf[..3], &plaintext[..3]);
// Seek to sector 1 start and read
reader.seek(SeekFrom::Start(512)).unwrap();
let mut buf2 = [0u8; 16];
reader.read_exact(&mut buf2).unwrap();
assert_eq!(&buf2, &plaintext[512..528]);
// Seek back to start
reader.seek(SeekFrom::Start(0)).unwrap();
let mut full = vec![0u8; 1024];
reader.read_exact(&mut full).unwrap();
assert_eq!(&full, &plaintext);
}
}
+196
View File
@@ -9,6 +9,8 @@ mod ntfs_reader;
mod disk_fallbacks;
mod vmdk_scan;
pub mod aes_xts;
pub mod bitlocker_decrypt;
pub mod dpapi_masterkey;
// Re-export pub(crate) items used by other modules (paging/pagefile, paging/filebacked)
@@ -85,6 +87,200 @@ pub fn extract_ntds_artifacts(path: &Path) -> Result<NtdsArtifacts> {
extract_ntds_artifacts_from_reader(&mut disk)
}
/// Extract secrets from a disk, trying FVEK keys for BitLocker partitions.
///
/// When BitLocker-encrypted partitions are detected and FVEK candidates are
/// available, each candidate is tried until one successfully decrypts a valid
/// NTFS volume. Falls back to standard (non-BitLocker) extraction if no
/// encrypted partition is found or no key works.
pub fn extract_disk_secrets_with_bitlocker(
path: &Path,
fvek_keys: &[crate::lsass::bitlocker::BitLockerKey],
) -> Result<DiskSecrets> {
let mut disk = crate::disk::open_disk(path)?;
extract_secrets_with_bitlocker(&mut disk, fvek_keys)
}
/// Extract NTDS artifacts from a disk, trying FVEK keys for BitLocker partitions.
pub fn extract_ntds_artifacts_with_bitlocker(
path: &Path,
fvek_keys: &[crate::lsass::bitlocker::BitLockerKey],
) -> Result<NtdsArtifacts> {
let mut disk = crate::disk::open_disk(path)?;
extract_ntds_with_bitlocker(&mut disk, fvek_keys)
}
/// Core BitLocker-aware extraction for SAM/LSA secrets.
fn extract_secrets_with_bitlocker<R: Read + Seek>(
reader: &mut R,
fvek_keys: &[crate::lsass::bitlocker::BitLockerKey],
) -> Result<DiskSecrets> {
let partitions = find_ntfs_partitions(reader).unwrap_or_default();
for &partition_offset in &partitions {
// Try unencrypted first
if !is_bitlocker_partition(reader, partition_offset) {
log::info!("Trying NTFS partition at offset 0x{:x}", partition_offset);
match ntfs_reader::read_hive_files(reader, partition_offset) {
Ok((sam_data, system_data, security_data)) => {
return process_hive_data(sam_data, system_data, security_data);
}
Err(e) => {
log::info!("Partition at 0x{:x}: {}", partition_offset, e);
}
}
continue;
}
// BitLocker-encrypted partition — try each FVEK candidate
eprintln!(
"[*] BitLocker partition at 0x{:x} — trying {} FVEK candidate(s)",
partition_offset,
fvek_keys.len()
);
if let Some(secrets) = try_bitlocker_fvek_candidates(
reader,
partition_offset,
fvek_keys,
|bl_reader, _offset| {
ntfs_reader::read_hive_files(bl_reader, 0)
.and_then(|(sam, sys, sec)| process_hive_data(sam, sys, sec))
},
) {
return Ok(secrets);
}
}
Err(crate::error::VmkatzError::DecryptionError(
"No secrets found (BitLocker-aware scan exhausted all partitions and FVEK candidates)"
.to_string(),
))
}
/// Core BitLocker-aware extraction for NTDS artifacts.
fn extract_ntds_with_bitlocker<R: Read + Seek>(
reader: &mut R,
fvek_keys: &[crate::lsass::bitlocker::BitLockerKey],
) -> Result<NtdsArtifacts> {
let partitions = find_ntfs_partitions(reader).unwrap_or_default();
for &partition_offset in &partitions {
if !is_bitlocker_partition(reader, partition_offset) {
log::info!(
"Trying NTDS on NTFS partition at offset 0x{:x}",
partition_offset
);
match ntfs_reader::read_ntds_artifacts(reader, partition_offset) {
Ok((ntds_data, system_data)) => {
return Ok(NtdsArtifacts {
ntds_data,
system_data,
partition_offset,
});
}
Err(e) => {
log::info!("Partition at 0x{:x}: {}", partition_offset, e);
}
}
continue;
}
eprintln!(
"[*] BitLocker partition at 0x{:x} — trying {} FVEK candidate(s) for NTDS",
partition_offset,
fvek_keys.len()
);
if let Some(artifacts) = try_bitlocker_fvek_candidates(
reader,
partition_offset,
fvek_keys,
|bl_reader, part_off| {
ntfs_reader::read_ntds_artifacts(bl_reader, 0).map(|(ntds_data, system_data)| {
NtdsArtifacts {
ntds_data,
system_data,
partition_offset: part_off,
}
})
},
) {
return Ok(artifacts);
}
}
Err(crate::error::VmkatzError::DecryptionError(
"NTDS.dit not found (BitLocker-aware scan exhausted)".to_string(),
))
}
/// Try each FVEK candidate on a BitLocker-encrypted partition.
///
/// For each candidate, validates the NTFS signature after decryption,
/// then calls `extract_fn` to perform the actual extraction.
/// Returns the first successful result, or `None` if no key works.
fn try_bitlocker_fvek_candidates<R, T, F>(
reader: &mut R,
partition_offset: u64,
fvek_keys: &[crate::lsass::bitlocker::BitLockerKey],
extract_fn: F,
) -> Option<T>
where
R: Read + Seek,
F: Fn(&mut bitlocker_decrypt::BitLockerReader<&mut R>, u64) -> Result<T>,
{
for (i, key) in fvek_keys.iter().enumerate() {
let xts_key = match bitlocker_decrypt::build_xts_key(key) {
Some(k) => k,
None => {
log::info!(
"BitLocker: skipping FVEK #{} — unsupported method 0x{:04x} ({})",
i,
key.method,
key.cipher
);
continue;
}
};
// Validate by decrypting sector 0 and checking NTFS signature
let mut bl_reader =
bitlocker_decrypt::BitLockerReader::new(&mut *reader, partition_offset, xts_key.clone());
if !bl_reader.validate_ntfs_signature() {
log::info!(
"BitLocker: FVEK #{} ({}) — NTFS signature mismatch after decryption",
i,
key.cipher
);
continue;
}
eprintln!(
"[+] BitLocker: FVEK #{} ({}) unlocked partition at 0x{:x}",
i, key.cipher, partition_offset
);
// Re-create reader (validate consumed a seek) and extract
let mut bl_reader =
bitlocker_decrypt::BitLockerReader::new(&mut *reader, partition_offset, xts_key);
match extract_fn(&mut bl_reader, partition_offset) {
Ok(result) => return Some(result),
Err(e) => {
log::warn!(
"BitLocker: FVEK #{} decrypted NTFS header but extraction failed: {}",
i,
e
);
}
}
}
None
}
/// Extract both SAM hashes and LSA secrets from a disk image.
pub fn extract_disk_secrets(path: &Path) -> Result<DiskSecrets> {
let mut disk = crate::disk::open_disk(path)?;