payload的自我删除能力直接关乎OPSEC原则,就不在这里赘述有多重要了。
24H2前的自删除
最早期也最简单的自删除方法之一是MoveFileEx,利用操作系统自身提供的机制在下次系统启动时完成删除操作。
这个技术很依赖于Win32 API 函数 MoveFileExW,通过调用此函数为其 dwFlags 参数指定 MOVEFILE_DELAY_UNTIL_REBOOT 标志,同时将目标文件名参数 lpNewFileName 设置为 NULL,程序可以请求操作系统在下一次重启时删除指定的源文件 lpExistingFileName 。
在底层MoveFileExW的这一调用并不会立即执行删除,相反的它会在注册表的一个特定位置记录下这个待处理的操作,位置是HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\PendingFileRenameOperations。这是一个 REG_MULTI_SZ 类型的值,其中包含了成对的文件路径。对于删除操作,其格式\??\C:\path\to\payload.exe\0\0,表示源文件路径后跟一个空的的目标路径 。在系统启动过程中,会话管理器子系统会读取这个注册表项,并在加载大部分驱动和服务之前,处理这些挂起的文件重命名和删除请求 。
局限性非常明显现在不会被采用了,我觉得最主要的问题是它要求系统重启,现在绝大多数攻击场景中强制目标系统重启的可行性很低,因为会中断用户正常工作和可能导致持久化访问权限的丢失。
方法二是辅助进程范式,为了克服之前重启的限制所以得开发多种依赖于第二个进程来完成删除任务的技术,基本思想是主程序创建一个独立的 生命周期分离的子进程,进程a随即退出,进程b在短暂延迟后删除进程a 的可执行文件。
这种范式比较经典的是Batch Scripts和分离的cmd.exe进程,后者是一种更简洁的变体直接调用 cmd.exe 执行一个包含延迟和删除命令的复合指令。比如*cmd.exe /C ping 1.1.1.1 -n 1 -w 3000 > Nul & Del /f /q “%s” *。
ping 的超时参数 -w 创造一个短暂的延迟确保主程序有足够的时间完全终止,释放对可执行文件的锁定,然后Del命令执行删除。
还有一个是代码注入or辅助可执行文,payload可以将一小段负责删除操作的 shellcode 注入到一个稳定运行的合法系统进程中然后通过 CreateRemoteThread 触发其执行,或者说主程序可以从自身资源段中释放一个极小的清理器可执行文件到临时目录,然后运行它来删除主程序。
辅助进程范式实现了无需重启的即时删除,但是也引入了新的攻击面和取证痕迹,无论是批处理脚本还是辅助可执行文件都会在磁盘上留下新的文件系统操作记录,这些都可能被ED捕获。而且代码注入虽然避免了在磁盘上创建新文件,但是注入行为本身就是 EDR 监控的重点。
Lloyd Labs句柄操纵技术是24H2之前的行业标准,这种基于文件系统句柄精巧操纵的技术被公认为是最先进的自删除方法,因为它实现了即时可靠且不产生额外文件辅助的纯净删除。
这个机制的实现依赖于对Windows 文件对象和句柄之间关系。 调用 CreateFile API 打开自身可执行文件的句柄,请求的访问权限DesiredAccess中必须包含 DELETE 权限。
↓
利用上一步获得的句柄,调用 SetFileInformationByHandle 并使用 FileRenameInfo 信息类,将文件的主数据流(:$DATA)重命名。例如将 C:\temp\payload.exe 重命名为* C:\temp\payload.exe:deleter*。这一步是整个技术的关键:它将文件的实际内容移动到了一个Alternate Data Stream中。从文件系统的角度看,payload.exe 文件本身变成了一个零字节文件,但其内容并未丢失只是被隐藏了。
↓
关闭第一步中获得的句柄,然后再次调用 CreateFile 打开 payload.exe,现在是零字节文件里的句柄
↓
调用 SetFileInformationByHandle,但这次使用 FileDispositionInfo 信息类。其对应的结构体 FILE_DISPOSITION_INFORMATION 中只有一个布尔成员 DeleteFile。将此成员设置为 TRUE。这个操作向内核文件系统驱动程序表明当这个句柄被关闭时,与之关联的文件应该被删除。
↓
关闭第二步获得的句柄,句柄的关闭行为触发了上一步设置的删除意图,文件系统驱动程序会立即将该文件从磁盘上彻底删除。
这种方法因为高效可靠和极低的取证痕迹而成为首选,不需要重启,不创建任何辅助文件,整个过程在内存中通过 API 调用完成,但是唯一的局限性在于它严重依赖于 NTFS.sys 驱动程序对于特定 API 调用序列的一种特定行为响应。
24H2
Windows 11 24H2 预览版的发布之后安全人员发现句柄操纵自删除技术突然失灵了。
观察到的失效场景
如其他技术博客分析的那样在 Windows 11 24H2 系统上执行采用上述句柄操纵技术的程序结果不再是文件被彻底删除,取而代之的是原始的可执行文件在原地变成了一个零字节文件,而其全部内容被悄无声息地转移到了这个零字节文件的一个Alternate Data Stream,例如一个5MB大小的 payload.exe 在执行自删除后会变成一个 0 字节的 payload.exe,但完整的5MB二进制内容可以通过访问 *payload.exe:random_stream_name *这样的路径来读取,这种行为就彻底违背了自删除的初衷,从抹除变成了隐藏。
研究人员对 Windows 11 23H2 和 24H2 两个版本的内核文件系统驱动 NTFS.sys 进行了深入的比较分析 。
大概研究过程是结合了静态分析和动态调试,静态分析用Ghidra两个版本的 NTFS.sys 中与文件处置相关的函数进行代码比对然后寻找逻辑上的差异,动态调试利用 WinDbg 附加到内核,设置断点并单步跟踪自删除程序在调用 SetFileInformationByHandle时的内部执行流程 。
分析最终定位到了 NTFS.sys 驱动内部处理文件处置信息(FileDispositionInformation)的函数 NtfsSetDispositionInfo 或其调用的某个内部函数。发现了在 24H2 版本中,当这个函数被调用以删除一个当前被内存映射(memory-mapped)的文件时它行为逻辑就发生了改变,驱动不再执行删除路径而是将文件内容转移到 ADS,并返回一个新的此前未公开的 NTSTATUS 错误码 0xF216D 。这一变更很可能是微软为了增强文件处理的健壮性或作为其他内核级修改的副作用而引入的,但它无意中破坏了依赖于旧有行为的自删除技术。
蓝队取证-ADS痕迹
虽然对红队是坏消息但对蓝队却是一个意料之外的惊喜,它将一种高效的反取证技术变成了一个高保真度的入侵指标。
正常情况下的ADS 中存储的数据量通常很小,例如 Zone.Identifier 流只有几十个字节。因此,在一个零字节文件的 ADS 中发现一个完整的、数兆字节大小的可执行文件内容是异常的现象。这种现象的出现并非某种通用的恶意行为,而是特定攻击技术在特定操作系统版本上失效后产生的唯一结果。
这就构成了一个近乎完美的 IOC:
- 高信噪比:几乎没有合法的应用程序会产生这样的文件结构,因此误报率极低。
- 高信息量:一旦检测到这种痕迹,防御方不仅知道系统上存在恶意活动,还能直接恢复完整的恶意软件样本进行分析,并且可以推断出攻击者使用的工具集尚未针对 24H2 进行更新。
在24H2发布后的一段时间内,蓝队可以通过简单的文件系统扫描或监控,精准地捕获那些尚未适应新环境的攻击者。这完全是由一次操作系统更新所带来的、攻防天平的暂时倾斜。
红队-利用POSIX语义恢复删除能力
红队这边新的解决方案放弃了使用 FileDispositionInfo,转而采用一个功能更强大、更现代的 native API 调用组合,直接调用 ntdll.dll 中的 NtSetInformationFile 函数,并为其 FileInformationClass 参数指定 FileDispositionInformationEx。
FileDispositionInformationEx 相比旧的 FileDispositionInfo提供了一个更具扩展性的结构 FILE_DISPOSITION_INFORMATION_EX,允许对删除行为进行更精细的控制。 新技术的精髓在于 FILE_DISPOSITION_INFORMATION_EX 结构中的 Flags 成员。通过将这个成员设置为 FILE_DISPOSITION_DELETE |FILE_DISPOSITION_POSIX_SEMANTICS 的组合程序可以请求内核以 POSIX 兼容的方式执行删除操作。
POSIX 风格的删除与传统的 Windows 删除在语义上存在差异,传统 Windows 删除是当一个文件被标记为删除时,系统会检查其打开句柄的引用计数,只有当引用计数归零时文件才会被真正从磁盘上移除。在此之前文件仍然可以通过已有的句柄访问,但不能再创建新的句柄。
POSIX 语义删除是当收到带有 FILE_DISPOSITION_POSIX_SEMANTICS 标志的删除请求后,文件系统会立即将文件的目录项从文件系统的可见命名空间中移除。这意味着从这一刻起任何尝试通过文件名打开该文件的操作都会失败。但是文件的实际数据块并不会立即被回收,而是会等到指向该文件的最后一个已打开的句柄被关闭后才会被真正删除 。
对于自删除场景 可执行文件在调用 NtSetInformationFile后进程本身仍然持有一个有效的句柄可以继续执行。但文件在磁盘上的目录条目已经消失,当进程最终执行完毕并退出时它持有的最后一个句柄被关闭,文件的内容也随之被彻底清除。
这种基于 POSIX 语义的绕过方法之所以能够成功,是因为它利用了 NTFS.sys 驱动内部可能存在的并行逻辑路径。微软在 24H2 中对 NtfsSetDispositionInfo 的修改很可能只影响了处理传统 Windows 删除请求的代码分支。而处理 POSIX 语义删除请求的逻辑位于一个独立且并行的代码分支中。
综上可得,当一个常用的 API或行为受到限制时,最有效的绕过策略往往是去寻找实现相似功能的替代性 API 或标志,特别是那些基于不同设计哲学或为兼容性而存在的API。
POC
原始句柄操纵技术 PoC,LloydLabs发布的演示了在24H2之前的自删除技术。 https://github.com/LloydLabs/delete-self-poc
针对24H2更新的PoC,专门用于在 Windows 11 24H2上实现自删除。 https://github.com/MaangoTaachyon/SelfDeletion-Updated
AgeloVito将此技术实现为BOF以便在 Cobalt Strike 等攻击框架中使用。 https://github.com/AgeloVito/self_delete_bof
#include <windows.h>
#include <winternl.h>
#include <tchar.h>
#include <stdio.h>
// Define necessary structures and function prototypes if not available in standard headers
// This structure is used with FileDispositionInformationEx
typedef struct _FILE_DISPOSITION_INFORMATION_EX {
ULONG Flags;
} FILE_DISPOSITION_INFORMATION_EX, *PFILE_DISPOSITION_INFORMATION_EX;
// Define flags for the FILE_DISPOSITION_INFORMATION_EX structure
#define FILE_DISPOSITION_DELETE 0x00000001
#define FILE_DISPOSITION_POSIX_SEMANTICS 0x00000002
// Define the FileInformationClass for FileDispositionInformationEx
const FILE_INFORMATION_CLASS FileDispositionInformationExClass = (FILE_INFORMATION_CLASS)64;
// Define a function pointer for the NtSetInformationFile function from ntdll.dll
typedef NTSTATUS(NTAPI *pNtSetInformationFile)(
HANDLE FileHandle,
PIO_STATUS_BLOCK IoStatusBlock,
PVOID FileInformation,
ULONG Length,
FILE_INFORMATION_CLASS FileInformationClass
);
// The function that performs the self-deletion
void SelfDelete() {
TCHAR szPath;
HANDLE hFile;
IO_STATUS_BLOCK ioStatusBlock;
FILE_DISPOSITION_INFORMATION_EX dispoInfo;
// Get the full path to the current executable
if (GetModuleFileName(NULL, szPath, MAX_PATH) == 0) {
_tprintf(_T("[-] GetModuleFileName failed. Error: %lu\n"), GetLastError());
return;
}
_tprintf(_T("[+] Attempting to delete: %s\n"), szPath);
// Open a handle to the executable with DELETE access
hFile = CreateFile(szPath, DELETE, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE) {
_tprintf(_T("[-] CreateFile failed. Error: %lu\n"), GetLastError());
return;
}
// Prepare the disposition information structure with POSIX semantics
dispoInfo.Flags = FILE_DISPOSITION_DELETE | FILE_DISPOSITION_POSIX_SEMANTICS;
// Get the address of NtSetInformationFile from ntdll.dll
pNtSetInformationFile NtSetInformationFile = (pNtSetInformationFile)GetProcAddress(
GetModuleHandle(TEXT("ntdll.dll")), "NtSetInformationFile"
);
if (NtSetInformationFile) {
// Call NtSetInformationFile to mark the file for deletion with POSIX semantics
NTSTATUS status = NtSetInformationFile(hFile, &ioStatusBlock, &dispoInfo, sizeof(dispoInfo), FileDispositionInformationExClass);
if (NT_SUCCESS(status)) {
_tprintf(_T("[+] NtSetInformationFile with POSIX semantics succeeded.\n"));
} else {
_tprintf(_T("[-] NtSetInformationFile failed. NTSTATUS: 0x%lx\n"), status);
}
} else {
_tprintf(_T("[-] Could not get address of NtSetInformationFile.\n"));
}
// Close the handle. The file is now unlinked from the file system namespace.
// The process can continue execution. Upon process termination, the OS will
// close the final handle, and the file's data will be deleted from disk.
CloseHandle(hFile);
_tprintf(_T("[+] Handle closed. The file is now unlinked. Process will exit shortly.\n"));
}
// Main entry point of the program
int main() {
_tprintf(_T("Self-deletion PoC for Windows 24H2.\n"));
_tprintf(_T("The program will now attempt to delete itself.\n"));
// Call the self-delete function
SelfDelete();
// The program can continue to do work here if needed.
// For this PoC, we'll just wait a moment before exiting.
Sleep(3000); // Wait for 3 seconds to demonstrate the process is still running
_tprintf(_T("Exiting program. The file should now be completely removed.\n"));
return 0;
}
⬆️是新步骤逻辑,打开一个带DELETE权限的句柄然后使用 NtSetInformationFile 和 FileDispositionInformationEx 结构体,设置FILE_DISPOSITION_POSIX_SEMANTICS 标志来请求 POSIX 风格的删除。
OPSEC还是不应将所有希望寄托于单一的完美清理技术,应该实现工具链的多样化与冗余,比如需要一个当遇到基于句柄的复杂技术失败时能够回退到更简单但有效的辅助进程方法()
最高级的隐蔽策略是完全避免在磁盘上留下任何需要删除的痕迹,不要局限在传统的删除思维而是去内存执行,后续得优先发展和使用无文件攻击技术,比如通过反射式DLL注入直接在内存中加载和执行载荷,把清理工作将从处理磁盘上的文件简化为确保进程终止后内存被正确回收。