DebugBlocker的研究学习,基于2022鹏城杯BUG之眼的初步探索。
Everyone want to debug me,why not debug myself?
0x00 技术原理
父进程创建并调试子进程,父子进程大多为同一可执行程序,并且通过 IsDebuggerPresent等检测调试的技术来使父子进程执行不同代码。这一特征类似fork,但因为父子进程是调试和被调试的关系,所以子进程无法直接attach调试,然而真正的逻辑往往都在修改后的子进程,故拿到真正的逻辑有一定的难度,DebugBlocker技术是比较硬核的一种反调试技术。
该类技术和SMC技术以及异常处理机制形影不离,其中父进程负责恢复控制流和程序代码,子进程则执行真正的程序代码。
0x01 常用API和结构体
1、CreateProcessA
创建新进程及其主线程
BOOL CreateProcessA(
[in, optional] LPCSTRlpApplicationName,
[in, out, optional] LPSTR lpCommandLine,
[in, optional] LPSECURITY_ATTRIBUTES lpProcessAttributes,
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in]BOOL bInheritHandles,
[in]DWORD dwCreationFlags,
[in, optional] LPVOIDlpEnvironment,
[in, optional] LPCSTRlpCurrentDirectory,
[in]LPSTARTUPINFOAlpStartupInfo,
[out] LPPROCESS_INFORMATION lpProcessInformation
);
typedef struct _PROCESS_INFORMATION {
HANDLE hProcess; //新进程的句柄
HANDLE hThread; //新建进程的主线程的句柄
DWORD dwProcessId; //PID
DWORD dwThreadId; //TID
} PROCESS_INFORMATION, *PPROCESS_INFORMATION, *LPPROCESS_INFORMATION;
其中最重要的是dwCreationFlags
标志位,其值表示着进程创建标志,各个值可通过或随机组合。
常量 | 值 | 意义 |
---|---|---|
DEBUG_PROCESS | 0x00000001 | 启动并调试新进程,可使用 WaitForDebugEvent接受相关调试事件 |
DEBUG_ONLY_THIS_PROCESS | 0x00000002 | 如果和DEBUG_PROCESS同时选择,则调用方只能调试该新进程 |
更多详见进程创建标志 (WinBase.h) - Win32 应用|微软文档 (microsoft.com),上述两个标志值为最基础的设置调试关系,即dwCreationFlags
的值为1或3时。
2、WaitForDebugEvent
等待调试进程中的调试事件
BOOL WaitForDebugEvent(
[out] LPDEBUG_EVENT lpDebugEvent,
[in] DWORD dwMilliseconds
);
其中要了解DEBUG_EVENT
结构体
typedef struct _DEBUG_EVENT {
DWORD dwDebugEventCode;
DWORD dwProcessId;
DWORD dwThreadId;
union {
EXCEPTION_DEBUG_INFO Exception;
...
} u;
} DEBUG_EVENT, *LPDEBUG_EVENT;
dwDebugEventCode
标识调试事件的类型,主要关注其中的异常事件和进程退出事件。
对于异常则关注 u.Exception.ExceptionRecord结构体
typedef struct _EXCEPTION_RECORD {
DWORDExceptionCode;
DWORDExceptionFlags;
struct _EXCEPTION_RECORD *ExceptionRecord;
PVOIDExceptionAddress;
DWORDNumberParameters;
ULONG_PTRExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD;
需要了解一些常见的异常类型及其值
一般题目中常以int3断点触发0x80000003异常,或访问未分配(不合理)的地址触发0xc0000005异常。
3、Get/Set Context
获取或设置指定线程的上下文
BOOL GetThreadContext(
[in] HANDLEhThread, //线程句柄
[in, out] LPCONTEXT lpContext //上下文结构指针
);
BOOL SetThreadContext(
HANDLE hThread,
CONST CONTEXT * lpContext );
而上下文主要是指寄存器上下文,往往会修改ip的值来修改控制流走向。
4、Read/Write ProcessMemory
向指定的进程中写入内存,要写入的区域必须有写权限。
这里并不是多个进程实现了共享内存,而是对指定进程的某地址进行了读和写。
BOOL WriteProcessMemory(
HANDLE hProcess, //进程句柄
LPVOID lpBaseAddress, //基址的指针
LPVOID lpBuffer, //要写入数据的指针
DWORD nSize, //写入的字节数
LPDWORD lpNumberOfBytesWritten );
BOOL ReadProcessMemory(
[in] HANDLE hProcess,
[in] LPCVOID lpBaseAddress,//读取内存的基址
[out] LPVOID lpBuffer, //存放读出数据的缓冲区
[in] SIZE_T nSize,//读取字节数
[out] SIZE_T *lpNumberOfBytesRead
);
5、ContinueDebugEvent
调试器继续调试新进程
BOOL ContinueDebugEvent(
[in] DWORD dwProcessId, //继续调试的PID
[in] DWORD dwThreadId, //继续调试的TID
[in] DWORD dwContinueStatus //继续调试事件的选项
);
dwContinueStatus
一般为DBG_CONTINUE
常量(0x10002),表示异常已经得到处理,继续执行。
0x02 例题解析
接下来通过分析2022鹏城杯的BUG之眼来进一步了解该技术,此题由父->子->孙子,修改完的逻辑在孙子进程中,并且是将主体代码分成了多块,边执行边修改,很好的隐藏了逻辑代码和控制流。
int __cdecl main(int argc, const char **argv, const char **envp)
{
if ( IsDebuggerPresent() )
sub_1400024B0();
else
sub_140002D50();
return 0;
}
main函数中通过IsDebuggerPresent
函数来区分执行代码,当为被调试身份时执行if块语句,不处于调试器状态即最开始的进程执行else语句块。
else块函数
首先是获取当前文件路径,并且调用CreateProcessA
创建新进程,并且dwCreationFlags
的值为1,即启动并调试新进程。
随后启动WaitForDebugEvent
来等待调试事件,主要关注dwDebugEventCode
为1时,即收到了调试进程的异常事件。
0x80000003是遇到的int3断点(0xcc),并通过异常相关结构体获取异常地址,GetThreadContext
获取主线程的rip回退到异常触发的哪一个字节,并用WriteProcessMemory将0xcc替换为0xc3即ret指令。
之后是将主线程的enc_data处的数据读入v9,v15是enc_data的地址转为10进制字符,5368733776(0x140006050)。之后按照其长度为一组进行逐字节异或,解密出的数据再写回新建进程,通过ContinueDebugEvent
通知调试器继续调试。
可见父进程的工作即捕获异常点,处理异常并解密子进程相关的数据(SMC)。
if块函数
if块则主要是由被调试进程即子进程执行的代码。
该块首部的代码类似else块,但是通过像内存中写入0xcc,之后以函数方式调用来触发异常,使父进程捕获并修改其相关数据,即enc_data。同时0xcc的值修改为0xc3(ret),并继续执行,会继续启动并调试一个新进程(孙子)。
对于孙子进程,父进程已经对enc_data进行了修改,所以孙子进程的enc_data是与子进程修改后的相同。
这里针对异常事件也有了两种处理,第一种是0x141000000
地址触发的0xcc,另一种则是低地址触发的处理。当孙子进程第一次在0x141000000
触发异常后,子进程会将其的eip改为0x140001330
并继续执行。
即在0x140001330
执行中再出现异常则会执行该步处理,这里需要先获得修改后的enc_data,可以通过调试或idapy获取。
而修改后的0x140001330
处的数据中有着许多0xcc会引发异常进而调用子进程进行处理。
再结合解密后的enc,可知enc_data[0]表示发生异常处距离0x140001330
的偏移,encdata[1]表示这一个块的大小,而enc[2]用于修复0xcc。
例如enc_data[3] = 0x5f 可以计算 0x140001330 + 0x5f = 0x0x14000138f ,查看改地址处的字节为0xcc。这样通过enc_data来记录每次修改的代码块,边运行边中断,这样更能阻止逆向分析人员的动态调试。
好在每段的解密逻辑一致且与父进程解密enc_data相同,由上述分析知采用动态调试的手段很难拿到修改后的代码,所以这里采取手动修复,使用ipy脚本。
import idautils
enc_data=[0x0000000000000000, 0x000000000000005F, 0x000000000000008C, 0x000000000000005F, 0x000000000000001E, 0x0000000000000084 ...]
start=0x140001330
for p in range(0,len(enc_data),3):
offset=enc_data[p] #异常地址偏移
size=enc_data[p+1] #代码块大小
tmp=enc_data[p+2] #首字节恢复
adr=start+offset
k=list(str(adr+1))
PatchByte(adr,Byte(adr)^tmp)
for i in range(1,size): #修复代码块大小
t=Byte(adr+i)
PatchByte(adr+i,t^ord(k[(i-1)%len(k)]))
if i%len(k)==0:
k = list(str(adr+i))
print('ok')
enc_data可以通过调试获取,或者采取ipy来进行patch。
修复后的0x140001330
函数如下
这与实际运行时程序的输出一致,故经过父->子->孙的修复,程序已经恢复原有的逻辑。
可以通过process explore来查看调用链。
0x03 总结
本文主要是对DebugBlocker反调试技术的研究,更多的解密细节不再展开。另外,我们是否能找到合适的patch点,让孙子进程再CreateProcess,并且不设置调试的运行关系,通过调试器attch来拿到解密后的代码。或者是通过API来使子进程退出调试状态或许也能达到预期的效果。
- 本文作者: Lu1u
- 本文来源: 奇安信攻防社区
- 原文链接: https://forum.butian.net/share/1754
- 版权声明: 除特别声明外,本文各项权利归原文作者和发表平台所有。转载请注明出处!