前言
安全软件自保与对抗一直是一个古老的话题,从最早的VKING蠕虫对抗杀毒软件再到直接物理句柄(physical handle)写入,再到debug后门API….到现在的各种驱动漏洞利用.随着国内外黑产/各大APT/红队 也开始进行对抗,我认为是时候总结一下了。此外本文文末会介绍为什么安全软件不处理这些手法或者仅消极的处理.
法律注意
本篇文章仅总结公开已经发现大规模在野利用的手法,不包括任何非公开手法.仅用于学习和知识补充,想从这篇文章学什么杀毒软件免杀所谓的致盲 的大哥还是别看了,并且仅介绍原理.本篇文章不提供任何代码.
现代安全软件自保机制
ObRegisterCallbacks
在之前的文章我提到过
在XP时代的安全软件的工作是基于SSDT HOOK的,任何R3的API,包括ALPC/注入/各种乱七八糟的API都会通过ssdt/sssdt(shadow ssdt,是win32k的专用API)表到内核里面做操作.可以简单理解为安全软件对系统的颗粒度控制非常高.包括所谓的计划任务RPC都可以通过SSDT HOOK直接解码.(重点)
在win7x64之后,因为微软的PG系统存在(对PG感兴趣吗?感兴趣给公众号留言单独开一篇),ssdt 无法hook了,至少无法正常的手段hook(etw hook这种歪门邪道除外,这种歪门邪道非常容易被微软拉黑驱动签名从而再也无法加载).所以win7x64的安全都交给了垃圾的微软回调
而win7之后,就使用OB回调了,ObRegisterCallbacks是Windows操作系统提供的一种内核回调注册机制,允许驱动程序监控和过滤对象管理器操作。这是现代安全软件最常用的自保护机制之一。
主要功能包括:
-
进程创建与终止的监控
-
句柄操作的监控与过滤
-
对象访问权限的控制
而现代杀毒软件,基本上都是通过ob回调进行自保。这也就是为什么常规的openprocess可以打开进程但是没办法结束的原因,因为权限被去掉了:
简单的代码例子保护notepad
(注意是AI写的我懒得写了别直接用就当作伪代码看就行):
#include <ntddk.h>
#include <wdm.h>
// 全局变量存储回调句柄
PVOID g_RegisterHandle = NULL;
// 进程保护名单
#define MAX_PROCESS_NAME 50
WCHAR g_ProtectProcessName[MAX_PROCESS_NAME] = L"notepad.exe";
// Pre-Operation回调函数
OB_PREOP_CALLBACK_STATUS PreOperationCallback(
PVOID RegistrationContext,
POB_PRE_OPERATION_INFORMATION PreInfo
) {
UNREFERENCED_PARAMETER(RegistrationContext);
if (PreInfo->ObjectType != *PsProcessType) {
return OB_PREOP_SUCCESS;
}
// 获取进程名称
PEPROCESS targetProcess = (PEPROCESS)PreInfo->Object;
PUNICODE_STRING procName = NULL;
if (NT_SUCCESS(SeLocateProcessImageName(targetProcess, &procName))) {
// 检查是否是被保护的进程
if (wcsstr(procName->Buffer, g_ProtectProcessName)) {
// 移除进程终止权限
if (PreInfo->Operation == OB_OPERATION_HANDLE_CREATE) {
ACCESS_MASK denyMask = PROCESS_TERMINATE | PROCESS_VM_WRITE;
PreInfo->Parameters->CreateHandleInformation.DesiredAccess &= ~denyMask;
}
}
ExFreePool(procName);
}
return OB_PREOP_SUCCESS;
}
// 注册回调
NTSTATUS RegisterObCallback() {
OB_CALLBACK_REGISTRATION obReg = {0};
OB_OPERATION_REGISTRATION opReg = {0};
// 初始化回调注册结构
obReg.Version = OB_FLT_REGISTRATION_VERSION;
obReg.OperationRegistrationCount = 1;
obReg.RegistrationContext = NULL;
RtlInitUnicodeString(&obReg.Altitude, L"321000");
// 设置操作回调
opReg.ObjectType = PsProcessType;
opReg.Operations = OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE;
opReg.PreOperation = PreOperationCallback;
opReg.PostOperation = NULL;
obReg.OperationRegistration = &opReg;
return ObRegisterCallbacks(&obReg, &g_RegisterHandle);
}
// 驱动卸载函数
VOID UnloadDriver(IN PDRIVER_OBJECT DriverObject) {
UNREFERENCED_PARAMETER(DriverObject);
if (g_RegisterHandle) {
ObUnRegisterCallbacks(g_RegisterHandle);
}
KdPrint(("Driver Unloadedn"));
}
// 驱动入口函数
NTSTATUS DriverEntry(IN PDRIVER_OBJECT DriverObject, IN PUNICODE_STRING RegistryPath) {
UNREFERENCED_PARAMETER(RegistryPath);
NTSTATUS status;
// 设置卸载函数
DriverObject->DriverUnload = UnloadDriver;
// 注册回调
status = RegisterObCallback();
if (!NT_SUCCESS(status)) {
KdPrint(("Failed to register callbacks: 0x%Xn", status));
return status;
}
KdPrint(("Driver Loaded Successfullyn"));
return STATUS_SUCCESS;
}
Ring-3对抗
ObRegisterCallbacks固然很好,理论上过滤掉关闭权限后自己就高枕无忧.但是实际上来说,有非常多的办法解决它,让我们先从R3开始说起
泄漏句柄问题
lsass和一些系统服务,包括处理不好的父进程子进程都会携带安全软件的完整权限句柄并且安全软件还没办法轻易的解决它
以lsass.exe为例,lass会负责令牌管理功能,包括一些比如winhttp/com操作都要让lsass.exe对其他进程有最高权限的句柄,为什么没办法拦截lsass的操作?因为lsass要负责:
当新进程创建时,LSASS需要为其生成访问令牌(Access Token)
需要访问目标进程以注入/设置令牌
管理进程的安全上下文(Security Context)
如果有域账户,更加麻烦…
lsass/svchost等系统服务有完整权限句柄权限是在安全软件进程创建的时候就有了的,如果那个时候驱动降权了,会导致进程无法启动.
对于句柄泄露的问题,五年前我提交过CNVD(而且最早WIN7那会就有木马和外挂开始利用了,已经是属于烂大街的东西.所以下面的代码真不是什么神奇魔法或者其他违法的东西)
补天对R3的漏洞回应: “此技术已经在自保界家常便饭,所以不收”
CNVD对R3的漏洞回应: “此漏洞过于复杂的触发条件而且无法造成实际性破坏,不收,感谢提交”
如何利用?
所以我们可以先搜集电脑上存在的句柄列表
然后编写shellcode注入到里面,这个shellcode就干一件事,利用现在的句柄terminalprocess
就能轻松的结束掉任何没对此做处理的进程。非常简单,极具破坏性.
当然win10之后lsass可以打开PPL保护防止被利用.这个是后话,记住我只是举个例子,系统里面很多服务都有完整权限.而且不能轻易去掉。
缓解句柄泄露
其实很简单,我们可以直接对句柄进行降权,这一招隔壁游戏反作弊用烂大街了.
(不过需要注意的一点是,需要主程序代码一起配合,被保护的杀毒软件的核心功能不能有任何需要跟lsass交互的代码,比如com/winhttp)
在windows系统中,所谓的handle是放在eprocess的一个句柄表(handletable)里面的:
bool __fastcall ObFindHandleForObject(
struct _EX_RUNDOWN_REF *eprocess,
__int64 object,
__int64 a3,
__int64 a4,
__int64 *handle)
{
bool v9; // bl
__int64 handle_table; // rcx
__int64 object_header[5]; // [rsp+20h] [rbp-28h] BYREF
v9 = 0;
handle_table = ObReferenceProcessHandleTable(eprocess);
if ( handle_table )
{
if ( object )
object_header[0] = object - 0x30;
else
object_header[0] = 0i64;
object_header[1] = a3;
object_header[2] = a4;
v9 = (unsigned __int8)ExEnumHandleTable(
handle_table,
(__int64 (__fastcall *)(__int64, signed __int64 *, __int64, __int64))ObpEnumFindHandleProcedure,
(__int64)object_header,// EnumParameter
handle) != 0;
ExReleaseRundownProtection_0(eprocess + 95);
}
return v9;
}
而我们所谓的权限(什么vm_read,vm_write) 其实是这里面的一个成员结构叫做GrantedAccessBits.
nt!_HANDLE_TABLE_ENTRY
+0x000 VolatileLowValue : Int8B
+0x000 LowValue : Int8B
+0x000 InfoTable : Ptr64 _HANDLE_TABLE_ENTRY_INFO
+0x008 HighValue : Int8B
+0x008 NextFreeHandleEntry : Ptr64 _HANDLE_TABLE_ENTRY
+0x008 LeafHandleValue : _EXHANDLE
+0x000 RefCountField : Int8B
+0x000 Unlocked : Pos 0, 1 Bit
+0x000 RefCnt : Pos 1, 16 Bits
+0x000 Attributes : Pos 17, 3 Bits
+0x000 ObjectPointerBits : Pos 20, 44 Bits
+0x008 GrantedAccessBits : Pos 0, 25 Bits
+0x008 NoRightsUpgrade : Pos 25, 1 Bit
+0x008 Spare1 : Pos 26, 6 Bits
+0x00c Spare2 : Uint4B
也就是说。我们完全可以自己遍历handle table,然后主动降权所有泄露的句柄权限.代码如下
(2018年写的,写的很丑,见谅)
BOOLEAN StripHandleCallback_win10(
IN PHANDLE_TABLE HandleTable,
IN PHANDLE_TABLE_ENTRY HandleTableEntry,
IN HANDLE Handle,
IN PVOID EnumParameter
)
{
BOOLEAN result = FALSE;
POBJECT_TYPE ObjectType = NULL;
ULONG64 Object = 0;
if (g_FlagProcessPid == (HANDLE)-1)
return FALSE;
if (ExpIsValidObjectEntry(HandleTableEntry))
{
POBJECT_TYPE ObjectType = NULL;
ULONG64 Object = 0;
if (Handle == (HANDLE)EnumParameter)
{
HandleTableEntry->GrantedAccessBits = (SYNCHRONIZE | THREAD_QUERY_LIMITED_INFORMATION);
//DebugPrint("Fuck Handle: %08X n", Handle);
goto _exit;
}
}
else {
return FALSE;
}
_exit:
// Release implicit locks
_InterlockedExchangeAdd8((char*)&HandleTableEntry->VolatileLowValue, 1); // Set Unlocked flag to 1
if (HandleTable != NULL && HandleTable->HandleContentionEvent)
ExfUnblockPushLock(&HandleTable->HandleContentionEvent, NULL);
return FALSE;
}
BOOLEAN StripHandleCallback_win7(PHANDLE_TABLE_ENTRY HandleTableEntry, HANDLE Handle, PVOID EnumParameter)
{
POBJECT_TYPE ObjectType = NULL;
ULONG64 Object = 0;
if (g_FlagProcessPid == (HANDLE)-1)
return FALSE;
if (ExpIsValidObjectEntry(HandleTableEntry))
{
if (Handle == (HANDLE)EnumParameter)
{
HandleTableEntry->GrantedAccessBits = (SYNCHRONIZE | THREAD_QUERY_LIMITED_INFORMATION);
//DebugPrint("Fuck Handle: %08X n", Handle);
return FALSE;
}
}
return FALSE;
}
VOID StripHandlePermission()
{
PSYSTEM_HANDLE_INFORMATION_EX HandleInfo = QueryHandleTable();
if (HandleInfo) {
for (int i = 0; i < HandleInfo->NumberOfHandles; i++)
{
//7 是 process 属性
if (HandleInfo->Information[i].ObjectTypeNumber == 7 || HandleInfo->Information[i].ObjectTypeNumber == OB_TYPE_INDEX_PROCESS || HandleInfo->Information[i].ObjectTypeNumber == OB_TYPE_INDEX_THREAD)
{
if (g_FlagProcessPid == (HANDLE)-1)
break;
if (HandleInfo->Information[i].ProcessId == (ULONG)g_FlagProcessPid || HandleInfo->Information[i].ProcessId == 4)
continue;
bool bCheck = ((HandleInfo->Information[i].GrantedAccess & PROCESS_VM_READ) == PROCESS_VM_READ ||
(HandleInfo->Information[i].GrantedAccess & PROCESS_VM_OPERATION) == PROCESS_VM_OPERATION ||
(HandleInfo->Information[i].GrantedAccess & PROCESS_VM_WRITE) == PROCESS_VM_WRITE);
PEPROCESS pEprocess = (PEPROCESS)HandleInfo->Information[i].Object;
if (pEprocess) {
HANDLE handle_pid = *(PHANDLE)((PUCHAR)pEprocess + g_OsData.UniqueProcessId);
HANDLE handle_pid2 = *(PHANDLE)((PUCHAR)pEprocess + g_OsData.InheritedFromUniqueProcessId);
if (bCheck && (handle_pid == g_FlagProcessPid || handle_pid2 == g_FlagProcessPid)) {
pEprocess = NULL;
NTSTATUS status = PsLookupProcessByProcessId((HANDLE)HandleInfo->Information[i].ProcessId, &pEprocess);
if (NT_SUCCESS(status)) {
//DebugPrint("Full Acess Handle! pid: %d n", HandleInfo->Information[i].ProcessId);
PHANDLE_TABLE HandleTable = *(PHANDLE_TABLE*)((PUCHAR)pEprocess + g_OsData.ObjTable);
ExEnumHandleTable(HandleTable, g_isWin7 ? (DWORD64*)&StripHandleCallback_win7 : (DWORD64*)&StripHandleCallback_win10, (PVOID)HandleInfo->Information[i].Handle, NULL);
ObDereferenceObject(pEprocess);
}
}
}
}
}
ExFreePoolWithTag(HandleInfo, POOL_TAG);
}
DebugPrint("StripHandlePermission Success n");
}
这样我们就可以完全避免高权限的句柄泄露,从而阻止此类攻击方法.
窗口消息-win32k攻击面
我之前说过
win32k可以说跟windows的内核机制是两套东西.窗口句柄管理等不受NTOS内核的控制, 另外win32k之前是R3的,为了性能搬迁到R0,导致出现了一个R3->R0的攻击面,非常多经典的windows漏洞都出自这个攻击面里面.
相信大家看到过很多的国内和国外的经典小黑meme《xx杀毒软件终结者》
结果一看是postmessage个窗口
一般来说都是用EnumWindows枚举窗口后给目标窗口发个WM_CLOSE或者其他乱七八糟啥的消息
#include <windows.h>
#include <iostream>
#include <string>
#include <vector>
// 窗口信息结构体
struct WindowInfo {
HWND hwnd;
std::wstring title;
std::wstring className;
};
// 用于EnumWindows回调的向量
std::vector<WindowInfo> windowList;
// EnumWindows的回调函数
BOOL CALLBACK EnumWindowsProc(HWND hwnd, LPARAM lParam) {
// 获取进程ID
DWORD processId;
GetWindowThreadProcessId(hwnd, &processId);
// 获取窗口标题
wchar_t title[256];
int titleLength = GetWindowTextW(hwnd, title, 256);
// 获取窗口类名
wchar_t className[256];
GetClassNameW(hwnd, className, 256);
// 输出调试信息
std::wcout << L"发现窗口 - 句柄: " << hwnd
<< L", 进程ID: " << processId
<< L", 标题长度: " << titleLength
<< L", 类名: " << className;
// 检查窗口状态
bool isVisible = IsWindowVisible(hwnd);
bool isUnicode = IsWindowUnicode(hwnd);
bool isEnabled = IsWindowEnabled(hwnd);
std::wcout << L" [可见性:" << isVisible
<< L", Unicode:" << isUnicode
<< L", 启用:" << isEnabled << L"]" << std::endl;
if (IsWindow(hwnd) && titleLength > 0) {
WindowInfo info;
info.hwnd = hwnd;
info.title = title;
info.className = className;
if (info.className.find(L"ProtectedWindow") != -1) {
windowList.push_back(info);
}
windowList.push_back(info);
}
return TRUE;
}
// 显示所有窗口
void ShowWindows() {
windowList.clear();
::EnumWindows(EnumWindowsProc, 0);
std::cout << "n可用窗口列表:" << std::endl;
std::cout << "序号t句柄tt标题t类名字" << std::endl;
std::cout << "----------------------------------------" << std::endl;
for (size_t i = 0; i < windowList.size(); i++) {
printf("%dt%08xt%wst%wsn",i, windowList[i].hwnd, windowList[i].title.c_str(), windowList[i].className.c_str());
}
}
// 发送消息到指定窗口
void SendMessageToWindow(HWND hwnd, UINT message) {
// 尝试发送消息
LRESULT result = SendMessage(hwnd, message, 0, 0);
std::cout << "消息已发送,返回值: " << result << std::endl;
// 也可以尝试PostMessage
PostMessage(hwnd, message, 0, 0);
std::cout << "PostMessage已发送" << std::endl;
}
int main() {
// 设置控制台编码为UTF-8
//SetConsoleOutputCP(CP_UTF8);
while (true) {
std::cout << "n=== Windows消息发送测试工具 ===" << std::endl;
std::cout << "1. 刷新窗口列表" << std::endl;
std::cout << "2. 发送WM_CLOSE消息" << std::endl;
std::cout << "3. 发送自定义消息" << std::endl;
std::cout << "0. 退出" << std::endl;
std::cout << "请选择操作: ";
int choice;
std::cin >> choice;
if (choice == 0) break;
switch (choice) {
case 1:
ShowWindows();
break;
case 2:
case 3: {
ShowWindows();
std::cout << "请输入目标窗口序号: ";
size_t index;
std::cin >> index;
if (index >= windowList.size()) {
std::cout << "无效的窗口序号!" << std::endl;
break;
}
HWND targetHwnd = windowList[index].hwnd;
if (choice == 2) {
std::cout << "正在发送WM_CLOSE消息..." << std::endl;
SendMessageToWindow(targetHwnd, WM_CLOSE);
}
else {
std::cout << "请输入消息ID(十六进制,如0x0010): ";
std::string msgHex;
std::cin >> msgHex;
// 将十六进制字符串转换为数值
UINT msgId;
try {
msgId = std::stoul(msgHex, nullptr, 16);
std::cout << "正在发送消息 0x" << std::hex << msgId << "..." << std::endl;
SendMessageToWindow(targetHwnd, msgId);
}
catch (...) {
std::cout << "无效的消息ID!" << std::endl;
}
}
break;
}
default:
std::cout << "无效的选择!" << std::endl;
break;
}
}
return 0;
}
还有一些是利用窗口的其他机制做攻击(比如KeUserModeCallback机制,模拟按键),我们这里就不展开.
我之前也在key08发过:
为什么这个东西如此好用,因为我们前面介绍的ob回调是没办法解决窗口消息的,事实上win32k是完全不受到NTOS的机制的控制的,窗口句柄权限什么的是独立的,而且窗口消息是没回调的。
缓解win32k攻击面
隐藏窗口
win32k的唯一的好处是,它的内核指针是不受到pg保护的.有些安全软件就为了防止自己的窗口被干,会选择隐藏窗口或者阻止窗口消息.(事实上,某些知名安全软件这样干挺久了).这里就不细说了,因为这块发代码或者思路黑产哥永远比白帽哥跟进的快 (比如拿去做挂…)
分离主程序与服务程序
更温柔一点的办法是分离主程序与服务程序,即便是窗口被关了,也应该跟保护功能没关系,这个是代码设计层面问题
KeUserModeCallback
这个在att&ck中也有映射
https://attack.mitre.org/techniques/T1574/013/
窗口回调都是通过KeUserModeCallback进行执行的
正常的系统调用过程为Ring3->Ring0->Ring3,而KeUserModeCallback提供了一种Ring0->Ring3->Ring0的方式,即从内核去用户层执行代码。
这里转载网上的(作者@revercc):
https://github.com/revercc
KeUserModeCallback (
IN ULONG ApiNumber, //对应函数在KernelCallback表中的索引
IN PVOID InputBuffer, //ApiNumber不同(即调用的函数不同),此参数对应不同的结构
IN ULONG InputLength,
OUT PVOID *OutputBuffer, //执行后输出的结果
IN PULONG OutputLength
)
KeUserModeCallback是ntoskrnl.exe导出的,系统中很多回调机制都是通过KeUserModeCallback实现的。
其参数ApiNumber是需要执行的回调函数在KernelCallback回调函数表中的索引,这张回调函数表的地址存放在PEB->KernelCallback中的每一个函数都有不同的用处,在windbg中如下。
其对应的回调函数表为:
第二个参数InputBuffer会根据ApiNumber索引回调函数的不同而指向不同的结构体,第四个参数OutputBuffer指向的缓冲区中是执行完回调函数后返回的信息。
一般是含有GDI系统服务的win32k.sys调用KeUserModeCallback,所以一般要HOOKKeUserModeCallback时都会通过IAThook win32k.sys的导入表中对KeUserModeCallback的调用。接着会调用nt!KiCallUserMode—->KiServiceExit—->sysexit进入应用层中,然后在应用层执行的第一个函数就是ntdll!KiUserCallbackDispatcher。
win32k.sys --->nt!KeUserModeCallback --->nt!KiCallUserMode--->KiServiceExit--->sysexit //Ring0--->Ring3
ntdll!KiUserCallbackDispatcher--->对应KernelCallback表中的回调函数 //Ring3
对应KernelCallback表中的回调函数--->User32!XyCallbackReturn--->int 0x2e //Ring3--->Ring0
nt!KiCallbackReturn--->nt!KeUserModeCallback //Ring0
我们通常利用SetWindwosHook设置全局消息钩子来实现dll注入,其实其就是利用nt!KeUserModeCallback函数来完成函数的调用的。
当我们在一个应用 程序中调用SetWindowsHook安装全局消息钩子并设置hook回调函数后,如果我们这时候打开另一个程序,如果此程序是一个GUI程序,其在加载过程中会产生一些消息,这样系统就会尝试调用我们刚刚注册的全局消息回调函数,如果发现此回调函数对应的dll还没有加载其就会尝试加载此dll,从而实现dll的注入。
在了解到原理后,我们就可以通过挂钩KernelCallbackTable阻止各种callback,各大反作弊都挂了这玩意。
举个例子,Storm-0978用的”Step Bear”注入技术,核心一点是EM_SETWORDBREAKPROC消息。而我们完全可以挂钩对应的函数,检查来源和目标.从而避免被窗口消息滥用(比如各大游戏反作弊会检查callback函数的位置和参数避免自己被滥用)
模拟按键
有些恶意软件会使用模拟按键帮助用户“按下”退出按钮。这也很好检测,使用低级钩子(mouse&key)注册回调,判断消息是不是LLMHF_INJECTED就能知道 是不是注入的消息。各大基于模拟按键的操作的对抗就失效了。比如卡巴斯基的窗口你是没办法用模拟按键的就是这个原理。
其他
这些技术奇奇怪怪,有ALPC也有DCOM也有系统机制,比如最近公开的”EDR滑铁卢”
https://key08.com/index.php/2024/07/06/1939.html
也是一个很古老的技术了.做过WFP开发的兄弟们都应该知道这个是啥玩意.这也不是什么神奇魔法,是windows的机制.
其他的一些技术这里就不说了.因为说了大概率会被利用。我们只说公开常见的利用手段。
ring-0
现如今大部分红队都开用所谓的BYVOD驱动做对抗.这边得说一句,BYVOD这个就是纯国外小黑meme的名字不小心流传开了.
这块之前被反作弊和作弊软件对抗最激烈的地方.随着kdmapper/卡巴斯基hook/某神漏洞驱动的火出圈以及lockbit滥用关杀毒软件,传到了信息安全领域。
在2014年左右这些在业内还是被叫做 “vulnerable driver” 也不是什么神秘的东西,我知道的到现在也就几十年了吧(实际可能更早,比如NSA的那个远控XP时代就用了)
这些驱动有几个类型,但是无非就是跟R3交互接口没验证签名导致函数被滥用。
我们一起来说.
直接物理内存读写
在早期我印象中的漏洞驱动,都是一些主板刷bios/cpuz/gpuz的驱动,这些驱动有一些物理内存读写的API,忘记做校验导致被利用.利用去关闭dse
这里以我五年前写的GPUZ漏洞利用为例:
不做任何校验,直接用了MMIO:
所以可以给gpuz发控制码读物理内存
而我们只有读物理内存的权利,所以还需要寻找到system PML4 这样才能翻译系统内存。
有了系统PML4后,就可以做地址翻译,把虚拟地址翻译为物理地址.然后进行读写
能做内核任意读写了,就可以干任何事情了,比如关闭驱动DSE
暴力搜特征码关的
关掉后就可以加载任意驱动了,或者跟之前一样, 遍历handle table拉高自己的handle权限 然后关闭安全软件
缓解
在系统windows1809后这种很少见了,因为windows加了一个机制,禁止这些物理内存读写的API访问系统CR3,如果访问了会被蓝屏
打开IDA,可以看到在iMapContiguousMemory的实现里面有这个代码
可以看到,它通过MiFillSystemPtes
if ( (int)MiFillSystemPtes(v16, v12, v24, v5, v17, (__int64)&v23) < 0 )
{
MiReleasePtes(&qword_43C0A0, v16, v14);
return 0i64;
}
阻止了这些API对系统CR3的访问。访问不了系统CR3,也就没办法做后续了。
当然还有后续,后续有个全新的方法继续绕过继续利用,毕竟对抗永无止境:
《[2024]从驱动直接读写物理内存漏洞 到 内存加载驱动分析》
https://key08.com/index.php/2024/08/18/2001.html
kernel side ZwTerminateProcess
现在信息安全领域见得多的还是这种内核发terminalprocess的东西,毕竟没游戏安全对抗那么激烈。大部分情况下关闭就行不做其他的操作
这种情况跟上面说的一样,内核IO管理权限校验没做好,让人可以对任意进程发ZwTerminateProcess.
正确缓解漏洞驱动
实际上,在2020年左右Aidan Khoury的faceit反作弊(也负责后来的VGK反作弊)
就已经开始做缓解了(这个作者长得很帅,建议github star一下)
https://github.com/ajkhoury
-
开机启动
-
标记/阻止 漏洞驱动加载
-
API 挂钩监控
前两个没什么好说的,无非就是拉黑驱动签名和HASH,但是漏洞驱动太多了.我们重点说第三点
在windows系统中,随便挂钩系统驱动可能会被PG蓝屏.但是挂钩第三方驱动不会的.而保险的方式是挂钩驱动IAT,比如欧洲bro挂的BE的驱动的IAT导入函数列表
是的,其实卡巴斯基和一些国外的EDR也挂了很多常见的驱动IAT或者IO HANDLE,目的就是为了防止漏洞驱动利用.
https://git.back.engineering/IDontCode/BELog/src/branch/master/BELog/DriverUtil.cpp#L148
比如某EDR,挂了第三方驱动的ZwTerminateProcess的导入表,一旦有人call了ZwTerminateProcess并且目标是受保护进程,直接阻止并且杀掉进程.
这就是某些EDR防止漏洞驱动利用的原理。
此外一些反作弊也进行挂钩,防止漏洞驱动利用或者内存加载攻击
非常简单而且实用。但是为什么国内没人用呢?
软件开发困境
在前文中,我们探讨了各种安全软件自保护的技术实现和对抗手段。那么你可能会问:既然有这么多技术可以用来加强安全软件的自保护,为什么很多安全软件选择不实现或者仅做最基础的防护呢?这就要谈到安全软件开发中面临的几个主要困境:
兼容性压力
任何激进的保护手段都可能带来兼容性问题。比如前面提到的 IAT HOOK 技术,虽然效果很好,但如果处理不当可能会影响其他正常软件的功能。国外环境还算不错,所以可以这样玩,毕竟国外系统也会一直更新.而且也没那么多奇怪的软件。(比如国内某些下载工具还会带驱动,某些聊天软件也会带驱动,而且还有bug)
所以在国内这样搞,大概率会造成非常多的兼容问题,比如某安全软件日活1000万终端, 即便是只有1%有兼容性问题, 也就是造成10万台蓝屏,如果而且如果写的不好,跟主流软件冲突,那么那个 主流软件安装量 = 你的蓝屏量,到那时候, 就可以打包走人了
软件开发首要原则
我们看了那么多攻击技术,对于TOC产品来说,其实只占主流安全威胁的百分之一,如果只为了几个红队或者APT去让其他人背负蓝屏风险,这个是在概率上不可接受的。宁愿保守一点-比如只拉黑漏洞驱动的hash,也不能让自己的用户群体冒着蓝屏风险去搞驱动对抗。
而TOB产品, 在国内现状,在很多企业看来,安全性可以没那么重要, 业务中蠕虫病毒了但是业务不中断也没到p0事故,如果业务因为安全软件中断了,那就是p0事故了。
所以软件开发的首要原则是稳定。而不是搞对抗,写安全软件的人都是聪明人,这些对抗手段其实在红队或者黑产没发现之前 业内也烂大街了,基本上都知道,知道不会做的原因就在这。
不搞内核对抗是对用户的负责。
正确的解决对抗
agent纯内核实现
大部分情况下,对抗的终极目标是杀掉R3的程序.而一些优秀的产品比如crowdstrike/戎码翼龙,则是纯内核工作,这样就能避免大部分对抗的场景。
比如戎码翼龙 即便是R3的agent被杀掉了,依然能检出威胁
而且这样做风险比直接做对抗小得多,因为不涉及到对抗和UD API
(虽然也有风险,比如crowdstrike极端的主防也在内核做了一个虚拟机,这样太激进了)
选择TO B产品而不是TO C的
在企业实际环境中, 应该首选采用TO B的产品,因为TO C的产品是不可能根据企业环境定制的
而各种所谓的对抗,本质上是企业的投入成本的问题, 如果企业终端安全只是使用TO C的产品, 那么各种针对性对抗就会暴露出来。
TO C的工作流程往往是允许一小部分用户中招- 只有中招了我才能知道这个是不是新的威胁。比如一小步分用户被XX驱动杀了agent, 我就把这个驱动特征拉黑,其他用户运行了这个特征的驱动我就杀掉。
更直白一点: toC的产品往往是只做已知的保护,不做未知威胁的保护
而TO B的产品往往是根据企业环境定制的,深入参与的,例如, 使用准入/零信任 设备联动终端agent,终端出现异常直接自动网关层面隔离主机等待人排查。每天有人hunting企业出现的新的程序,完善的sora和应急方案,进行分析等等工作方法。这样才是阻止未知威胁的最有效的办法,而不是做终端层面的对抗。
往期推荐:
深度研究APT组织Storm0978的高级注入技术StepBear
国庆专题: 深度了解”核晶“的工作原理并且手动实现一个自己的"核晶"
原文始发于微信公众号(冲鸭安全):深度了解现代安全软件对抗与缓解措施
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论