Windows locks executable files while they’re mapped. Try to delete C:\path\to\evil.exe while it’s running and you get ERROR_SHARING_VIOLATION. This is by design: the loader holds an open handle to the file’s section object, and the file system won’t delete a file with open handles against it.

Except it will, with the right flags.

The technique combines two primitives: NTFS alternate data streams (ADS) and a disposition flag introduced in Windows 10 RS1 (build 14393) called FILE_DISPOSITION_FLAG_POSIX_SEMANTICS. Neither is new. The combination isn’t widely documented in a self-contained form.

What the loader actually holds

When Windows maps an executable, the loader calls NtCreateSection with SEC_IMAGE against the file handle. The resulting section object holds a reference to the underlying NTFS data stream, not to the directory entry. The directory entry is just a name that points at a file record. The file record points at the data stream.

This distinction matters. If you remove the directory entry, the section object’s reference to the data remains valid. The process keeps running. The file is gone from the filesystem.

The loader’s section reference is to the data. The directory entry is expendable.

The four-step sequence

text
1. Open the executable with DELETE | SYNCHRONIZE
2. Rename :$DATA → :abandoned  (FileRenameInfo)
3. Mark for deletion            (FileDispositionInfoEx + POSIX_SEMANTICS)
4. CloseHandle

Step 2 moves the default data stream out of the directory entry’s primary stream slot. The directory entry still exists, but it now points at an ADS named :abandoned. Step 3 marks that ADS for immediate deletion. Step 4 closes the handle. NTFS removes the stream, then removes the now-empty file record, then removes the directory entry.

The section object the loader holds survives all of this. It’s reference-counted against the data, not the name.

The flag that matters

FILE_DISPOSITION_FLAG_DELETE alone marks deletion on last-handle-close. That’s the behaviour people expect, and it’s not what we want here. We want immediate unlinking from the directory.

FILE_DISPOSITION_FLAG_POSIX_SEMANTICS changes the semantics: the file is unlinked from the directory immediately when the call returns, while the handle is still open. POSIX-style unlink has worked this way since POSIX 1003.1. Windows added this flag in RS1 (Windows 10 version 1607, build 14393) per Microsoft’s documentation for FileDispositionInformationEx. Prior to that build, the flag is unsupported and SetFileInformationByHandle returns ERROR_INVALID_PARAMETER.

The combined flag value is 0x03:

c
#define FILE_DISPOSITION_FLAG_DO_NOT_DELETE              0x00000000
#define FILE_DISPOSITION_FLAG_DELETE                     0x00000001
#define FILE_DISPOSITION_FLAG_POSIX_SEMANTICS            0x00000002
#define FILE_DISPOSITION_FLAG_FORCE_IMAGE_SECTION_CHECK  0x00000004
#define FILE_DISPOSITION_FLAG_ON_CLOSE                   0x00000008
#define FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE  0x00000010

0x01 | 0x02 = 0x03. Pass FileDispositionInfoEx as the class. The older FileDispositionInfo with DeleteFile = TRUE only schedules deletion on close. Don’t mix them up.

The rename step

Renaming the stream requires FileRenameInfo (class 3) with a stream path in FileName. The stream path is the colon-prefixed name: :abandoned. No directory component. The RootDirectory field is NULL.

The FILE_RENAME_INFO structure changed in RS1. Pre-RS1, the first field is BOOLEAN ReplaceIfExists. Post-RS1, it’s a union with a DWORD Flags field in the same position. The Windows SDK ships FILE_RENAME_INFO with the union. If you’re targeting older toolchains, define a compatible structure manually.

One thing the original source gets wrong: it closes and re-opens the handle between rename and delete. That’s unnecessary. You can do both operations on the same handle. The code below does it correctly without the extra round-trip.

ADS name generation

The rename target needs to be a colon-prefixed stream name. BCryptGenRandom gives us 4 cryptographically random bytes and avoids the RDRAND availability question on VMs.

c
static FILE_RENAME_INFO *BuildRenameInfo(void)
{
    BYTE rnd[4] = {0};
    WCHAR streamName[16];

    if (!BCRYPT_SUCCESS(BCryptGenRandom(NULL, rnd, sizeof(rnd),
                                        BCRYPT_USE_SYSTEM_PREFERRED_RNG)))
        return NULL;

    _snwprintf_s(streamName, ARRAYSIZE(streamName), _TRUNCATE,
                 L":%02x%02x%02x%02x", rnd[0], rnd[1], rnd[2], rnd[3]);

    DWORD nameLen = (DWORD)(wcslen(streamName) * sizeof(WCHAR));
    SIZE_T bufLen = sizeof(FILE_RENAME_INFO) + nameLen;

    FILE_RENAME_INFO *ri = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, bufLen);
    if (!ri) return NULL;

    ri->ReplaceIfExists = FALSE;
    ri->RootDirectory   = NULL;
    ri->FileNameLength  = nameLen;
    memcpy(ri->FileName, streamName, nameLen);
    return ri;
}

Deletion sequence

SelfDelete opens the executable, renames the default data stream to the random ADS name, then marks it for immediate deletion.

c
hFile = CreateFileW(exePath, DELETE | SYNCHRONIZE,
                    FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
                    NULL, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, NULL);

/* Step 1: rename :$DATA to a random ADS */
SetFileInformationByHandle(hFile, FileRenameInfo,
                           ri, sizeof(FILE_RENAME_INFO) + ri->FileNameLength);

/* Step 2: unlink from directory immediately */
FILE_DISPOSITION_INFO_EX diex;
diex.Flags = FILE_DISPOSITION_FLAG_DELETE;
SetFileInformationByHandle(hFile, FileDispositionInfoEx, &diex, sizeof(diex));

/* CloseHandle removes the stream; the section object lives */
CloseHandle(hFile);

Verify with Process Explorer: Open Process Explorer, right-click the running process, select Properties. Under the Image tab, the executable path is shown. After SelfDelete completes, try to navigate to that path in Explorer – it won’t exist. The process is still running.

A few notes on implementation choices:

No re-open between rename and delete. The original closes the handle after the rename and re-opens on the original path. That path is now a directory entry with an empty default stream – it will still open, but there’s no reason to do the round-trip. One handle, two SetFileInformationByHandle calls.

BCryptGenRandom instead of _rdrand32_step. RDRAND is available on Ivy Bridge and later but you can’t assume it in a VM. BCryptGenRandom with BCRYPT_USE_SYSTEM_PREFERRED_RNG uses the kernel’s CSPRNG and works everywhere.

FILE_FLAG_OPEN_REPARSE_POINT in CreateFileW. Prevents following reparse points on the executable path. Not strictly necessary for a file you own, but avoids a TOCTOU class issue if the path traverses a junction.

Fallback path for pre-RS1. FILE_DISPOSITION_FLAG_POSIX_SEMANTICS returns ERROR_INVALID_PARAMETER on anything older than build 14393. The fallback sets DeleteFile = TRUE via FileDispositionInfo, which schedules deletion on last-handle-close. The file disappears when CloseHandle runs, not immediately. The difference matters for forensics but the end result is the same.

What the filesystem sees at each step

InitialAfter openAfter renameAfter delete+closeevil.exe:$DATA → PE bytesloader section→ PE bytesevil.exe:$DATA → PE bytesloader section→ PE byteshFile (DELETE)open handleevil.exe:a3f7c2b1 → PE bytesloader section→ PE bytes:$DATA slot is emptyevil.exe(gone from dir)loader section→ PE bytes (live)process still runningCreateFileFileRenameInfoFileDispositionInfoEx+ CloseHandleWindows events fired during the sequence4663: access grantedDELETE access to fileobject type: File4663: rename attemptWriteAttributes+DELETE accesses4660: object deletedSysmon 23: FileDeletefires on POSIX unlinkmemory: PE intactsection object livesuntil process exits

Detection

What fires

Windows Security Event 4663 fires when the DELETE access is granted on the file object. You’ll see two: one for the initial open, one associated with the rename/delete operation. The AccessMask field shows 0x10000 (DELETE).

Windows Security Event 4660 fires when the object is deleted. The ObjectName field contains the file path at deletion time, which will be the renamed ADS variant if you’re lucky, or just the base path depending on how auditing resolves the handle.

Sysmon Event 23 (FileDelete) fires for the POSIX unlink. Sysmon archives a copy of the file if configured to do so, which is the most important defensive configuration here. The TargetFilename field shows the executable path. The IsExecutable field is true.

Sysmon Event 11 (FileCreate) does not fire for the ADS rename. Renaming a stream isn’t creating a file.

What doesn’t fire (reliably)

Anti-malware callbacks via IRP_MJ_SET_INFORMATION can catch the rename and the disposition flag set. Most EDRs hook this at the minifilter level. But the hook sees a FILE_RENAME_INFORMATION structure pointing at a colon-prefixed path, which is legal and not inherently suspicious. Renaming a stream is unusual but not unheard of (some backup tools do it).

CreateFile with DELETE | SYNCHRONIZE on an executable the current process loaded is not flagged by most heuristics. Processes opening their own images with DELETE access is uncommon but not nonexistent. Uninstallers do it.

What a forensic analyst can recover

The PE image survives as a section object until the last reference drops. Practical recovery paths:

Live memory dump. winpmem or any kernel-level RAM dumper can read the VAD for the process. The loader maps the PE as a SEC_IMAGE backed section. The section’s physical pages are readable. volatility3 with the windows.dlllist and windows.dumpfiles plugins can dump the image directly from the section object even after the file is gone from disk.

Process handle table. The loader doesn’t hold a HANDLE to the file in the traditional sense after mapping. It holds a pointer to the section object. But the section object’s reference count keeps the underlying stream data alive. A kernel debugger or a memory forensics tool walking _EPROCESS.SectionObject can reach it.

Page file. If pages from the section get paged out before you dump memory, they land in the page file. pagefile.sys analysis can recover them.

The path the loader reports via QueryFullProcessImageName or through the PEB will still reflect the original executable path after deletion. The path string is cached at load time. It’s now a lie.

Gotchas and edge cases

HVCI (Hypervisor-Protected Code Integrity). Not relevant here. HVCI affects kernel-mode image loading. This technique is entirely user-mode.

Controlled Folder Access. If the executable lives under a protected folder (like %APPDATA%), CFA may block the DELETE open. Unlikely for malware paths but worth knowing.

Volume Shadow Copies. If VSS snapshots exist, the file will still be recoverable via \\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyN\.... This technique only removes the live filesystem entry.

Early Windows 10. Build 14393 is the first with FILE_DISPOSITION_FLAG_POSIX_SEMANTICS. Anything earlier requires the on-close fallback. The code above handles this. The on-close path is less clean but functionally equivalent: the file disappears when the last handle closes.

Share flags on the initial CreateFile. FILE_SHARE_DELETE is required. The loader opens the PE without FILE_SHARE_DELETE on older Windows versions. On RS2 and later, the loader’s handle includes share-delete. If the open fails with ERROR_SHARING_VIOLATION, you’re on an older build and the on-close path won’t save you either.

Why this works from a kernel perspective

NtSetInformationFile with FileDispositionInformationEx calls down into NTFS’s internal disposition handler (the routine observed as NtfsSetDispositionInfo in ntfs.sys). When POSIX_SEMANTICS is set, it invokes the internal link-removal routine (observed as NtfsRemoveLink in Windows 10 RS2+ ntfs.sys builds), which removes the directory entry immediately. The section object and any open file objects still hold a reference to FCB (File Control Block). The FCB keeps the NTFS file record alive. Data streams are reference-counted against the FCB. The PE bytes live until the FCB’s reference count hits zero.

The loader’s section object holds a reference count on _SECTION_OBJECT, which holds a reference on the _SEGMENT, which holds a reference on the _CONTROL_AREA, which points at the NTFS file object. All of that unwinding happens at process exit. Until then, the data is intact and the PE executes normally.


This technique shows up in commodity malware as a cleanup step after initial execution, and in loaders that want to remove their first-stage dropper. The forensic countermeasure is Sysmon 23 with archival enabled. Without it, the only recovery path is a live memory dump taken before the process exits.

For related evasion primitives: process injection without a visible thread covers early-bird APC injection. Mapping kernel code without pool traces covers the BYOVD approach for kernel-mode execution that avoids pool accounting.

Source

Full compilable implementation (BuildRenameInfo, SelfDelete, wmain): ntfs_self_delete.c