一、前言
前一阵子我一直在研究与SYSTEM权限有关的技术,本周我想暂时休息一下,把目光转到一些反调试技术上。现在许多漏洞奖励计划中都会包含客户端应用程序方面内容,而许多安全产品(以及游戏中的反作弊引擎)会使用各种技术阻止用户调试核心组件,本文中我们介绍了某一种反调试技术以及相应的规避方法。
显而易见的是,本文中介绍的技术并不属于漏洞范畴,如果攻击者已经掌握系统的访问权限,那么就不用费那么多事,他们很有可能已经在系统中装上了rootkit,完成攻击过程。
在本文中我测试的是AVG安全产品,但其他许多AV解决方案及安全产品也使用了完全相同的技术,因此本文介绍的方法也可以在这些产品上使用。
二、具体问题
如果你之前曾经尝试使用x64dbg
调试工具附加(attach)到某个AV组件上,你经常会看到如下画面,这一点已经见惯不怪:
实际结果是调试器无法成功attach到该进程上,我们只能茫然地盯着屏幕,不知下一步该干啥。另外,如果我们不attach,而是选择在调试器中启动该进程,会看到如下画面:
还是得到一样的结果,当程序即将启动时我们就已被踢出局。最后,像其他逆向分析师一样,我们尝试使用WinDBG来分析,结果会得到如下错误信息:
为了理解调试器的工作原理,也为了让我们更好地了解到底哪里出了问题,我们可以分析一下x64dbg的代码(实际上我们需要分析的是TitanEngine的代码,TitanEngine是x64dbg使用的调试引擎),看看我们无法attach进程背后的具体原因。
__declspec(dllexport) bool TITCALL AttachDebugger(DWORD ProcessId, bool KillOnExit, LPVOID DebugInfo, LPVOID CallBack)
{
...
if(ProcessId != NULL && dbgProcessInformation.hProcess == NULL)
{
if(engineEnableDebugPrivilege)
{
EngineSetDebugPrivilege(GetCurrentProcess(), true);
DebugRemoveDebugPrivilege = true;
}
if(DebugActiveProcess(ProcessId))
{
...
}
}
}
这里我们可以看到,x64dbg使用的是DebugActiveProcess.aspx)这个API函数,该API为KernelBase.dll
提供的Win32 API。
三、DebugActiveProcess的工作原理
DebugActiveProcess
负责启动针对目标进程的调试会话,以进程的PID值作为参数。如果我们在MSDN.aspx)上查找这个函数,我们可以得到如下信息:
“调试器必须具备目标进程的合适权限,必须能够以PROCESS_ALL_ACCESS
方式打开目标进程。
如果目标进程创建时使用了安全描述符(security descriptor),使调试器无法获得完全访问权限,那么调用DebugActiveProcess
时可能会失败。如果调试进程具备SE_DEBUG_NAME
权限,那么它就可以调试任何进程。”
这段话给了我们第一个提示信息,提示我们究竟是什么导致我们的调试会话无法正常建立。从上述代码片段中,我们可以看到调试器正在调用EngineSetDebugPrivilege
,那么我们来看看这个函数的具体内容:
DWORD EngineSetDebugPrivilege(HANDLE hProcess, bool bEnablePrivilege)
{
DWORD dwLastError;
HANDLE hToken = 0;
if(!OpenProcessToken(hProcess, TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken))
{
...
}
...
if(!LookupPrivilegeValue(NULL, SE_DEBUG_NAME, &luid))
{
...
}
tokenPrivileges.PrivilegeCount = 1;
tokenPrivileges.Privileges[0].Luid = luid;
if(bEnablePrivilege)
tokenPrivileges.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
else
tokenPrivileges.Privileges[0].Attributes = 0;
AdjustTokenPrivileges(hToken, FALSE, &tokenPrivileges, sizeof(TOKEN_PRIVILEGES), NULL, NULL);
...
}
在上述代码中,我们的进程具备SE_DEBUG_NAME
权限,也就是说我们满足了上述要求,可以从调试器中调用DebugActiveProcess
函数。
接下来我们需要看一下我们是否拥有目标进程的PROCESS_ALL_ACCESS
访问权限。
四、DebugActiveProcess内部工作流程
DebugActiveProcess
这个API仅接受一个参数,那就是目标进程的进程ID,函数内部会调用ProcessIdToHandle
,利用这个值获得目标进程的句柄。
如果我们跳转到ProcessIdToHandle
函数,我们会发现该函数只是NtOpenProcess
API的封装函数:
对于NtOpenProcess
函数,其DesiredAccess
参数的传入值为C3Ah
。查阅相关文档.aspx)后,我们发现这个值为一些标志的组合值,这些标志为:
- PROCESS_CREATE_THREAD
- PROCESS_VM_OPERATION
- PROCESS_VM_WRITE
- PROCESS_VM_READ
- PROCESS_SUSPEND_RESUME
- PROCESS_QUERY_INFORMATION
使用这个值后,函数就可以拥有所需的所有权限,开始调试目标进程。
现在,我们已经知道调试器具备SE_DEBUG_NAME
权限,并且DebugActiveProcess
调用也拿到了目标进程的正确的访问权限。那么,到底是什么在阻止我们调试目标进程?
五、ObRegisterCallbacks登场
当我加入游戏以及游戏修改(modding)社区时,我才第一次见到ObRegisterCallbacks
的身影,社区爱好者在绕过反作弊及DRM驱动时,经常使用该函数在游戏中修改或注入其他功能。
根据微软的说法,ObRegisterCallbacks.aspx)是“一个例程,负责为线程、进程以及桌面句柄操作注册一系列回调例程”。这一过程在内核模式下完成,借助这一功能,当调用OpenProcess
函数以及当OpenProcess
函数返回时,驱动程序可以获得相关通知信息。
但为什么这个函数可以阻止调试器访问AV进程呢?想要阻止程序成功调用DebugActiveProcess
的一种方法就是过滤掉在NtOpenProcess
调用期间所请求的访问权限。阻止调试器请求目标进程的PROCESS_ALL_ACCESS
权限后,我们自然就失去调试该进程的能力。这一方法也顺便解释了我们之前在WinDBG中看到的错误现象。
但是我们还需要确定这是否就是我们当前问题的症结所在。现在让我们使用内核调试器,看一下Ring-0状态下系统如何处理已注册的回调函数。
(我不会在本文中介绍如何设置内核调试器,大家可以查看我之前发表的文章了解具体细节)。
六、ObRegisterCallback内部流程
建立内核调试器连接后,首先我们可以使用nt!ProcessType
来获得一些信息:
kd> dt nt!_OBJECT_TYPE poi(nt!PsProcessType)
+0x000 TypeList : _LIST_ENTRY [ 0xffffcb82`dee6cf20 - 0xffffcb82`dee6cf20 ]
+0x010 Name : _UNICODE_STRING "Process"
+0x020 DefaultObject : (null)
+0x028 Index : 0x7 ''
+0x02c TotalNumberOfObjects : 0x26
+0x030 TotalNumberOfHandles : 0xe8
+0x034 HighWaterNumberOfObjects : 0x26
+0x038 HighWaterNumberOfHandles : 0xea
+0x040 TypeInfo : _OBJECT_TYPE_INITIALIZER
+0x0b8 TypeLock : _EX_PUSH_LOCK
+0x0c0 Key : 0x636f7250
+0x0c8 CallbackList : _LIST_ENTRY [ 0xffffa002`d31bacd0 - 0xffffa002`d35d2450 ]
该符号提供了指向_OBJECT_TYPE
对象的一个指针,该对象定义了“Process”类型,其中我们最感兴趣的是CallbackList
属性。这个属性定义了由ObRegisterCallbacks.aspx)注册的一系列回调函数,随后,当有程序尝试获取进程句柄时内核就会调用这些回调函数。知道这些信息后,我们可以遍历回调函数列表,查找有哪些已注册的回调会干扰我们调用OpenProcess
。
CallbackList
是指向CALLBACK_ENTRY_ITEM
结构的一个LIST_ENTRY
。微软文档中并没有公开这个结构,感谢“DOUGGEM’S GAME HACKING AND REVERSING NOTES”这个网站,我们最终可以得知该结构的具体定义,如下所示:
typedef struct _CALLBACK_ENTRY_ITEM {
LIST_ENTRY EntryItemList;
OB_OPERATION Operations;
CALLBACK_ENTRY* CallbackEntry;
POBJECT_TYPE ObjectType;
POB_PRE_OPERATION_CALLBACK PreOperation;
POB_POST_OPERATION_CALLBACK PostOperation;
__int64 unk;
}CALLBACK_ENTRY_ITEM, *PCALLBACK_ENTRY_ITEM;
这里我们关心的是该结构中的PreOperation
属性。
我们可以使用如下WinDBG命令来遍历CALLBACK_ENTRY_ITEM
列表:
!list -x ".if (poi(@$extret+0x28) != 0) { u poi(@$extret+0x28); }" (poi(nt!PsProcessType)+0xc8)
在我的测试环境中,我总共发现了4个驱动使用ObRegisterCallbacks
注册了PreOperation
回调函数。我们可以使用WinDBG获得驱动名称,以便后续分析:
!list -x ".if (poi(@$extret+0x28) != 0) { lmv a (poi(@$extret+0x28)) }" (poi(nt!PsProcessType)+0xc8)
上面列出的4个驱动中,有一个非常值得怀疑,那就是avgSP.sys
:
“AVG自我保护模块(AVG self protection module)”这个驱动会限制我们使用调试器来附加到相关进程(其实该驱动最大的作用很有可能是防止恶意软件篡改反病毒引擎)。让我们深入分析这个驱动,看是否有任何蛛丝马迹表明该驱动的确会修改我们的OpenProcess
调用。
首先,搜索ObRegisterCallbacks
后,我们可以找到某个处理程序的注册过程,如下所示:
如果我们检查已注册的处理程序,我们很快就能看到如下类似信息:
上图显示的汇编代码中,A0121410
这个魔术值实际上对应的是如下权限:
- PROCESS_VM_READ
- PROCESS_QUERY_INFORMATION
- PROCESS_QUERY_LIMITED_INFORMATION
- READ_CONTROL
- SYNCHRONIZE
如果只设置了这些权限,那么驱动就不会执行后续检查过程,因而就不会过滤OpenProcess
调用。然而,除了这几个白名单权限以外,如果程序请求其他权限,驱动会进一步执行一些检查过程,最终在调用返回前过滤掉相应的权限:
这里我不会详细介绍驱动的工作细节,因为本文的目的是介绍如何识别并移除各种产品所使用的这种hook的通用方法,根据前面已知的这些信息,我们已经找到了干扰并修改OpenProcess
调用的那个驱动。
找到幕后黑手后,现在我们可以从内核中去掉已绑定的这个处理程序。
七、解开OpenProcess过滤器
如果想解开OpenProcess
过滤器,我们首先需要找到与过滤器函数对应的PreOperation
属性的地址。我们可以使用如下WinDBG命令完成这一任务:
!list -x ".if (poi(@$extret+0x28) != 0) { .echo handler at; ?? @$extret+0x28; u poi(@$extret+0x28); }" (poi(nt!PsProcessType)+0xc8)
识别出正确的地址后,我们可以清空这个指针,禁用掉处理函数,具体命令如下:
eq 0xffffa002`d31bacf8 0
此时,如果我们重新attach调试器,我们可以看到如下画面:
非常好,看上去我们已经打败了这个反调试技术!
好吧,其实结果并不完美。经过一些交互操作后,我们注意到调试器中会出现一些错误信息,事情并没有那么顺利。即使是在上一张图中,我们也可以发现寄存器的值全部为0,并且出现了访问冲突(access violation)问题,肯定还有一些细节我们没有注意到。
八、解开线程过滤器
根据前文描述,我们已经知道ObRegisterCallbacks
可以用来hook OpenProcess
,但这个函数还有其他用途吗?如果我们回头查一下官方文档.aspx),就会知道该函数也可以用来hook OpenThread
调用:
幸运的是,最难的那一部分工作我们已经完成了,现在我们所需要做的就是找到线程回调函数所存储的具体位置,该位置对应的是nt!PsThreadType
。
我们可以修改前面用过的那条WinDBG命令,查看这个驱动是否hook了OpenThread
:
!list -x ".if (poi(@$extret+0x28) != 0) { .echo handler at; ?? @$extret+0x28; u poi(@$extret+0x28); }" (poi(nt!PsThreadType)+0xc8)
果不其然,我们找到了这个hook。与进程hook类似,我们可以使用类似的eq
命令解开这个过滤器:
eq 0xffffc581`89df32e8 0
再次启动调试器:
现在,我们终于得到了完美的调试器会话。
九、总结
许多安全产品会使用这种反调试方法,希望阅读本文后,大家对这种方法有所了解。如果大家对此感兴趣,可以探索一下相关的漏洞奖励项目,比如bugcrowd上有一些安全产品的漏洞奖励计划,这些产品包括AVG、Cylance、Sophos等(但我并没有将本文介绍的这种方法以漏洞内容提交,因为DKOM(Direct Kernel Object Manipulation,直接操作内核对象)技术很有可能不属于漏洞奖励范畴)。