Fix DCC2 key offset, GMSA display, and add block device support

- Fix DCC2 cached credentials: use NL$KM[0:16] as AES key instead of
  [16:32]. The secret is already stripped of its LSA_SECRET_BLOB header,
  so [16:32] was a double offset producing garbage decryption.
- Fix GMSA secrets: display managed service account passwords as hex
  instead of trying to decode binary data as UTF-16LE.
- Add raw block device support for LVM thin volumes (/dev/pve/...).
  Auto-detect block devices and route to SAM extraction. Use seek-to-end
  for device size since metadata().len() returns 0 for block devices.
- Update README with Proxmox LVM test results and Server 2025 support.
This commit is contained in:
NK
2026-02-24 23:02:12 +01:00
parent 396817c594
commit b241b4e805
6 changed files with 56 additions and 29 deletions
+6 -2
View File
@@ -54,9 +54,10 @@ All 9 SSP credential providers that mimikatz implements:
| VirtualBox virtual disks | `.vdi` | VirtualBox |
| QEMU/KVM virtual disks | `.qcow2` | QEMU, Proxmox |
| Hyper-V virtual disks | `.vhdx`, `.vhd` | Hyper-V |
| LVM block devices | `/dev/...` | Proxmox LVM-thin, raw LVs |
| VM directories | any folder | Auto-discovers all processable files |
**Target OS**: Windows 7 SP1 through Windows 11 x64 (auto-detected).
**Target OS**: Windows 7 SP1 through Windows Server 2025 x64 (auto-detected).
## Quick Start
@@ -200,7 +201,7 @@ cargo build --release --no-default-features --features "sam ntds.dit"
## Tested Targets
20 VMs tested across 6 Windows versions and 3 hypervisors.
Tested across 7 Windows versions and 4 hypervisors.
| Hypervisor | Guest OS | Artifact | Result | Notes |
| --- | --- | --- | --- | --- |
@@ -219,6 +220,9 @@ cargo build --release --no-default-features --features "sam ntds.dit"
| ESXi 8.0 | Windows 11 x64 | LSASS (`.vmsn`) | PASS | 2 VMs, no VBS |
| ESXi 8.0 | Windows 11 x64 | SAM (flat `.vmdk`) | PASS | Powered-off VM |
| ESXi 8.0 | Windows 11 x64 (VBS) | LSASS (`.vmsn`) | FAIL | Credential Guard / VBS |
| Proxmox 8 | Windows Server 2016 x64 | SAM / LSA / DCC2 (LVM block device) | PASS | Live + stopped VMs |
| Proxmox 8 | Windows Server 2019 x64 | SAM / LSA / DCC2 (LVM block device) | PASS | 3 VMs, incl. DCs |
| Proxmox 8 | Windows Server 2025 x64 | SAM / LSA (LVM block device) | PASS | Template VM |
### Known limitations
- **VBS / Credential Guard**: VMs with Virtualization-Based Security enabled use nested Hyper-V page tables. The VMEM captured by ESXi is 99% zero pages because the actual kernel memory is behind Hyper-V's SLAT. An EPT walker is implemented but cannot yet recover credentials from these VMs. SAM extraction from the virtual disk still works when the VM is powered off.
+6 -5
View File
@@ -25,7 +25,7 @@ pub fn open_disk(path: &Path) -> Result<Box<dyn DiskImage>> {
.to_lowercase();
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
// Detect flat VMDK (e.g., "name-flat.vmdk") — raw disk, not sparse
// Detect flat VMDK (e.g., "name-flat.vmdk") - raw disk, not sparse
if ext == "vmdk" && stem.ends_with("-flat") {
let disk = raw::RawDisk::open(path)?;
return Ok(Box::new(disk));
@@ -56,9 +56,10 @@ pub fn open_disk(path: &Path) -> Result<Box<dyn DiskImage>> {
let disk = raw::RawDisk::open(path)?;
Ok(Box::new(disk))
}
_ => Err(crate::error::GovmemError::ProcessNotFound(format!(
"Unsupported disk format: .{}",
ext
))),
_ => {
// No recognized extension - try as raw disk (block devices, etc.)
let disk = raw::RawDisk::open(path)?;
Ok(Box::new(disk))
}
}
}
+7 -4
View File
@@ -5,7 +5,8 @@ use std::path::Path;
use crate::error::{GovmemError, Result};
/// Raw flat disk image — no container format, just raw sectors.
/// Handles flat VMDKs (`-flat.vmdk`), raw dumps (`.raw`, `.img`, `.dd`).
/// Handles flat VMDKs (`-flat.vmdk`), raw dumps (`.raw`, `.img`, `.dd`),
/// and block devices (`/dev/...`).
pub struct RawDisk {
file: File,
size: u64,
@@ -13,11 +14,13 @@ pub struct RawDisk {
impl RawDisk {
pub fn open(path: &Path) -> Result<Self> {
let file = File::open(path).map_err(GovmemError::Io)?;
let size = file.metadata().map_err(GovmemError::Io)?.len();
let mut file = File::open(path).map_err(GovmemError::Io)?;
// metadata().len() returns 0 for block devices, so seek to end instead
let size = file.seek(SeekFrom::End(0)).map_err(GovmemError::Io)?;
file.seek(SeekFrom::Start(0)).map_err(GovmemError::Io)?;
log::info!(
"Raw disk: {} ({} MB)",
path.file_name().unwrap_or_default().to_string_lossy(),
path.display(),
size / (1024 * 1024)
);
Ok(Self { file, size })
+26 -16
View File
@@ -150,30 +150,24 @@ fn main() -> anyhow::Result<()> {
return run_directory(input_path, &args);
}
// Auto-detect SAM mode for disk images, or explicit --sam flag
// Auto-detect SAM mode for disk images / block devices, or explicit --sam flag
#[cfg(feature = "sam")]
{
let ext = input_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let is_disk_ext = ext.eq_ignore_ascii_case("vdi")
|| ext.eq_ignore_ascii_case("vmdk")
|| ext.eq_ignore_ascii_case("qcow2")
|| ext.eq_ignore_ascii_case("qcow")
|| ext.eq_ignore_ascii_case("vhdx")
|| ext.eq_ignore_ascii_case("vhd");
let is_block_device = is_block_dev(input_path);
#[cfg(feature = "ntds.dit")]
let sam_mode = args.sam
|| args.ntds
|| ext.eq_ignore_ascii_case("vdi")
|| ext.eq_ignore_ascii_case("vmdk")
|| ext.eq_ignore_ascii_case("qcow2")
|| ext.eq_ignore_ascii_case("qcow")
|| ext.eq_ignore_ascii_case("vhdx")
|| ext.eq_ignore_ascii_case("vhd");
let sam_mode = args.sam || args.ntds || is_disk_ext || is_block_device;
#[cfg(not(feature = "ntds.dit"))]
let sam_mode = args.sam
|| ext.eq_ignore_ascii_case("vdi")
|| ext.eq_ignore_ascii_case("vmdk")
|| ext.eq_ignore_ascii_case("qcow2")
|| ext.eq_ignore_ascii_case("qcow")
|| ext.eq_ignore_ascii_case("vhdx")
|| ext.eq_ignore_ascii_case("vhd");
let sam_mode = args.sam || is_disk_ext || is_block_device;
if sam_mode {
return run_sam(input_path, &args);
}
@@ -668,6 +662,22 @@ fn run_lsass(
}
}
/// Check if a path is a block device (Linux /dev/...).
fn is_block_dev(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
std::fs::metadata(path)
.map(|m| m.file_type().is_block_device())
.unwrap_or(false)
}
#[cfg(not(unix))]
{
let _ = path;
false
}
}
/// Format detection for LSASS memory snapshot files.
enum LsassFormat {
VBox,
+4 -2
View File
@@ -95,8 +95,10 @@ pub fn extract_cached_credentials(
log::info!("DCC2 iteration count: {}", iteration_count);
// AES key = NL$KM[16..32]
let aes_key = &nlkm_key[16..32];
// AES key = first 16 bytes of the NL$KM secret.
// Note: pypykatz/impacket use [16:32] on the RAW blob (including the 16-byte
// LSA_SECRET_BLOB header), which is equivalent to [0:16] on the stripped secret.
let aes_key = &nlkm_key[..16];
let mut credentials = Vec::new();
+7
View File
@@ -447,6 +447,13 @@ fn parse_secret(name: &str, data: &[u8]) -> LsaSecretType {
}
if let Some(service_name) = name.strip_prefix("_SC_") {
// GMSA managed passwords are binary blobs, not UTF-16LE text
if service_name.starts_with("GMSA_") || service_name.starts_with("GMSA{") {
return LsaSecretType::ServicePassword {
service: service_name.to_string(),
password: hex::encode(data),
};
}
let password = decode_utf16le(data);
return LsaSecretType::ServicePassword {
service: service_name.to_string(),