基础知识
如果你正在开发rootkit,但是你没有已知的有效证书来签署签名,那么就只能手动映射rootkit。手动映射驱动程序的概念类似于反射 PE 加载的概念。不是从磁盘加载程序,而是手动将其映像布置到内存中,然而,驱动程序必须映射到内核内存,这意味着我们必须在环 0 中拥有某种写原语,这就是 BYOVD 和 LOLDrivers[https://loldrivers.io/]发挥作用的地方。 如果我们可以利用已签名但易受攻击的驱动程序来将任意数据写入内核空间,就可以直接将 rootkit 驱动程序的映像写入内存,就像合法加载一样。
使用 kdmapper 手动映射驱动程序
最著名的将驱动程序映射到内存的工具是 kdmapper,它用来自 intel 的易受攻击的 iqvw64e.sys 驱动程序将任意驱动程序写入内核。当然这玩意都被列入黑名单。不过可以把内存原语替换为任何其他易受攻击的驱动程序,最好是只有自己知道的驱动程序,并且仍然使用 kdmapper 来部署您的 rootkit。
正如前面所描述的,手动映射的过程与反射 PE 注入非常相似,因为本质上驱动程序只是另一个 PE。例如,将部分写入内存、解析导入并应用重定位、擦除IMAGE标头以进行隐藏,最后调用驱动程序的入口点。除了这个映射过程之外,kdmapper 还负责擦除正在加载的英特尔驱动程序的痕迹,这是映射器而不是 rootkit 应该处理的事情,但它涉及清除各种未记录的数据结构中的条目,例如 Defender WdFilter.sys 驱动程序使用的 PiDDBCacheTable、MmUnloadedDrivers 数组、g_KernelHashBucketList 和 RuntimeDriver* 结构。
驱动交互
通常,驱动程序以及 Rootkit 通过 IOCTL 代码进行通信,IOCTL 代码是通过设备句柄上的 DeviceIoControl API 发送到设备的控制消息。为了让用户模式程序获取这样的句柄,驱动程序必须注册一个设备对象,用户模式程序可以使用该对象来调用 CreateFile。在驱动程序中,通常如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 | NTSTATUS
DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath)
{
IoCreateDevice(
pDriverObject,
0 ,
&usDriverName,
FILE_DEVICE_UNKNOWN, / / not associated with any real device
FILE_DEVICE_SECURE_OPEN,
FALSE,
&pDeviceObject
);
}
|
这会在 Windows 对象管理器中为驱动程序创建一个对象,通常采用以下形式\Device\<Name>
,然后客户端可以调用CreateFileA("\\Device\\Rootkit",GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL)
打开 rootkit 的句柄,然后向该句柄发送 IOCTL 代码以控制 rootkit。
检测方法
如果我们选择用户模式/内核模式通信的标准方式,我们的 rootkit 将显示在 Windows 对象管理器中。但反 Rootkit 如何将其与合法驱动程序区分开来?如果是手动映射的,我们可以简单地查看所有设备对象并检查它们在内核内存中的映像是否实际上由有效模块支持。这是我们将在这篇文章中介绍的第 1 号检测。
检测方法一:查询设备对象
要查询设备对象,首先需要进行一系列 API 调用来获取 \Driver 目录对象:
1 2 3 4 5 6 7 | / * 参考 https: / / github.com / not - wlan / driver - hijack / blob / master / memedriver / hijack.cpp
/ / 获取 \Driver 目录的句柄
InitializeObjectAttributes(&attributes, &directoryName, OBJ_CASE_INSENSITIVE, NULL,NULL);
ZwOpenDirectoryObject(&handle, DIRECTORY_ALL_ACCESS, &attributes);
/ / 从句柄中获取对象
ObReferenceObjectByHandle(handle, DIRECTORY_ALL_ACCESS, nullptr, KernelMode,&directory, nullptr);
POBJECT_DIRECTORY directoryObject = (POBJECT_DIRECTORY)directory;
|
有了这个对象,我们现在可以开始迭代每个设备对象。对象管理器实际上将对象管理在具有 37 个条目的哈希桶中(有关技术细节,请参阅:https://www.informit.com/articles/article.aspx?p=22443&seqNum=7)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | / / 获取访问目录对象的锁
KeEnterCriticalRegion();
ExAcquirePushLockExclusiveEx(&directoryObject - >Lock, 0 );
/ / 迭代 hash 桶
for (POBJECT_DIRECTORY_ENTRY entry : directoryObject - >HashBuckets)
{
if (!entry)
continue ;
/ / 迭代每个 hashbuckets 条目
while (entry ! = nullptr && entry - > Object )
{
PDRIVER_OBJECT driver = (PDRIVER_OBJECT)entry - > Object ;
/ *
* 我们只是检查驱动程序条目 (DriverInit) 内存是否驻留在已加载模块地址空间之一内。
* /
if (GetDriverForAddress((ULONG_PTR)driver - >DriverInit) = = NULL)
{
LOG_MSG("[DeviceObjectScanner] - > Detected DriverEntry
pointing to unbacked region % ws @ 0x % llx\n",
driver - >DriverName. Buffer ,
(ULONG_PTR)driver - >DriverInit
);
}
entry = entry - >ChainLink;
}
}
/ / Release
ExReleasePushLockExclusiveEx(&g_hashBucketLock, 0 );
KeLeaveCriticalRegion(
|
检查内存地址是否在已加载模块的地址空间内部的实现相当简单:我们迭代 DriverSection->InLoadOrderLinks 链表,其中包含每个已加载驱动程序的 KLDR_DATA_TABLE_ENTRY (有点类似于用户模式下 PEB 中的InLoadOrderModuleList))。在这里,我们检查地址是否驻留在这些模块之一中 - 如果它不属于任何模块,那么就是手动映射到内存的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | PKLDR_DATA_TABLE_ENTRY
GetDriverForAddress(ULONG_PTR address)
{
if (!address)
{
return NULL;
}
PKLDR_DATA_TABLE_ENTRY entry = (PKLDR_DATA_TABLE_ENTRY)(g_drvObj) -
>DriverSection;
for (auto i = 0 ; i < 512 ; + + i)
{
UINT64 startAddr = UINT64(entry - >DllBase);
UINT64 endAddr = startAddr + UINT64(entry - >SizeOfImage);
if (address > = startAddr && address < endAddr)
{
return (PKLDR_DATA_TABLE_ENTRY)entry;
}
entry = (PKLDR_DATA_TABLE_ENTRY)entry - >InLoadOrderLinks.Flink;
}
return NULL;
}
|
如果我们现在加载基于 IOCTL 的 rootkit 驱动程序(例如通过 kdmapper 的 Nidhogg)并运行 unKover,我们可以快速看到 DeviceObject 扫描程序将 Nidhogg 驱动程序发现为无支持的内核内存区域:
下面是针对手动映射到内存的rootkit,通过该检测方法示例:
虽然存在几种逃避这种简单检测的方法,但最简单的方法之一是更改用户模式/内核模式通信的方法并放弃基于 IOCTL 的通信,这样我们甚至不需要再注册设备,从而不会在对象管理器中可见。比如通过共享内存或命名管道进行通信。
检测方法二:使用 APC 检测无系统支持的线程
现在,有了命名管道或共享内存,我们的 Rootkit 会持续读取等待新命令的内容(while true:ReadCommandFromSharedMemory()),就会出现新的检测 - 反 Rootkit 只需通过分析线程来识别线程即可指向无支持内存的帧的调用堆栈。检测映射作弊驱动程序的一种方法(例如 BattleEye 反作弊所使用的方法)是将 APC 排队到所有系统线程中,以展开每个线程的堆栈帧。然后,反作弊程序可以检查每个帧的地址是否指向系统不支持的内存 ,如果是,我们就捕获了潜在的 rootkit/作弊线程。
首先,必须定义分析线程调用堆栈的 APC(为简洁起见,省略了大量代码):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | VOID
CaptureStackAPC(
IN PKAPC Apc,
IN OUT PKNORMAL_ROUTINE * NormalRoutine,
IN OUT PVOID * NormalContext,
IN OUT PVOID * SystemArgument1,
IN OUT PVOID * SystemArgument2
)
{
/ / 为堆栈帧分配内存
PVOID * stackFrames = (PVOID * )ExAllocatePoolWithTag(NonPagedPoolNx,
MAX_STACK_DEPTH * sizeof(PVOID), POOL_TAG);
RtlSecureZeroMemory(stackFrames, MAX_STACK_DEPTH * sizeof(PVOID));
/ *
* 捕获堆栈调用,展开堆栈并返回一个指针
* /
USHORT framesCaptured = RtlCaptureStackBackTrace( 0 , MAX_STACK_DEPTH,
stackFrames, NULL);
/ / 开始分析
for (auto i = 0 ; i < framesCaptured; + + i)
{
/ / C查帧的地址是否来自无支持的内存
ULONG_PTR addr = (ULONG_PTR)stackFrames[i];
if (GetDriverForAddress(addr) = = NULL)
{
DbgPrint("[APCStackWalk] - > Detected stack frame pointing to
unbacked region: TID: % lu @ 0x % llx\n", HandleToUlong(PsGetCurrentThreadId()), addr);
}
}
if (stackFrames) { ExFreePoolWithTag(stackFrames, POOL_TAG); }
/ / 释放 APC 并发出 APC 已完成信号
ExFreePoolWithTag(Apc, POOL_TAG);
KeSetEvent(&g_kernelApcSyncEvent, 0 , FALSE);
}
|
现在我们只需将此 APC 排队到所有系统线程即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | VOID
APCStackWalk()
{
KeInitializeEvent(&g_kernelApcSyncEvent, NotificationEvent, FALSE);
/ / 将 APC 排队到系统线程。系统线程 ID 是 4 的倍数 https: / / devblogs.microsoft.com / oldnewthing / 20080228 - 00 / ?p = 23283
for (auto tid = 4 ; tid < 0xFFFF ; tid + = 4 )
{
PETHREAD ThreadObj;
/ / 获取 TID 的 ETHREAD 对象
if (!NT_SUCCESS(PsLookupThreadByThreadId(UlongToHandle(tid),
&ThreadObj)))
{
continue ;
}
/ / 忽略当前线程和非系统线程
if (!PsIsSystemThread(ThreadObj) || ThreadObj = =
KeGetCurrentThread())
{
ObDereferenceObject(ThreadObj);
continue ;
}
/ / 初始化 APC
PKAPC apc = (PKAPC)ExAllocatePoolWithTag(
NonPagedPool,
sizeof(KAPC),
POOL_TAG
);
KeInitializeApc(apc,
ThreadObj,
OriginalApcEnvironment,
CaptureStackAPC,
RundownAPC, / / Empty APC routine
NormalAPC, / / Empty APC routine
KernelMode,
NULL
);
/ / 队列 APC
NTSTATUS NtStatus = KeInsertQueueApc(apc, NULL, NULL,
IO_NO_INCREMENT);
/ / 在排队之前等待事件表明 apc 已完成
next one
LARGE_INTEGER timeout;
timeout.QuadPart = 2000 ;
NtStatus = KeWaitForSingleObject(&g_kernelApcSyncEvent, ExecutiKernelMode, FALSE, &timeout);
KeResetEvent(&g_kernelApcSyncEvent);
if (ThreadObj) { ObDereferenceObject(ThreadObj); }
}
}
|
下图是使用Banshee 运行的rootkit,它使用共享内存 KM/UM 通信和读取命令的系统线程,我们可以上述检测方法可以看到该线程:
同样,有很多方法可以规避这种检测:其中之一是堆栈欺骗,这样我们就可以假装不在无支持的内存中。另一种技术基于我们系统线程的 KTHREAD 对象的直接内核对象修改 (DKOM) - 如果我们将其 ApcQueueable 位设置为 0,我们实际上不允许任何 APC 在我们的线程上排队 (https://www.unknowncheats.me/forum/anti-cheat-bypass/587069-disable-apc.html) - 这是一个使用的功能,例如作者:KeEnterCriticalRegion(即使反 rootkit 可以翻转该位 - 如果 APC 开始在关键代码区域中排队,这也是非常具有侵入性的,并且会极大地使操作系统稳定性面临风险)。请记住,KTHREAD 是一个未记录的结构,不同 Windows 版本之间存在很大差异。
检测方法三:不可屏蔽中断
NMI 是不可屏蔽中断,这意味着它们是发送到 CPU 的硬件驱动中断,无法被屏蔽(即防止发生)。在Windows中,HalSendNmi API可用于将NMI发送到CPU核心,这将在中断时直接中断该核心上运行的线程并调用NMI回调。 NMI 回调函数可以由任何内核驱动程序定义,因此可用于检查在特定内核上运行线程的调用堆栈,就像上面的 APC 一样。这意味着,如果幸运的话,我们可以捕获在 CPU 上运行的 rootkit 线程,并且如果我们不时发送足够的 NMI,则可以遍历堆栈以查找未支持的内存指针。直接包含上述两个检测功能。
定义 NMI 回调:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | BOOLEAN
NmiCallback(PVOID context, BOOLEAN handled)
{
PNMI_CONTEXT nmiContext = (PNMI_CONTEXT)context;
ULONG procNum = KeGetCurrentProcessorNumber();
nmiContext[procNum].threadId = HandleToULong(PsGetCurrentThreadId());
/ / 捕获堆栈跟踪
nmiContext[procNum].framesCaptured = RtlCaptureStackBackTrace(
0 ,
STACK_CAPTURE_SIZE,
(PVOID * )nmiContext[procNum].stackFrames,
NULL
);
return TRUE;
}
|
由于 NMI 不应该运行太长时间,出于稳定性原因,我们将信息保存到堆分配的内存中,并在另一个线程中解析其数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | VOID
AnalyzeNmiData()
{
for (auto core = 0u ; core<g_numCores; + + core)
{
PETHREAD ThreadObj = NULL;
NMI_CONTEXT nmiContext = g_NmiContext[core];
/ / 获取线程对象
PsLookupThreadByThreadId(ULongToHandle(nmiContext.threadId),
&ThreadObj);
/ / 检查每个堆栈帧的来源
for (auto i = 0 ; i < nmiContext.framesCaptured; + + i)
{
ULONG_PTR addr = (ULONG_PTR)(nmiContext.stackFrames[i]);
PKLDR_DATA_TABLE_ENTRY driver = GetDriverForAddress(addr);
if (driver = = NULL)
{
LOG_MSG("[NmiCallback] - > Detected stack frame
pointing to unbacked region. TID: % u @ 0x % llx", nmiContext.threadId, addr);
}
}
if (ThreadObj) { ObDereferenceObject(ThreadObj); }
}
}
|
这个逻辑与APC解析中使用的逻辑几乎相同,在主循环中,我们定期发送 NMI 以捕获线程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | VOID
SendNMI(IN PVOID StartContext)
{
NTSTATUS NtStatus;
do
{
/ / 注册回调
g_NmiCallbackHandle = KeRegisterNmiCallback(NmiCallback,
g_NmiContext);
/ / 为每个核心触发 NMI
for (auto core = 0u ; core<g_numCores; + + core)
{
KeInitializeAffinityEx(g_NmiAffinity);
KeAddProcessorAffinityEx(g_NmiAffinity, core);
HalSendNMI(g_NmiAffinity);
/ / Sleep for 1 seconds between each NMI to allow completion
SleepMs( 1000 );
}
/ / 取消注册回调
KeDeregisterNmiCallback(g_NmiCallbackHandle);
/ / 分析代码,略
AnalyzeNmiData();
SleepMs( 5000 );
} while (true);
}
|
由于回调可能会被 rootkit 删除,因此我们确保在触发 NMI 之前注册回调并在之后取消注册。如果我们让它运行足够长的时间,我们迟早会捕获一个指向未备份内存的线程。
再次强调,首先不使用线程是此检测的最佳对策,但这将标准提高了很多。调用堆栈欺骗仍然是一种可行的措施。