前言
发现一个不错的仓库:https://github.com/eversinc33/unKover,里面不断开发了一些反rootkit技术。将针对其中部分技术展开本博客。
对于熟悉用户级恶意软件的人来说,手动映射驱动程序的概念类似于反射 PE 加载的概念 ,不是从磁盘加载程序,而是手动将其映像布局到内存中。然而,驱动程序必须映射到内核内存,这意味着我们必须在环 0 中已经有某种 write-primitive。这就是 BYOVD 和 LOLDrivers 发挥作用的地方,如果我们可以利用已签名但易受攻击的驱动程序将任意数据写入内核空间,我们就可以将 rootkit 驱动程序的映像写入内存,就像它是合法加载的一样。
使用 kdmapper 手动映射驱动程序
将驱动程序映射到内存的最知名工具可以说是 kdmapper,它利用 intel 的易受攻击的 iqvw64e.sys 驱动程序将任意驱动程序写入内核。当然,现在这个 loldriver在某些环境中被列入黑名单。但是可以将内存原语替换为任何其他易受攻击的驱动程序,最好是只有你的红队知道的驱动程序,并且仍然使用 KDMapper 来部署你的 Rootkit。除了这个映射过程之外,kdmapper 还负责擦除正在加载的 intel 驱动程序的痕迹。描述映射器不是本文的重点,但它涉及清除各种未记录的数据结构中的条目,例如 Defender WdFilter.sys 驱动程序使用的 PiDDBCacheTable、MmUnloadedDrivers 数组、g_KernelHashBucketList 和 RuntimeDriver* 结构。那么,我们如何检测手动映射的驱动程序呢?在第一个检测思路之前,我们需要再谈谈一个话题:驱动通信。
驱动间通信
通常,驱动程序和 rootkit 通过 IOCTL 代码进行通信,IOCTL 代码是通过设备句柄通过 DeviceIoControl API 发送到设备的控制消息。若要使用户模式程序获取此类句柄,驱动程序必须注册用户模式程序可用于调用 CreateFile 的设备对象。在驱动程序中如下所示:
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。
如果我们选择这种标准的 Usermode/Kernelmode 通信方式,我们的 rootkit 将显示在 Windows 对象管理器中。但是,反 Rootkit 如何将其与合法驱动程序区分开来呢?好吧,如果它是手动映射的,我们可以简单地查看所有设备对象,并检查它们在内核内存中的映像是否真的由有效的模块支持。参考下文检测规则1.
检测规则1:查询设备对象
要查询设备对象,首先需要一系列 API 调用来获取 Driver 目录对象:
/* Code skeleton stolen from https://github.com/not-wlan/driver-
hijack/blob/master/memedriver/hijack.cpp#L136 */
// Get Handle to Driver directory
InitializeObjectAttributes(&attributes,&directoryName, OBJ_CASE_INSENSITIVE, NULL,
NULL);
ZwOpenDirectoryObject(&handle, DIRECTORY_ALL_ACCESS,&attributes);
// Get the object from the handle
ObReferenceObjectByHandle(handle, DIRECTORY_ALL_ACCESS,nullptr,KernelMode,
&directory,nullptr);
POBJECT_DIRECTORY directoryObject =(POBJECT_DIRECTORY)directory;
有了这个对象,我们现在可以开始迭代每个 device 对象。对象管理器实际上将对象组织在具有 37 个条目的 hashbucket 中(有关技术详细信息,请参阅:https://www.informit.com/articles/article.aspx?p=22443&seqNum=7)。
// acquire the lock for accessing the directory object
KeEnterCriticalRegion();
ExAcquirePushLockExclusiveEx(&directoryObject->Lock,0);
// iterate over the hashbucket
for(POBJECT_DIRECTORY_ENTRY entry : directoryObject->HashBuckets)
{
if(!entry)
continue;
// iterate over each hashbuckets entries items
while(entry !=nullptr&& entry->Object)
{
PDRIVER_OBJECT driver =(PDRIVER_OBJECT)entry->Object;
/*
* We are simply checking if the driver entry (DriverInit) memory
* resides inside of one of the loaded modules address spaces.
*/
if(GetDriverForAddress((ULONG_PTR)driver->DriverInit)== NULL)
{
LOG_MSG("[DeviceObjectScanner] -> Detected DriverEntry
pointing to unbacked region %ws @ 0x%llxn",
driver->DriverName.Buffer,
(ULONG_PTR)driver->DriverInit
);
}
entry = entry->ChainLink;
}
}
// Release lock when done
ExReleasePushLockExclusiveEx(&g_hashBucketLock,0);
KeLeaveCriticalRegion();
检查内存地址是否在已加载模块的地址空间内的实现相当简单:我们迭代 DriverSection->InLoadOrderLinks 链表,其中包含每个已加载驱动程序的KLDRDATATABLE_ENTRY(有点类似于的 PEB 的 Usermode InLoadOrderModuleList)。在这里,我们检查地址是否驻留在其中一个模块中 - 如果它不属于任何模块,则手动将其映射到内存。
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 驱动程序发现为未支持的内核内存区域:
虽然有几种方法可以逃避这种简单的检测,但其中一种更简单的方法是改变 Usermode/Kernelmode 通信的方法,不再使用基于 IOCTL 的通信,这样我们甚至不需要再注册设备,因此在对象管理器中根本不可见。我想到的是通过共享内存或命名管道进行通信。在github中,目前正在使用共享内存,这是在 rootkit 驱动程序和 Usermode 客户端之间共享的。
现在,有了等待新命令的 rootkit 持续读取的命名管道或共享内存(while true:ReadCommandFromSharedMemory()),下面出现了新的检测 - 反 rootkit 只需通过分析线程调用堆栈来识别线程,以查找指向无支持的内存的帧。
检测规则2:使用 APC 检测无支持的系统线程
有无数方法可以完成上述操作。一种检测映射作弊驱动程序的方法(例如 BattleEye 反作弊器使用的方法)是将 APC 排队到所有系统线程,从而展开每个线程的堆栈帧。然后,反作弊可以检查每一帧的地址是否指向无支持的内存 - 如果指向,则我们捕获了潜在的 rootkit/作弊线程。
现在内核中的 APC 是一个超级复杂的话题。我发现一个对实现这一点有用的博客是 https://repnz.github.io/posts/apc/kernel-user-apc-api/#kernel-apc-api-reference。
首先,必须定义分析线程调用堆栈的 APC(为简洁起见,省略了大量代码):
VOID
CaptureStackAPC(
IN PKAPC Apc,
IN OUT PKNORMAL_ROUTINE*NormalRoutine,
IN OUT PVOID*NormalContext,
IN OUT PVOID*SystemArgument1,
IN OUT PVOID*SystemArgument2
)
{
// allocate memory for the stack frames
PVOID* stackFrames =(PVOID*)ExAllocatePoolWithTag(NonPagedPoolNx,
MAX_STACK_DEPTH *sizeof(PVOID), POOL_TAG);
// zero the memory
RtlSecureZeroMemory(stackFrames, MAX_STACK_DEPTH *sizeof(PVOID));
/*
* Capture the stack trace.
* All the heavy lifting is done by RtlCaptureStackBackTrace, which
* unwinds the stack for us and gives back a pointer of
*/
USHORT framesCaptured =RtlCaptureStackBackTrace(0, MAX_STACK_DEPTH,
stackFrames, NULL);
// Stack trace analysis...
for(auto i =0; i < framesCaptured;++i)
{
// Check if address of frame is from unbacked memory
ULONG_PTR addr =(ULONG_PTR)stackFrames[i];
if(GetDriverForAddress(addr)== NULL)
{
DbgPrint("[APCStackWalk] -> Detected stack frame pointing to
unbacked region: TID: %lu @ 0x%llxn",HandleToUlong(PsGetCurrentThreadId()), addr);
}
}
if(stackFrames){ExFreePoolWithTag(stackFrames, POOL_TAG);}
// Free the APC and signal that the APC is done
ExFreePoolWithTag(Apc, POOL_TAG);
KeSetEvent(&g_kernelApcSyncEvent,0, FALSE);
}
RtlCaptureStackBackTrace 完成了艰苦的工作,它为我们展开了堆栈帧,使堆栈分析变得轻而易举。现在我们只需将此 APC 排队到所有系统线程。
VOID
APCStackWalk()
{
KeInitializeEvent(&g_kernelApcSyncEvent,NotificationEvent, FALSE);
// Queue APCs to system threads. System thread IDs are a multiple of 4.
// (Usually at least. See:
https://devblogs.microsoft.com/oldnewthing/20080228-00/?p=23283)
for(auto tid =4; tid <0xFFFF; tid +=4)
{
PETHREAD ThreadObj;
// Get ETHREAD object for TID
if(!NT_SUCCESS(PsLookupThreadByThreadId(UlongToHandle(tid),
&ThreadObj)))
{
continue;
}
// Ignore current thread and non system threads
if(!PsIsSystemThread(ThreadObj)||ThreadObj==
KeGetCurrentThread())
{
ObDereferenceObject(ThreadObj);
continue;
}
// Initialize 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
);
// Queue APC
NTSTATUS NtStatus=KeInsertQueueApc(apc, NULL, NULL,
IO_NO_INCREMENT);
// Wait for event to signal that the apc is done before queueing the
next one
LARGE_INTEGER timeout;
timeout.QuadPart=2000;// 2 second wait timeout
NtStatus=KeWaitForSingleObject(&g_kernelApcSyncEvent,Executive,
KernelMode, FALSE,&timeout);
KeResetEvent(&g_kernelApcSyncEvent);
// Clean up
if(ThreadObj){ObDereferenceObject(ThreadObj);}
}
}
运行Git项目,它使用共享内存 KM/UM 通信和读取命令的系统线程,我们马上能看到线程被找到:
因此,即使我们没有注册设备对象,由于我们的系统线程源自未支持的内存,我们也会被 unKover 检测到。
同样,有很多方法可以规避这种检测:其中之一是堆栈欺骗,这样我们就可以假装不在无支持的内存中。另一种技术基于系统线程的 KTHREAD 对象的直接内核对象修改 (DKOM) , 如果我们将其 ApcQueueable 位设置为 0,则实际上不允许任何 APC 在我们的线程上排队 (https://www.unknowncheats.me/forum/anti-cheat-bypass/587069-disable-apc.html) - 这是 KeEnterCriticalRegion 使用的功能(即使反 rootkit 可以将此位翻转回来 - 这是非常具有侵入性的,并且极大地使操作系统的稳定性处于危险之中, 如果 APC 开始在关键代码区域中排队)。请记住,KTHREAD 是一个未记录的结构,它因 Windows 版本而异。
检测规则3:Non-Maskable-Interrupts
NMI 是不可屏蔽的中断,这意味着它们是发送到 CPU 的硬件驱动的中断,无法被屏蔽(即防止发生)。在 Windows 中,可以使用 HalSendNmi API 将 NMI 发送到 CPU 内核,将在中断时直接中断该内核上运行的线程,并调用 NMI 回调。NMI 回调函数可以由任何内核驱动程序定义,因此可用于检查在特定内核上运行的线程的调用堆栈,就像上面对 APC 一样。这意味着,如果我们幸运的话,我们会捕获到 CPU 上运行的 rootkit 线程,并且如果我们不时发送足够的 NMI,则可以遍历堆栈以查找未支持的内存指针。
首先,必须定义 NMI 回调:
BOOLEAN
NmiCallback(PVOID context, BOOLEAN handled)
{
PNMI_CONTEXT nmiContext =(PNMI_CONTEXT)context;
ULONG procNum =KeGetCurrentProcessorNumber();
nmiContext[procNum].threadId =HandleToULong(PsGetCurrentThreadId());
// capture the stack trace
nmiContext[procNum].framesCaptured =RtlCaptureStackBackTrace(
0,
STACK_CAPTURE_SIZE,
(PVOID*)nmiContext[procNum].stackFrames,
NULL
);
return TRUE;
}
由于 NMI 不应运行太长时间,因此出于稳定性考虑,我们将信息保存到堆分配的内存中,并在另一个线程中解析其数据:
VOID
AnalyzeNmiData()
{
for(auto core=0u; core<g_numCores;++core)
{
PETHREAD ThreadObj= NULL;
NMI_CONTEXT nmiContext = g_NmiContext[core];
// get the thread object
PsLookupThreadByThreadId(ULongToHandle(nmiContext.threadId),
&ThreadObj);
// Check each stack frame for origin
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);}
}
}
此 logic 与 APC 解析中使用的 logic 几乎相同。现在在主循环中,我们会定期发送 NMI,以期捕获线程:
VOID
SendNMI(IN PVOID StartContext)
{
NTSTATUS NtStatus;
do
{
// Register callback
g_NmiCallbackHandle =KeRegisterNmiCallback(NmiCallback,
g_NmiContext);
// Fire NMI for each core
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);
}
// Unregister the callback
KeDeregisterNmiCallback(g_NmiCallbackHandle);
// Analyze data
AnalyzeNmiData();
SleepMs(5000);
}while(true);
}
由于回调可以被 rootkit 删除,因此我们确保在触发 NMI 之前注册回调,并在之后取消注册。如果我们让它运行足够长的时间,我们迟早会捕获一个指向 unbacked memory 的线程(尽管如果线程大部分时间都在休眠。这有点像在进行内存扫描时捕获beacon ,在这种情况下,通常只捕获混淆的内存)。
结论
虽然我不确定 rootkit 在常见 EDR 产品的威胁模型中的集成程度如何,并且到目前为止还没有真正面临任何检测,但这个实验了解潜在的检测向量。我相信 EDR 在干扰内核组件时会非常小心,因为这会对整个系统的稳定性构成重大风险。但是,我计划花一些时间反转常见的反 Rootkit 驱动程序,以找出它们实现的检测类型。
原文始发于微信公众号(TIPFactory情报工厂):反反rootkit技术的一些探索检测方法
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论