分类 信息安全 下的文章

0x01 PEB相关标志位反调试

PEB是一个非常庞大的数据结构,它是用来存储每个进程的运行时数据。下图为Windows 10的_PEB部分结构。

image-20211118204902448

在VS中获取当前进程PEB结构体地址方法

#ifndef _WIN64
PPEB pPeb = (PPEB)__readfsdword(0x30);
#else
PPEB pPeb = (PPEB)__readgsqword(0x60);
#endif // _WIN64

1.1 IsDebuggerPresent函数反调试

这个API是最经典检测调试器的函数,它底层原理就是返回PEB结构中BeingDebugged位的值,当有调试器附加的时候BeingDebugged位被置为1。

image-20211118210405744

1.2 NtGlobalFlag标志位

PEB的NtGlobalFlag字段(32位Windows的0x68偏移,64位Windows的0xBC)默认为0。

image-20211118212313105

附加一个调试器并不改变NtGlobalFlag的值。但是如果进程是由调试器创建的,则将设置以下标志:

FLG_HEAP_ENABLE_TAIL_CHECK (0x10)
FLG_HEAP_ENABLE_FREE_CHECK (0x20)
FLG_HEAP_VALIDATE_PARAMETERS (0x40)

NtGlobalFlag值:
0000 0000 0111 0000

反调试检测:

#define FLG_HEAP_ENABLE_TAIL_CHECK   0x10
#define FLG_HEAP_ENABLE_FREE_CHECK   0x20
#define FLG_HEAP_VALIDATE_PARAMETERS 0x40
#define NT_GLOBAL_FLAG_DEBUGGED (FLG_HEAP_ENABLE_TAIL_CHECK | FLG_HEAP_ENABLE_FREE_CHECK | FLG_HEAP_VALIDATE_PARAMETERS)

#ifndef _WIN64
PPEB pPeb = (PPEB)__readfsdword(0x30);
DWORD dwNtGlobalFlag = *(PDWORD)((PBYTE)pPeb + 0x68);
#else
PPEB pPeb = (PPEB)__readgsqword(0x60);
DWORD dwNtGlobalFlag = *(PDWORD)((PBYTE)pPeb + 0xBC);
#endif // _WIN64
 
if (dwNtGlobalFlag & NT_GLOBAL_FLAG_DEBUGGED)
    do something...

1.3 Heap Flags

在PEB的ProcessHeap位指向_HEAP结构体,该结构体中有俩个字段会受到调试器的影响,具体如何影响,取决于Windows的版本,主要是修改原始的内容,这两个字段是Flags和ForceFlags。

image-20211119125259826

image-20211118220517877

Win10 x86环境下检测代码:

BOOL CheckHeapFlagsDebug()
{
    PPEB pPeb = (PPEB)__readfsdword(0x30);
    PVOID pHeapBase = (PVOID)(*(PDWORD_PTR)((PBYTE)pPeb + 0x18));
    DWORD dwHeapFlagsOffset = 0x40;
    DWORD dwHeapForceFlagsOffset = 0x44;
    
    PDWORD pdwHeapFlags = (PDWORD)((PBYTE)pHeapBase + dwHeapFlagsOffset);
    PDWORD pdwHeapForceFlags = (PDWORD)((PBYTE)pHeapBase + dwHeapForceFlagsOffset);
    
    //HEAP_GROWABLE (2)
    return (*pdwHeapFlags & ~HEAP_GROWABLE) || (*pdwHeapForceFlags != 0); 
}

1.4 堆Magic标志

当进程被调试器调试时该进程堆会被一些特殊的标志填充,这些特殊标记分别是0xABABABAB , 0xFEEEFEEE。在调试模式下, NtGlobalFlag的HEAP_TAIL_CHECKING_ENABLED 标志将被默认设置,堆内存分配会在末尾追加 0xABABABAB标志进行安全检查,如果NtGlobalFlag设置了HEAP_FREE_CHECKING_ENABLED标志,那么当需要额外的字节来填充堆块尾部时, 就会使用0xFEEEFEEE(或一部分) 来填充。PROCESS_HEAP_ENTRY结构详细介绍

BOOL CheckHeapMagic()
{
    PROCESS_HEAP_ENTRY HeapEntry = { 0 };
    do
    {
        if (!HeapWalk(GetProcessHeap(), &HeapEntry))
            return false;
    } while (HeapEntry.wFlags != PROCESS_HEAP_ENTRY_BUSY);

    PVOID pOverlapped = (PBYTE)HeapEntry.lpData + HeapEntry.cbData;
    return ((DWORD)(*(PDWORD)pOverlapped) == 0xABABABAB);
}

0x02 ThreadHideFromDebugger线程属性反调试

2.1 ZwSetInformationThread反调试

ThreadHideFromDebugger是线程的一个属性值,当线程具备ThreadHideFromDebugger特性时则该线程(一般是主线程)对“调试器”隐藏,使调试器无法继续接收该线程的调试事件。所以如果线程设置了ThreadHideFromDebugger那么当断点等调试事件触发时调试器表现为卡死。当线程启动后可以通过ZwSetInformationThread函数来设置,该函数需要动态从ntdll.dll中获取,以下是函数声明:

NTSTATUS (NTAPI*) pfnZwSetInformationThread(
   HANDLE          ThreadHandle,
   THREADINFOCLASS ThreadInformationClass,
   PVOID           ThreadInformation,
   ULONG           ThreadInformationLength
);

调用方式:

HINSTANCE hModule;
pfnZwSetInformationThread ZwSetInformationThread;
hModule = GetModuleHandleA("Ntdll.dll");
ZwSetInformationThread = (pfnZwSetInformationThread)GetProcAddress(hModule,"ZwSetInformationThread");
ZwSetInformationThread(GetCurrentThread(), ThreadHideFromDebugger, 0, 0);

2.2 NtCreateThreadEx反调试

Windows Vista新引入了NtCreateThreadEx函数,以下是函数声明:

#define THREAD_CREATE_FLAGS_CREATE_SUSPENDED 0x00000001
#define THREAD_CREATE_FLAGS_SKIP_THREAD_ATTACH 0x00000002
#define THREAD_CREATE_FLAGS_HIDE_FROM_DEBUGGER 0x00000004
#define THREAD_CREATE_FLAGS_HAS_SECURITY_DESCRIPTOR 0x00000010
#define THREAD_CREATE_FLAGS_ACCESS_CHECK_IN_TARGET 0x00000020
#define THREAD_CREATE_FLAGS_INITIAL_THREAD 0x00000080

NTSTATUS NTAPI NtCreateThreadEx (
    _Out_    PHANDLE              ThreadHandle,
    _In_     ACCESS_MASK          DesiredAccess,
    _In_opt_ POBJECT_ATTRIBUTES   ObjectAttributes,
    _In_     HANDLE               ProcessHandle,
    _In_     PVOID                StartRoutine,
    _In_opt_ PVOID                Argument,
    _In_     ULONG                CreateFlags,
    _In_opt_ ULONG_PTR            ZeroBits,
    _In_opt_ SIZE_T               StackSize,
    _In_opt_ SIZE_T               MaximumStackSize,
    _In_opt_ PVOID                AttributeList
);

在CreateFlags这个参数中设置THREAD_CREATE_FLAGS_HIDE_FROM_DEBUGGER标志,则线程创建时将对调试器隐藏该线程。这与NtSetInformationThread函数设置的ThreadHideFromDebugger相同。

0x03 NtQueryInformationProcess函数反调试

NtQueryInformationProcess函数是操作系统中十分有用的一个关键函数,可以用来查找进程的很多相关信息。以下是函数声明:

NTSTATUS NTAPI NtQueryInformationProcess(
  [in]            HANDLE           ProcessHandle,
  [in]            PROCESSINFOCLASS ProcessInformationClass,
  [out]           PVOID            ProcessInformation,
  [in]            ULONG            ProcessInformationLength,
  [out, optional] PULONG           ReturnLength
);
https://docs.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntqueryinformationprocess

第一个参数表明待查询的目标进程句柄,而第二个参数则是标明查询的信息种类。PROCESSINFOCALASS是一个枚举类型,能查询近百种信息,其中以下四种信息是最常见可用于检测调试器的存在。

ProcessDebugPort            // 0x7
ProcessDebugFlags            // 0x1F
ProcessDebugObjectHandle    // 0x1E
ProcessBasicInformation        // 0x0

3.1 CheckRemoteDebuggerPresent函数

CheckRemoteDebuggerPresent函数是用来检测另一个进程是否处于调试状态,以下是利用该函数进行反调试的示例代码:

BOOL bDebugger = FALSE;
if (CheckRemoteDebuggerPresent(GetCurrentProcess(), &bDebugger ))
{
        if (bDebugger)
            do something...
}

ProcessDebugPort端口是Windows调试子系统依赖的一个数据结构,可以通过检测调试端口的方式来检测进程是否被调试。在CheckRemoteDebuggerPresent函数内部就是调用NtQueryInformationProcess函数查询ProcessDebugPort信息,判断目标进程是否在调试状态,没有调试器的时候值为0。

image-20211119171510823

3.2 ProcessDebugObjectHandle反调试

ProcessDebugObjectHandle的内容为调试对象的句柄,没有调试器的时候值为0,以下为示例代码:

DWORD bDebugger = -1;
NTSTATUS status = NtQueryInformationProcess(
                GetCurrentProcess(),        // 进程句柄
                0x1E,                        // 要检索的进程信息类型,ProcessDebugObjectHandle
                &bDebugger,                    // 接收进程信息的缓冲区指针
                sizeof(DWORD),                // 缓冲区大小
                NULL                        // 实际返回进程信息的大小
                );

3.3 ProcessDebugFlags反调试

进程调试标志位,当程序处于调试状态的时候ProcessDebugFlags = 0,以下为示例代码:

DWORD bDebugger = 0;
NTSTATUS status = NtQueryInformationProcess(
                GetCurrentProcess(),        // 进程句柄
                0x1F,                        // 要检索的进程信息类型,ProcessDebugFlags
                &bDebugger,                    // 接收进程信息的缓冲区指针
                sizeof(DWORD),                // 缓冲区大小
                NULL                        // 实际返回进程信息的大小
                );

3.4 ProcessBasicInformation反调试

当使用ProcessBasicInformation标志调用NtQueryInformationProcess函数时,将返回PROCESS_BASIC_INFORMATION结构,以下是在官方定义的基础上进行完整化的结构信息:

typedef struct _PROCESS_BASIC_INFORMATION 
{
      DWORD ExitStatus;                     // 接收进程终止状态
      DWORD PebBaseAddress;                 // 接收进程环境块地址(PEB)
      DWORD AffinityMask;                     // 接收进程关联掩码
      DWORD BasePriority;                     // 接收进程的优先级类
      ULONG UniqueProcessId;                 // 接收进程ID
      ULONG InheritedFromUniqueProcessId;     // 接收父进程ID
} PROCESS_BASIC_INFORMATION;

我们知道一般情况下都是通过Windows的资源管理器来打开程序,所以一般的手动打开的进程父进程都是explorer.exe,如果通过调试器创建进程则父进程就不会是explorer.exe。所以可以通过检测父进程是否是explorer.exe来检测调试器的存在。

通过这个标志我们就可以获得父进程ID(Reserved3),后续在通过OpenProcess获取父进程句柄,调用GetProcessImageFileName获得父进程名进行比较即可判断是否被调试。

0x04 基于时间的反调试

在现代计算机中一段代码的执行通常不会消耗很多时间,但是如果程序处于调试状态,则他的执行时间就不可控了。这种检测本质就是通过获取时间的函数比较时间差进程检测。

4.1 rdtsc函数反调试

原理很直接了就是在代码的俩个地方执行rdtsc函数,然后检测两次执行的时间差。

BOOL IsDebugged(DWORD64 qwNativeElapsed)
{
    ULARGE_INTEGER Start, End;
    __asm
    {
        xor  ecx, ecx
        rdtsc
        mov  Start.LowPart, eax
        mov  Start.HighPart, edx
    }
    // do something...
    __asm
    {
        xor  ecx, ecx
        rdtsc
        mov  End.LowPart, eax
        mov  End.HighPart, edx
    }
    return (End.QuadPart - Start.QuadPart) > qwNativeElapsed;
}

4.2 GetTickCount函数反调试

BOOL IsDebugged(DWORD dwNativeElapsed)
{
    DWORD dwStart = GetTickCount();
    // do something...
    DWORD dwEnd = GetTickCount();
    return (dwEnd - dwStart) > dwNativeElapsed;
}

4.3 GetLocalTime函数反调试

BOOL IsDebugged(DWORD64 qwNativeElapsed)
{
    SYSTEMTIME stStart, stEnd;
    FILETIME ftStart, ftEnd;
    ULARGE_INTEGER uiStart, uiEnd;

    GetLocalTime(&stStart);
    // do something...
    GetLocalTime(&stEnd);

    if (!SystemTimeToFileTime(&stStart, &ftStart))
        return FALSE;
    if (!SystemTimeToFileTime(&stEnd, &ftEnd))
        return FALSE;

    uiStart.LowPart  = ftStart.dwLowDateTime;
    uiStart.HighPart = ftStart.dwHighDateTime;
    uiEnd.LowPart  = ftEnd.dwLowDateTime;
    uiEnd.HighPart = ftEnd.dwHighDateTime;
    return (uiEnd.QuadPart - uiStart.QuadPart) > qwNativeElapsed;
}

4.4 GetSystemTime函数反调试

BOOL IsDebugged(DWORD64 qwNativeElapsed)
{
    SYSTEMTIME stStart, stEnd;
    FILETIME ftStart, ftEnd;
    ULARGE_INTEGER uiStart, uiEnd;

    GetSystemTime(&stStart);
    // do something...
    GetSystemTime(&stEnd);

    if (!SystemTimeToFileTime(&stStart, &ftStart))
        return FALSE;
    if (!SystemTimeToFileTime(&stEnd, &ftEnd))
        return FALSE;

    uiStart.LowPart  = ftStart.dwLowDateTime;
    uiStart.HighPart = ftStart.dwHighDateTime;
    uiEnd.LowPart  = ftEnd.dwLowDateTime;
    uiEnd.HighPart = ftEnd.dwHighDateTime;
    return (uiEnd.QuadPart - uiStart.QuadPart) > qwNativeElapsed;
}

4.5 QueryPerformanceCounter函数反调试

BOOL IsDebugged(DWORD64 qwNativeElapsed)
{
    LARGE_INTEGER liStart, liEnd;
    QueryPerformanceCounter(&liStart);
       // do something...
    QueryPerformanceCounter(&liEnd);
    
    return (liEnd.QuadPart - liStart.QuadPart) > qwNativeElapsed;
}

0x05 基于异常的反调试

异常处理是Windows操作系统中非常重要的一种机制,它包括VEH/SEH等等,异常处理程序通常会脱离原有的程序执行流程,它很多时候可以被用于反调试。若进程在调试运行中发生异常,调试器就会接受处理。利用该特征就可以判断进程使正常运行还是调试运行,然后根据不同的结果执行不同的操作。

在代码编写过程中使用\_\_try/\_\_except 关键字可以设置一个SEH异常处理函数

int main()
{
    __try
    {
        cout << "hello,world" << endl;
    }
    __except (EXCEPTION_EXECUTE_HANDLER)
    {
        cout << "异常" << endl;
    }
    return 0;
}

__except中值的含义:

image-20211120174502674

1:处理异常
0:不处理异常交给下一个异常节点去处理
-1:继续执行也就是继续EIP处理执行,但是这里又有异常,所以这里就会一直卡在这里

Windows的异常处理流程大致为:

1. 交给调试器(进程必须被调试)
2. 执行VEH
3. 执行SEH
4. TopLevelEH(进程被调试时不会被执行)
5. 交给调试器(上面的异常处理都说处理不了,就再次交给调试器)
6. 调用异常端口通知csrss.exe

也就是说当一个异常被触发最先会到调试器(如果被调试),所以我们可以在__except内部动手脚以实现反调试,示例代码如下:

BOOL IsDebugged()
{
    __try{
        __asm int 3
    }
    __except(EXCEPTION_EXECUTE_HANDLER)
    {
        return FALSE;
    }
    return TRUE;
}

0x06 总结

以上是在此前学习过程中收集的一些应用层反调试原理,其中还有很多比如获取窗口,文件完整性检验,代码重映射,硬件断点检测等由于篇幅的原因无法详细展开,如果文中出现什么错误还望师傅指正。

下一篇打算结合SycllaHide这个开源反调试插件项目探索一下Windows应用层反反调试的原理和实现。

在创建游戏程序的快捷方式,然后在快捷方式中添加-insecure关闭VAC验证服务方便进行初步的调试。如果是用7Launcher平台的得添加Run_CSGO.exe的设置并启动。

image-20211107154920433image-20211107170154792

进到游戏后记得在设置里面打开开发者控制台,方便控制机器人

image-20211107154724783

控制台常用指令
net_graph “0/1”  关闭/显示当前fps以及ping、loss、choke、tick等服务器与网络参数
mp_roundtime_defuse 50 休闲/竞技模式每局时间60分钟
sv_cheats 0/1 关闭/开启作弊功能
bot_stop 1 bot原地不动
bot_kick 踢出所有电脑
bot_add 随机添加一个bot
https://www.csgo.com.cn/news/gamenews/20170825/205831.shtml

0x01 自瞄 & 透视准备

1.0 寻找自身矩阵

我们知道矩阵的值一般是以浮点数为主,所以我们可以在CE里面搜索未知初始值的单浮点数,通过开关镜鼠标移动键盘移动不断搜索。

image-20211107171601541

找到自身4x4矩阵,可能会找到很多个

image-20211107203248709

1.1 寻找角度信息

角度信息就比较好找了,CSGO当准星指向最上方的时候,Y轴角度值为-89,当准星指向最下方的时候,上下角度的角度值为89。通过这个特点我们就能很容易定位到。

image-20211107211400488

唯一要注意的用默认调试器选项可能会照成游戏进程闪退,推荐使用VEH调试器。

image-20211107211634118

1.2 寻找位置信息

这个位置信息就是任务在整个游戏环境中的XYZ坐标,我们可以通过敌人坐标值与我们坐标值进行某种计算得到与敌人的相对距离。

最方便的是从Z坐标开始寻找,因为XY我们不能确认是增大还是减小,Z坐标可以通过所在地势高低很好判断。不过很容易会找成坐标数组这个得具体分析。

image-20211107215946272

1.3 寻找敌人和队友位置信息

这个就是通过自身间接寻找敌人的坐标,就是先走到和敌人同一水平高度,然后搜索数值范围,不断开启bot运动多次搜索进行定位。

image-20211108185444998

我们利用CE的结构分析观察上层指针

image-20211108185612149

我们通过大胆猜测和测试,可以发现每间隔0x10就是另一个人物的结构体指针,在个结构体0xA8是该人物的Z坐标,0x100是血量等。

image-20211108185911638

图中一共有四个结构体指针,和游戏中人物数量匹配,敌人和队友都在这一个数组中。

image-20211108190024088

1.4 寻找人物骨骼

为了实现骨骼透视和锁头,还需要得到敌人的骨骼坐标,在游戏建模的时候为了实现人物模型的生动性,一个人物是由多个模块组成的比如头、手、腿、脚等,这样设计可以实现各个骨骼的各自移动。因此,每块骨骼都应该具有一个独立的坐标点,我们得到了其中某些骨骼的坐标点,才可以实现出骨骼透视和锁头。

骨骼坐标也是由XYZ的,搜索骨骼坐标有个非常关键的技巧,当我们看向敌人的时候,敌人的骨骼坐标就会发生变动,而当我们远离且不看向敌人时,这个值就不变。所以我们就可以利用这个规律进行骨骼坐标搜索。

image-20211108213622222

我们找到骨骼坐标头后可以发现,当我们看向敌人的时候这个数组中的XYZ值一直在浮动,远离不看就变动停止。而且这个骨骼头位置是通过人物结构体二级指针指向的。

image-20211108213838528

1.5 寻找人物结构体的阵营信息

通过结构分析工具进行对比查看

image-20211108214513897

我们把0xF4偏移的值改成一样的造成了游戏标识判断错误,改成1敌人模型消失,所以可以大胆猜测这里就是阵营判断。

image-20211108214816890

0x02 内置辅助

对于透视分为内置辅助和外置辅助,内置的意思就是我们编写一个Dll文件注入到目标进程Hook掉D3D绘制的相关函数,以实现我们的透视方框;而外置辅助就是在游戏窗口上生成一个透明的窗口,然后通过跨进程读取游戏内存数据,在自己的透明窗口上进行透视方框绘制。

现在我们先讨论内置辅助的前置准备-HOOKD3D绘制相关函数,电脑在安装DirectX后会有相关的例子,本节以官方的ShadowMap.exe程序进行测试,我们使用MinHook这个HOOK框架进行操作。

image-20211109195650911

image-20211110111605425

HRESULT WINAPI MyReset(IDirect3DDevice9* direct3dDevice9, D3DPRESENT_PARAMETERS* pPresentationParameters)
{
    OutputDebugString(L"MyReset");

    HRESULT result = fpReset(direct3dDevice9, pPresentationParameters);
    return result;
}

HRESULT WINAPI MyEndScene(IDirect3DDevice9* direct3dDevice9)
{


    HRESULT result = fpEndScene(direct3dDevice9);
    return result;
}

unsigned int WINAPI InitD3D9(PVOID data)
{
    OutputDebugString(L"InitD3D9");

    g_Direct3d9 = Direct3DCreate9(D3D_SDK_VERSION);
    OutputDebugString(L"Direct3DCreate9");

    memset(&g_Present, 0, sizeof(g_Present));
    g_Present.Windowed = TRUE;
    g_Present.SwapEffect = D3DSWAPEFFECT_DISCARD;
    g_Present.BackBufferFormat = D3DFMT_UNKNOWN;

    HRESULT result = g_Direct3d9->CreateDevice(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, FindWindowW(L"Direct3DWindowClass", NULL),
        D3DCREATE_SOFTWARE_VERTEXPROCESSING, &g_Present, &g_Direct3ddevice9);
    OutputDebugString(L"CreateDevice");

    //HOOK
    int* g_direct3d9Table = (int*)*(int*)g_Direct3d9;
    int* g_direct3dDevice9Table = (int*)*(int*)g_Direct3ddevice9;

    if (MH_Initialize() != MH_OK)
       return 1;
    OutputDebugString(L"MH_Initialize");

    if (MH_CreateHook((LPVOID)g_direct3dDevice9Table[16], &MyReset,
        reinterpret_cast<LPVOID*>(&fpReset)) != MH_OK)
        return 1;
    if (MH_CreateHook((LPVOID)g_direct3dDevice9Table[42], &MyEndScene,
        reinterpret_cast<LPVOID*>(&fpEndScene)) != MH_OK)
        return 1;
    OutputDebugString(L"MH_CreateHook");
    
    if (MH_EnableHook((LPVOID)g_direct3dDevice9Table[16]) != MH_OK)
        return 1;
    if (MH_EnableHook((LPVOID)g_direct3dDevice9Table[42]) != MH_OK)
        return 1;
    OutputDebugString(L"MH_EnableHook");
    
    return 0;
}

2.1 ImGui绘制菜单

ImGui是一个开源的图形库上手比较简单,本节拿来做菜单的绘制测试。我们参考imgui官方给的例子把需要的cpp和h文件加入到我们测试工程。

image-20211110194921684

模仿着官方实例代码我们可以编写出我们自己的菜单,主要就是依赖于此前对D3D9的HOOK在对应绘制函数上进行操作。

LRESULT CALLBACK MyProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    if (ImGui_ImplWin32_WndProcHandler(hWnd, uMsg, wParam, lParam))
        return true;

    return CallWindowProcW(g_fpProc, hWnd, uMsg, wParam, lParam);
}

void InitImGui(IDirect3DDevice9* direct3dDevice9)
{
    IMGUI_CHECKVERSION();
    ImGui::CreateContext();
    ImGui::StyleColorsLight();
    ImGuiIO& io = ImGui::GetIO(); 
    io.IniFilename = NULL;
    io.LogFilename = NULL;

    ImGui_ImplWin32_Init(FindWindowW(L"Direct3DWindowClass", NULL));
    ImGui_ImplDX9_Init(direct3dDevice9);
}

HRESULT WINAPI MyReset(IDirect3DDevice9* direct3dDevice9, D3DPRESENT_PARAMETERS* pPresentationParameters)
{
    OutputDebugString(L"MyReset");

    ImGui_ImplDX9_InvalidateDeviceObjects();
    HRESULT result = g_fpReset(direct3dDevice9, pPresentationParameters);
    ImGui_ImplDX9_CreateDeviceObjects();

    return result;
}

HRESULT WINAPI MyEndScene(IDirect3DDevice9* direct3dDevice9)
{
    static BOOL b_First = TRUE;
    if (b_First)
    {
        b_First = FALSE;
        InitImGui(direct3dDevice9);
        g_fpProc = (WNDPROC)SetWindowLongA(FindWindowW(L"Direct3DWindowClass", NULL), GWL_WNDPROC, (LONG)MyProc);
    }

    ImGui_ImplDX9_NewFrame();
    ImGui_ImplWin32_NewFrame();
    ImGui::NewFrame();

    ImGui::Begin("MyWindow");
    ImGui::Text("Test Windows");
    ImGui::End();

    ImGui::EndFrame();
    ImGui::Render();
    ImGui_ImplDX9_RenderDrawData(ImGui::GetDrawData());

    HRESULT result = g_fpEndScene(direct3dDevice9);

    return result;
}

image-20211110195143944

0x03 外置辅助

外置辅助就是游戏进程窗口上方建立一个透明窗口,本例子将配合Blackbone这个库进行跨进程读写操作,直接上源码:

void StartCheat()
{
    InitCheat(L"csgo.exe");

    extern pDoingCheat g_CheatFunc;
    g_CheatFunc = DoingCheat;

    // 以下和内置辅助类似,创建透明D3D窗口,在窗口过程函数中WM_PAINT调用DoingCheat函数
    HWND g_hTransparentHwnd =  CreateTransparentWindow(g_hGameWnd);
    InitDirect3d9(g_hTransparentHwnd);
    
    MessageLoop(g_hGameWnd, g_hTransparentHwnd);
}

void InitCheat(LPCWSTR lpProcessName)
{
    g_hGameWnd = FindWindow(NULL, L"Counter-Strike: Global Offensive");

    auto pids = Process::EnumByName(lpProcessName);
    if (!pids.empty())
    {
        procCSGO.Attach(pids[0]);

        auto clientModule = procCSGO.modules().GetModule(L"client.dll");
        auto serverModule = procCSGO.modules().GetModule(L"server.dll");
        auto engineModule = procCSGO.modules().GetModule(L"engine.dll");

        g_MatrixAddress = clientModule->baseAddress + dwMatrix;
        g_PlayersAddress = clientModule->baseAddress + dwCharacter;
        g_AngleAddress = engineModule->baseAddress + dwAngleBase;
        g_SelfAddress = serverModule->baseAddress + dwXYZBase;

        printf("自己矩阵基地址 : %8x \n", g_MatrixAddress);
        printf("自己角度基地址 : %8x \n", g_AngleAddress);
        printf("自己位置基地址 : %8x \n", g_SelfAddress);
        printf("玩家信息基地址 : %8x \n", g_PlayersAddress);
        printf("\n");
    }
}
void DoingCheat() 
{
    Player* player = GetPlayerList();// 遍历读取玩家列表信息

    DrawPlayerBox(player);// 绘制人物矩形

    if (GetMouseRightDown())// 自瞄
        StartAimbot(player);

    FreePlayList(player);
}

玩家结构体

struct Player
{
    BOOL effective;        //是否有效
    int aimbot_len;        //自瞄长度
    BOOL self;            //是否自己
    float location[3];    //身体位置
    float head_bone[3];    //头骨位置
    BOOL mirror;        //是否开镜
    int camp;            //阵营
    int blood;            //血量
    struct Player* next = NULL;
};

遍历获取玩家信息

Player* GetPlayerList()
{
    Player* headPlayer = NULL, * tmpPlayer = NULL;
    auto& memoryCSGO = procCSGO.memory();

    for (int i = 0; i < g_PlayerNumberMax; i++)
    {
        DWORD dwPlayerBaseAddress = 0;
        DWORD dwPlayerBlood = 0;
        DWORD dwBoneBaseAddress = 0;
        memoryCSGO.Read((g_PlayersAddress + i * 0x10), sizeof(dwPlayerBaseAddress), &dwPlayerBaseAddress);
        if (dwPlayerBaseAddress == 0)
            break;

        // Blood
        memoryCSGO.Read((dwPlayerBaseAddress + 0x100), sizeof(dwPlayerBlood), &dwPlayerBlood);
        if (dwPlayerBaseAddress <= 0)
            continue;

        Player* temp = (Player*)VirtualAlloc(NULL, sizeof(Player), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
        temp->blood = dwPlayerBlood;
        temp->aimbot_len = 9999;
        temp->effective = TRUE;

        // Bone
        if (!memoryCSGO.Read((dwPlayerBaseAddress + 0x26A8), sizeof(dwBoneBaseAddress) ,&dwBoneBaseAddress))
        {
            memoryCSGO.Read((dwBoneBaseAddress + 99 * sizeof(float)), sizeof(float), &(temp->head_bone[0]));
            memoryCSGO.Read((dwBoneBaseAddress + 103 * sizeof(float)), sizeof(float), &(temp->head_bone[1]));
            memoryCSGO.Read((dwBoneBaseAddress + 107 * sizeof(float)), sizeof(float), &(temp->head_bone[2]));
        }

        memoryCSGO.Read(dwPlayerBaseAddress + 0xA0, sizeof(temp->location), temp->location);
        memoryCSGO.Read(dwPlayerBaseAddress + 0xF4, sizeof(int), &temp->camp);
        memoryCSGO.Read(dwPlayerBaseAddress + 0x3914, sizeof(BOOL), &temp->mirror);

        if (headPlayer == NULL)
        {
            headPlayer = temp;
            tmpPlayer = headPlayer;
        }
        else
        {
            tmpPlayer->next = temp;
            tmpPlayer = temp;
        }
    }
    return headPlayer;
}

绘制透视方框

void DrawPlayerBox(Player* playerHead)
{
    int x, y, width, height;
    GetWindowSize(g_hGameWnd, x, y, width, height);
    width /= 2;
    height /= 2;

    float matrix[4][4];
    auto& memoryCSGO = procCSGO.memory();
    memoryCSGO.Read(g_MatrixAddress, (sizeof(float) * 4 * 4), matrix);

    // SelfCoordinate
    float selfLocation[3];
    DWORD dwLocationBaseAddress = 0;
    memoryCSGO.Read(g_SelfAddress, sizeof(dwLocationBaseAddress), &dwLocationBaseAddress);
    if (dwLocationBaseAddress)
        memoryCSGO.Read(dwLocationBaseAddress + 0x1DC, (sizeof(float) * 3), selfLocation);

    // SelfCamp
    Player* tempPlayerHead = playerHead;
    for(int i = 0;i < g_PlayerNumberMax && tempPlayerHead; ++i)
    {
        if (tempPlayerHead->effective)
        {
            int temp_x = abs(selfLocation[0] - tempPlayerHead->location[0]);
            int temp_y = abs(selfLocation[1] - tempPlayerHead->location[1]);
            if (temp_x < 5.0f && temp_y < 5.0f)
            {
                tempPlayerHead->self = TRUE;
                g_MyCamp = tempPlayerHead->camp;
                break;
            }
            tempPlayerHead = tempPlayerHead->next;
        }
    }

    for (int i = 0; i < g_PlayerNumberMax && playerHead; ++i)
    {
        int x, y, w, h;
        if (playerHead->effective && playerHead->self == FALSE && ChangeMatrixInfo(matrix, playerHead->location, width, height, x, y, w, h))
        {
            if (playerHead->blood > 0)
            {
                if(playerHead->camp!=3 && playerHead->camp!=2)
                    printf("%d\n", playerHead->camp);
                D3DCOLOR color = D3DCOLOR_XRGB(255, 255, 0);
                if (g_MyCamp != playerHead->camp)
                {
                    color = D3DCOLOR_XRGB(255, 0, 0);
                    playerHead->aimbot_len = GetAimbotLen(width, height, x, y);
                }
                DrawRect(color, x, y, w, h);
                DrawPlayerBlood(playerHead->blood, x - 5, y, h);
                DrawUnderLine(color, width, height, x + (w / 2), y + h);
            }
        }
        playerHead = playerHead->next;
    }
}

自瞄

void StartAimbot(Player* player, float maxFov = 30.0f)
{
    float selfLocation[3];
    auto& memoryCSGO = procCSGO.memory();
    DWORD dwLocationBaseAddress = 0;
    memoryCSGO.Read(g_SelfAddress, sizeof(dwLocationBaseAddress), &dwLocationBaseAddress);
    if (dwLocationBaseAddress)
        memoryCSGO.Read(dwLocationBaseAddress + 0x1DC, (sizeof(float) * 3), selfLocation);

    // 获取最近人物骨骼
    Player* aimPlayer = GetRecentHeadBone(player);
    if (aimPlayer == NULL)
        return;

    // 获取当前角度
    float flCurrentAngle[2];
    DWORD dwAngleBaseAddress = 0;
    memoryCSGO.Read(g_AngleAddress, sizeof(dwAngleBaseAddress), &dwAngleBaseAddress);
    if (dwLocationBaseAddress)
        memoryCSGO.Read(dwAngleBaseAddress + 0x4D90, (sizeof(float) * 2), flCurrentAngle);

    float flAimAngle[2];
    GetAimBotAngle(selfLocation, aimPlayer->head_bone, flAimAngle, -15.0f);

    if (abs(flAimAngle[0] - flCurrentAngle[0]) > maxFov
        || abs(flAimAngle[1] - flCurrentAngle[1]) > maxFov)
        return;
    // 自瞄
    memoryCSGO.Write(dwAngleBaseAddress + 0x4D90, (sizeof(float) * 2), flAimAngle);
}

成果

image-20211112164514553

image-20211112164546054

这里有两个我认为值得一提的点,一个是创建透明窗口的名字我使用了时间作为种子随机生成窗口名;另外一个我把所有的偏移放到了一个头文件里方便以后进行修改,当然可以用BlackBone的特征码匹配自动寻找基址。

image-20211112165849750

image-20211112165645814

0x04 总结

花了一周左右的时间比较完整的实现了CSGO辅助的学习,最后配合MinHook,D3D和BlackBone了解了内外置自瞄+透视的辅助功能实现原理,收获还是比较多的,这里放几个我个人认为比较好的学习资料以供后来者学习参考。

Osiris --- 开源的CSGO辅助

How-to-create-a-csgo-cheating-program --- 本文主要参考的教程

Xenos --- 好用的开源DLL注入器

0x00 前言

最近粗浅的研究了一下Windows应用层相关调试API和对应调试原理,以达到实现反附加的功能。本文内容主要参考《软件调试》和网络上相关优秀文章,并且主要侧重在应用层调试附加方面,关于内核层面因为水平有限本文没有详细展现。

0x01 用户态调试基本流程

首先我们先用调试API编写一个最简单的附加调试器

int main(int argc,TCHAR *argv[])
{
    DWORD dwPID;
       BOOL waitEvent = TRUE;
    if (argc > 1) {
        dwPID = atoi(argv[1]);
    }
    else {
        printf("usage: MyDebugger.exe dwPID\n");
        exit(0);
    }

    DebugActiveProcess(dwPID);
    while (waitEvent)
    {
        DEBUG_EVENT MyDebugInfo;
        waitEvent = WaitForDebugEvent(&MyDebugInfo, INFINITE); // Waiting
        switch (MyDebugInfo.dwDebugEventCode)
        {
            case EXIT_PROCESS_DEBUG_EVENT:
                waitEvent = FALSE
                break;
        }
        if (waitEvent) {
            ContinueDebugEvent(debugEvent.dwProcessId, debugEvent.dwThreadId, DBG_CONTINUE);
        }
    }
    return 0;
}

上文主要用到的就是 DebugActiveProcess 这个调试API对目标PID进程进行调试附加操作,如果我们要在程序创建的时候就对程序进行调试可以在Debugger中执行CreateProcess并将第6个参数传入DEBUG_ONLY_THIS_PROCESS,这样设置之后,子进程发生的调试事件会通知给父进程处理。

CreateProcess(path, // 可执行模块路径
    NULL, // 命令行
    NULL, // 安全描述符
    NULL, // 线程属性是否可继承
    FALSE, // 否从调用进程处继承了句柄
    DEBUG_ONLY_THIS_PROCESS, // 以“只”调试的方式启动
    NULL, // 新进程的环境块
    NULL, // 新进程的当前工作路径(当前目录)
    &stcStartupInfo, // 指定进程的主窗口特性
    &stcProcInfo)) // 接收新进程的识别信息

DEBUG_EVENT中的dwDebugEventCode表示调试信息的种类,对于DEBUG_EVENT详细的介绍可以查看MSDN,简单来说就是用共用体来存储具体的数据。

typedef struct _DEBUG_EVENT {
    DWORD dwDebugEventCode;
    DWORD dwProcessId;
    DWORD dwThreadId;
    union {
        EXCEPTION_DEBUG_INFO Exception;
        CREATE_THREAD_DEBUG_INFO CreateThread;
        CREATE_PROCESS_DEBUG_INFO CreateProcessInfo;
        EXIT_THREAD_DEBUG_INFO ExitThread;
        EXIT_PROCESS_DEBUG_INFO ExitProcess;
        LOAD_DLL_DEBUG_INFO LoadDll;
        UNLOAD_DLL_DEBUG_INFO UnloadDll;
        OUTPUT_DEBUG_STRING_INFO DebugString;
        RIP_INFO RipInfo;
    } u;
} DEBUG_EVENT, *LPDEBUG_EVENT;

0x02 用户态 DebugActiveProcess 实现

我们通过查找NT5的源码可以看到DebugActiveProcess 具体实现代码,主要就是调用了DbgUiConnectToDbg ,ProcessIdToHandle和DbgUiDebugActiveProcess这三个函数

image-20210818203841617

image-20210818205033766

DbgUiConnectToDbg

首先判断TEB->DbgSsReserved[1]是否保存着调试对象的句柄,如果存在则直接返回函数如果不存在就进行初始化并调用NtCreateDebugObject创建调试对象

image-20210818205728745

image-20210818211454716

ProcessIdToHandle

如果是伪句柄则调用CsrGetProcessId获取csrss.exe的PID,然后调用NtOpenProcess获得对应进程句柄,给后续调用做准备。

image-20210818213529075

image-20210818214255906

DbgUiDebugActiveProcess

此函数首先传入进程和调试对象的句柄进入内核,然后调用DbgUiIssueRemoteBreakin函数创建线程开始地址为DbgUiRemoteBreakin的远程线程让被调试进程断下来,如果远程线程设置失败则调用DbgUiStopDebugging停止调试。这个地方创建了远程线程是在将来反附加检测的主要检测点。

image-20210818211939159

image-20210818213257737

全览图

0x03 调试子系统

Windows的调试子系统使用调试事件驱动,这个和窗体的消息驱动是很类似的。在调试子系统中使用WaitForDebugEvent在用户态等待调试事件,当调试器处理调试事件时,被调试进程会被挂起,所以调试器处理完毕后要调用ContinueDebugEvent使被挂起的被调试进程继续运行。

下图是张银奎老师在软件调试纵横谈中的调试模型,在用户空间部分就是我们此前通过源码分析的用户态附加调试API DebugActiveProcess 的基本流程,在系统空间维护着一个DebugObject的链表并且通过Dbgk*例程采集和传递调试事件。

用户态调试模型

0x04 反附加手段

根据此前内容知道,当调试器附加一个进程的时候是调用DebugActiveProcess函数,该函数内部调用了DbgUiDebugActiveProcess,此函数内部会调用DbgUiIssueRemoteBreakin函数,最后内部则会通过RtlCreateUserThread在被调试进程内创建一个线程,线程的起始地址是DbgUiRemoteBreakin。

image-20210819204553058

在被调试进程内DbgUiRemoteBreakin会判断PEB中的BeingDebugged标志位,如果在调试则调用DbgBreakPoint函数,调试器附加后被调试进程就中断在DbgBreakPoint函数内。

image-20210819204416047

根据上述流程我们可以知道在被调试进程(我们的程序)会创建新线程,线程起始函数是DbgUiRemoteBreakin。所以我们可以Hook DbgUiRemoteBreakin 直接调用 ExitProcess 结束我们的程序。

image-20210819205656353

0x05 反反附加插件

本文以ScyllaHide为例子,因为ScyllaHide是开源项目可以直接分析源码,插件加载后我们此前设置的HOOK会被还原。

image-20210819210704926

image-20210819210717166

对于SharpOD,他会在插件加载的时候Hook调试器的DebugActiveProcess和CreateProcessInternalW,在DebugActiveProcessDetour 会在被调试进程中添加ShellCode,并HOOK LdrInitializeThunk 跳转到ShellCode中断,并在附加进程的调试事件到达后还原LdrInitializeThunk 。

image-20210819210932220

image-20210819210957423

0x06 x64dbg的Titan Engine

对于新版x64dbg会内嵌Titan Engine(2020年11月12日版本后添加),所以SharpOD插件对于反反附加对新版本x64dbg起不到作用。在Titan Engine内部自己实现了DebugActiveProcess一套流程。

image-20210819211542046

并且没有调用DbgUiIssueRemoteBreakin让程序被调试器附加的时候断下。

image-20210819211614725

0x07 总结及参考链接

本文初步学习了Windows用户层基本的调试原理和模型,并给出了一个简单的反附加方案,同时分析了目前常见的反反附加插件。不过同时存在不知道如何绕过SharpOD的反反附加检测等问题,还需要进一步学习研究。因为本人水平有限,如果存在错误还望各位前辈指正。

Windows 调试原理学习

调试器原理

软件调试纵横谈

理解内核机制有助于我们调试以及从整体上理解系统的运作

image-20211102202235933

6.1 中断请求级别(IRQL)

当要处理的线程多于可用处理器的数量时,就会考虑到线程的优先级。同时硬件设备需要去通知系统来进行进程调度。比如:由磁盘驱动器执行的I/O操作,操作完成后磁盘驱动器会通过请求中断来通知系统操作已经完成。该请求中断连接到中断控制器硬件设备,然后把请求发送到处理器进行处理。现在有一个问题就是哪个线程来执行中断服务程序(ISR Interrupt Service Routine)呢

每个物理硬件中断都与一个优先级有关,叫做IRQL(Interrupt Request Level)中断请求级别,由HAL(硬件抽象层)来决定IRQL为多少。每个处理器的上下文都有自己的IRQL,就像每个处理器有自己的寄存器一样,可以像对待CPU的寄存器一样来对待IRQL。

对于IRQL来说基本规则就是:处理器会执行IRQL级别更高的对应的程序(ISR)。例如:当前处理器的IRQL为0,这时有一个IRQL为5的中断进来,处理器就会在当前线程的内核栈中保存上下文状态,然后将处理器的IRQL提升为5,然后执行中断服务程序(ISR Interrupt Service Routine)。一旦执行结束,IRQL就会回到原来的环境。另一方面如果在中断的IRQL==5的时候又有新中断来了也是一样的,先判断IRQL的大小,如果大于5则调用新中断如果小就等待。

image-20211102212104048

image-20211102212117847

通过以上两张图我们可以知道,所有的ISR(Interrupt Service Routine中断服务程序)都是在被中断的线程中完成的。Windows没有专门的线程来处理中断而是由当前在中断处理器上运行的线程来处理。

在用户态的代码执行时,IRQL总是等于0,所以在用户态开发的时候我们也无需关注IRQL。大部分的内核代码也是在IRQL等于0的环境下运行,在内核态下可以通过内核API提高IRQL。

image-20211102212552120

当处理器的IRQL提升到大于等于2以上时,执行的代码就会有很多限制

  1. 访问不存在物理内存的内存会导致系统崩溃,这意味着从非分页池访问数据总是安全的,而从分页池或用户提供的缓冲区访问数据是不安全的,必须避免。
  2. 等待任何调度程序内核对象(例如互斥锁或事件)会导致系统崩溃,除非将等待超时时间设置为零。

产生限制的原因:因为调度程序是在IRQL(2)上运行,所以当处理器的IRQL大于等于2,调度程序就无法在处理器上运行,因此就不会发生线程上下文切换(用该CPU上的另一个线程替换该线程)。只有更高级别的中断才能临时将代码转移到关联的ISR,但是它仍然是同一个线程里,不会发生线程上下文的切换。总的来说,以上两种状态需要通过调度程序进行线程切换,但是调度程序的IRQL为2无法在当前处理器IRQL大于等于2的时候运行。

提高和降低IRQL

在用户态是不能修改IRQL的,只有内核态可以。IRQL可以被KeRaiseIrql函数提升和被KeLowerIrql函数降低。这里提供一个代码片段来方便理解:

//假设当前IRQL<=DISPATCH_LEVEL 也就是IRQL(2)
KIRQL oldIrql;    //KIRQL是对UCHAR的一种typedef重命名
KeRaiseIrql(DISPATCH_LEVEL,&oldIrql);

NT_ASSERT(KeGetCurrentIrql() == DISPATCH_LEVEL);

// do some work

KeLowerIrql(oldIrql);

如果提高了IRQL,请确保在相同的函数中降低它,只提升了原来的却不降低是非常危险的。用了KeRaiseIrql来提高务必在同一函数用KeLowerIrql来降低

线程优先级和IRQL的异同

IRQL是处理器的一个属性,线程优先级是线程的一个属性,线程优先级只有在IRQL<2时才有意义。

任务管理器用一个叫做System interrupt的伪进程来描述CPU在IRQL>=2的情况下花费的时间,在Process Explorer用interrupt来描述:

image-20211102214255187

6.2 延迟过程调用(DPC)

image-20211102215451245

上图显示了客户端调用I/O操作时的经典事件序列:用户层下的线程打开某个文件句柄,然后调用ReadFile发起一个读操作。由于线程可以异步调用,它几乎马上就可以重新获得控制权并可以做其他工作。收到ReadFile的读取请求的驱动程序会调用文件系统驱动程序(例如 ntfs.sys),它可能会一直往下调用直到磁盘驱动程序,最后磁盘驱动程序对磁盘进行操作。

当硬件完成读操作的时候,会发出一个中断。该中断会引起与之关联的中断服务程序(ISR)在硬件设备的IRQL处执行。一个典型的ISR会访问设备硬件以得到操作的结果,最后完成(CompleteRequest)请求。

如前文所说完成一个请求通常是通过调用IoCompleteRequest函数来完成的,但是该函数的文档说只能在IRQL<=DISPATCH_LEVEL(2)时才能使用。

允许ISR调用IoCompleteRequest(和类似的函数)的机制被称为DPC(Derferred Procedure Call)

注册了ISR的驱动程序需要从非分页池内存中分配KDPC结构体,并用KeInitializeDpc来初始化给后面DPC做调用准备。当ISR被调用时,在退出ISR调用之前,ISR调用KeInsertQueueDpc函数将此DPC插入队列,当DPC函数执行时,就会调用IoCompleteRequest函数了。这是一种调用DPC的折中方案。它在IRQL=DISPATCH_LEVEL状态上运行,这表示它也不能进行调度和访问分页内存。

系统中每一个处理器都有自己的DPC队列,在默认的情况下KeInsertQueueDpc函数将DPC插入当前处理器的DPC队列里。当ISR返回前,再IRQL降回0之前,会检测处理器的队列里面是否还有PDC,如果有处理器降低IRQL等级为DISPATH_LEVEL(2)然后以先进先出(队列的方式)来处理队列里的DPC,直到队列清空,处理器的IRQL等级才降为0,并恢复中断时的环境。

也可以通过这两个函数KeSetImportantceDpc,KeSetTargetProcessorDpc自己定制DPC

image-20211102222117898

Using DPC with a Timer

DPC最初是为了给ISR使用而创建的,但是也有别的机制可以使用DPC。DPC可以和内核时钟绑定一起使用。

KTIMER结构体表示内核时钟(Kernel Timer)允许通过相对或者绝对时间来设置一个时钟。时钟(Timer)是一个调度对象(dispatcher object),可以用KeWaitForSingleObject等函数来等待,但是不太方便。更简单常用的办法是在内核时钟(kernel timer)中使用回调函数(DPC)。

用一个例子来方便理解:

KTIMER Timer; KDPC TimerDpc;
void InitializeAndStartTimer(ULONG msec) { 
KeInitializeTimer(&Timer); 
KeInitializeDpc(&TimerDpc,                                                                                     OnTimerExpired,// callback function 
                  nullptr); // passed to callback as "context"
    
    // relative interval is in 100nsec units (and must be negative)
    // convert to msec by multiplying by 10000
                                          
    LARGE_INTEGER interval;
    interval.QuadPart = -10000LL * msec; 
    KeSetTimer(&Timer, interval, &TimerDpc);
}

void OnTimerExpired(KDPC* Dpc, PVOID context, PVOID, PVOID) { 
    UNREFERENCED_PARAMETER(Dpc); 
    UNREFERENCED_PARAMETER(context);
    NT_ASSERT(KeGetCurrentIrql() == DISPATCH_LEVEL); 
        // handle timer expiration
}

这段代码表示当内核时钟到期时,DPC会被插入到CPU中的DPC队列中并尽快执行。使用DPC比普通基于IRQL(0)的回调更快,因为它级别比较高,所以能够保证在用户态代码和大多数内核代码之前执行。

6.3 异步过程调用(APC)

DPC被封装成函数会在IRQL等于DISPATCH_LEVEL的时候被调用。异步过程调用(APC)也是被封装成函数来调用,但是和DPC不同,APC是专门给某个特定线程使用,而DPC是和处理器有关。这意味着每个线程都有一个APC队列,每个处理器有DPC队列。

image-20211104150036221

用户模式下可以调用适当的API来使用APC。例如,调用 ReadFileEx 或 WriteFileEx 开始异步 I/O 操作。操作完成后,用户模式 APC 会附加到调用线程。如前文所述,该 APC 当线程进入警报状态的时候执行。在用户模式下显式插入 APC 的另一个API函数是QueueUserAPC。

关键区和警戒区

关键区禁止用户态和普通内核APC执行。线程使用KeEnterCriticalRegion函数来进入关键区,使用KeLeaveCriticalRegion来离开关键区。内核编程中的某些功能函数需要位于关键区(Critical Regions)内。尤其是在处理执行体资源(executive resources)时;警戒区(Guarded Regions)阻止所有APC执行。KeEnterGuardedRegion和KeleaveGuardedRegion必须成套出现不然很危险。

6.4 结构化异常(SEH)

异常是由于某条指令执行某些导致处理器引发错误的操作而发生的事件。异常的常见例子包括:除零,断点,页错误,堆栈溢出和无效指令等。如果发生异常内核会捕获它并在可能的情况下运行代码来处理异常,这种机制称为结构化异常处理(Structured Exception Handling SEH),可以用于用户和内核层,异常也是断点实现的基本原理。

内核异常处理程序由IDT(Interrupt Dispatch Table 中断描述符表)来调用,IDT与中断和ISR之间的映射相同,一一对应。对于Windbg来说,可以使用!idt命令来查看系统IDT表的所有映射。image-20211105102737011

一些常见的异常:

image-20211105103354241

一旦程序发生了异常,内核会在发生异常的函数中搜索处理程序(除了一些透明处理的异常,例如断点(3)),如果没有找到就会向上搜索调用堆栈,直到找到异常处理程序,如果堆栈耗尽,那么系统崩溃。

Windows提供了四个C语言关键字来让开发者完成异常处理:

image-20211105105611346

关键字的有效组合是 _try/except和 _try/finally,这些关键字在用户态和内核态都可以使用。

6.5 系统奔溃

系统奔溃我们简单的理解就是系统蓝屏了(BSOD),系统蓝屏是一种保护机制,因为如果代码再往下执行就有可能造成毁灭性打击,就所以直接蓝屏不让系统继续执行了。

如果崩溃的系统连接到了一个内核的调试器的话,会在调试器中产生一个中断,可以让你在调试器里面对系统的状态进行检查。可以在Windows里面进行配置使得当出现蓝屏时保存一个dump文件,这个dump文件会保存系统蓝屏的环境。

image-20211105170424660

Dump转储类型决定了什么样的数据会被写入,具体的选项如下

类型描述
小内存转储非常小,仅包含基本的系统信息和引起崩溃的线程信息
核心内存转储捕获所有的内核内存但不包括用户内存,一般来说这个是足够的,因为用户代码一般不会整出蓝屏
完整内存转储提供了全部内存的转储,文件大小偏大
自动内存转储(Windows8+)等同于核心内存转储,在启动时自动调整页面文件大小,来保证有一个合适的大小来存储内核内存转储文件。
活跃内存转储(Windows10+)类似与完整内存转储,除了崩溃的系统有文件,否则是不会有的。有助于减小服务器系统的转储文件大小。

具体如何分析Dump文件可以参考此前的Windbg的使用

6.6 线程同步

一个驱动程序可以被多个应用程序调用,所以就难免会出现线程调度的问题,比如说一个在改一个在读,这样就可能造成不安全访问,这也被称为数据竞争。这种情况下最简单安全的办法就是当一个线程访问某个内容时,其他线程都不能访问,只能等待,这样就不会导致不安全的情况了。Windows提供了一些原子操作来实现线程同步。

6.6.1 互锁操作

一些驱动程序可用的互锁函数

函数描述
InterlockedIncrement/InterlockedIncrement16/InterlockedIncrement64对32/16/64位的整数原子化加一
InterlockedDecrement/16/64对32/16/64位的整数原子化减一
InterlockedAdd/InterlockedAdd64原子化的将32/64位数加到一个变量上
InterlockedExchange/8/16/64原子化的交换32/8/16/64位整数
InterlockedCompareExchange/64/128原子化地比较一个变量与一个值,如果相等则将提供的值交换到变量中并返回TRUE;否则,将当前的值放入变量中并返回FALSE

6.6.2 分发器对象

分发器对象也叫可等待对象。这些对象有着有信号和无信号两种状态,之所以被称为可等待对象是因为线程可以等待该对象从无信号到有信号然后再使用。这个在用户态下被称为信号对象。

用于等待的主要函数是KeWaitForSingleObjectKeWaitForMultipleObject 函数:

返回值有两种:STATUS_SUCCESS 等待完成有信号; STATUS_TIMEOUT:等待完成超时。

注意返回值用NT_SUCCESS宏都是返回真,不能直接用返回值为真来判断是否等待成功。

6.6.3 互斥量

很经典的一种对象,用于解决多个线程的某个线程在任何时候访问共享资源的标准问题。

互斥量Mutex在自由的时候是信号态,一旦被调用这个互斥量就变成无信号态,别的线程就无法调用它了。调用它的线程就被称为拥有者。对于Mutex来说拥有关系很重要。因为:
如果某个线程拥有了它,该线程就是唯一可以释放该互斥量的线程
一个互斥量能多次被同一线程获取,需要注意的是使用完之后必须释放掉,不然别的线程将无法获取。

要使用互斥量Mutex,需要从非分页池(non-paged pool)中分配一个KMUTEX结构。互斥量的API包含了如下与KMUTEX一起工作的函数:
KeInitializeMutex:必须被调用一次来初始化互斥量。
某一个等待函数需要将分配的KMUTEX结构体的地址作为参数传递给它
在某个线程是互斥量的拥有者时需要调用KeReleaseMutex释放互斥量
利用上述函数,这里有一个使用互斥量访问共享数据,使得一次只能有一个线程访问的例子:

KMUTEX MyMutex;
LIST_ENTRY DataHead;

void Init() { 
    KeInitializeMutex(&MyMutex, 0); 
}
void DoWork() {
    // wait for the mutex to be available
    KeWaitForSingleObject(&MyMutex, Executive, KernelMode, FALSE, nullptr); 
    // access DataHead freely
    
    // once done, release the mutex 
    KeReleaseMutex(&MyMutex, FALSE);
}

重要的是,无论怎样都要释放互斥量,因此最好使用前文提到的__try/ \_\_finally 以保证在任何情况下都能释放互斥量:

void DoWork() { 
    // wait for the mutex to be available
    KeWaitForSingleObject(&MyMutex, Executive, KernelMode, FALSE, nullptr);
    __try {
        // access DataHead freely
    }
    __finally {
        // once done, release the mutex
        KeReleaseMutex(&MyMutex, FALSE); 
    } 
}

6.6.4 快速互斥量

快速互斥量是传统互斥量的一种替代,提供了更好的性能有着自己的一套API,和传统互斥量有者以下特点:
不能递归获取,不然会造成死锁
被获取后,CPU的IRQL会提高到APC_LEVEL(1),会阻止线程上的APC传递
只能无限等待,无法指定超时时间
只能用于驱动层

6.6.5 信号量

信号量的主要目的是用来限制某些东西,比如队列的长度。信号量的最大值和初始值(一般初始值等于最大值)用KeInitalizeSemaphore来确定,当信号量内部值大于零时,处于有信号态,等于零为无信号态。调用KeWaitForSingleObject时当信号值大于零会表示等待成功然后计数减一。KeReleaseSemaphore会释放信号量让计数加一

6.6.6 事件

事件封装了一个布尔值的标志,真为有信号,假为无信号。事件的主要目的是在某事发生时发出信号,提供执行流上的同步。事件有两种类型,类型在初始化事件的时候指定:
通知事件N(手动重置):该事件被触发后会释放所有正在等待的线程,并且状态一直保持为有信号,除非被显示重置。
同步事件(自动重置):被触发后最后释放一个线程。触发后回到无信号状态。

创建方法:从非分页池里创建一个KEVENT结构,指明事件类型和初始事件状态,然后调用KeInitalizeEvent初始化,,调用KeSetEvent设置事件为有信号,调用KeResetEvent或KeClearEvent重置。

6.6.7 执行体资源

内核提供了一种单写多读的线程同步原语,就是执行体资源。

6.7 高IRQL同步

自旋锁

实现CPU同步

6.8 工作项目

用来描述在系统线程池中排队的函数

标题所指的调试为利用WinDbg(Preview)对驱动/内核进行调试,书中介绍了本地内核调试和双机内核调试两种情况,本文主要侧重介绍双机内核调试,本地内核调试只会简单带过。

5.1 本地内核调试(LKD)

本地内核调试和双机内核调试的主要区别在于本地内核调试只能查看内核当前的状态无法设置断点,所以在执行命令的时候所看到的信息不一定可靠。

我们可以使用Sysinternals的LiveKd(64)工具,使用livekd(64) -w命令让LiveKd运行WinDbg

image-20211019211514256

5.2 双机内核调试

本节介绍的是VMWare的双机调试,和书中介绍使用Hyper-V虚拟机不太一样。

首先介绍一个双机调试的神器 VirtualKD-Redux ,将target32(64)放到对应位数的虚拟机操作系统中,运行vminstall.exe(请勿在主机运行

image-20211019214133428

image-20211019214938603

重启后选择下面启动项,按F8选择禁用驱动程序强制签名

image-20211019215101037

并在实体机打开vmmon64.exe,如果是在微软商城安装的WinDbg Preview 默认就会配置好路径我们不用额外设置,如果没有可以手动设置WinDbg.exe所在路径一般在以下路径

  • x86: C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\WinDbg.exe
  • x64: C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\WinDbg.exe

image-20211019215355493

当选择进入系统并且配置好WinDbg路径,在实体机上会自动弹出WinDbg调试窗口

image-20211019215513818

5.3 WinDbg调试指令

可以参考软件调试第30章,张银奎老师讲的非常好我不再献丑了。