Windows 系统调用分析与免杀实现

#Windows 内核基础

此节概念引用自《加密与解密》第7章

现代操作系统一般分为应用层和内核层两部分。应用层通过系统调用进入内核,由系统底层完成响应的功能,这时候内核执行处在该进程的上下文空间中。同时内核处理某些硬件发来的中断请求,代替硬件完成某些功能,这时候内核处在中断的上下文空间中。

#权限级别

系统内核层又叫零环(Ring 0),与此对应的应用层叫3环(即Ring 3)。

CPU 设计者将CPU 的运行级别从内向外分为4个,依次为R0,R1,R2,R3,运行权限从R0到R3依次降低。操作系统设计者在设计操作系统的时候,并没有使用R1和R2 两个级别(本来应该用来运行设备驱动),而是将设备驱动运行在与内核同级别的R0级。(在AMD64 CPU 之后,CPU 也只保留了R0和R3两个级别)

#R3 与 R0 通信

当应用程序调用一个API 时,实际上是调用应用层的某个DLL 库(如kernel32.dll 、user32.dll)。而此DLL 中还会调用在ntdll.dll 中的Native API 函数。例如当kernel32.dll 中的API 通过ntdll.dll 执行时,会完成参数的检查工作,再调用一个中断(int 2Eh或者SysEnter/syscall指令),里面存放了与ntdll.dll 中对应的SSDT 系统服务处理函数,即内核态的Nt*系列函数,它们与ntdll.dll 中的函数一一对应。

大部分API在R3都是处理各种校验,真正执行功能都是在R0(并不是所有的API都是在R0处理)。

ntdll.dll 中的Native API 函数时成对出现的,分别以Nt和Zw 开头,它们本质上是一样的只是名字不同。使用Zw* 系列的API 可以避免额外的参数列表检查,提高效率。 Kernel32.dll: 最核心的功能模块,比如管理内存、进程和线程相关的函数等。

User32.dll: 是Windows用户界面相关应用程序接口,如创建窗口和发送消息等。

Ntdll.dll: 大多数API都会通过这个DLL进入内核。

#SSDT

SSDT 全称是系统服务描述符表,在内核中的实际名称是KeServiceDescriptorTable。

SSDT的每个成员叫做系统服务表,系统服务表里的函数都是来自内核文件导出的函数,但并不包含内核文件导出的所有函数,而是3环最常用的内核函数,系统服务表位于 _KTHREAD +00xE0

SSDT的第一个成员是导出的,声明一下即可使用,第二个成员是未导出的,需要通过其它方式查找,第三个成员和第四个成员未被使用。

SSDT 用于处理应用层通过 kernel32.dll 下发的各个API 操作请求。当kernel32.dll 中的API 通过 ntdll.dll 时会先完成对参数的检查,再调用一个中断,从而实现从R3层进入R0层,并将要调用的服务号(也就是SSDT 数组的索引号)存放到EAX 中。最后根据存放在EAX的索引值在SSDT 数组中调用指定的服务。(Nt* 系列函数)

[函数地址表 + 系统服务号*4] = 内核函数地址

#Windows API 调用过程分析

#32位 ReadProcessMemory 分析与实现

1
2
3
4
5
6
7
8
9
#include <Windows.h>
#include <stdio.h>
int main() {
    HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, 4980);
    DWORD dwData = 0;
    ReadProcessMemory(hProcess, (PVOID)0x400000, &dwData, 4, NULL);

    return 0;
}

编译后使用DBG 进行调试。

Tips: 分析32位调用过程,需要在32位系统上进行。(在64位系统上运行的32位程序与运行在32位系统上的调用过程是不一样的,下面介绍原因) 运行到call dword ptr ds:[<&ReadProcessMemory>] 并进入函数。 可以看到调用了kernel32.dll中的ReadProcessMemory函数,继续跟入jmp。 这里进入了kernelbase.dll 中的ReadProcessMemory函数,并从该函数调用了ntdll.dll 中的ZWReadVirtualMemory函数。紧接着进入ntdll.dll 中。 根据前面的介绍,可知115 就是ReadProcessMemory的系统调用号,保存在eax寄存器中。

1
2
mov edx,<&KiFastSystemCall> 
call dword ptr ds:[edx]

<&KiFastSystemCall> 是一个函数地址(固定为0x7FFE0300),通过该函数进入0环(由KUSER_SHARED_DATA.SystemCall 决定以哪种方式进入0环)。真正读取进程内存的函数在0环实现,我们所用的函数只是系统提供的函数接口。

KiFastSystemCall函数中的内容,可以看到把当前栈顶放入edx中后调用sysenter进入内核。 实现 ReadProcessMemory(x86)

不使用任何DLL,直接调用0环函数,可以绕过AV/EDR 的API Hook(通过在执行Win32 API调用之前对其进行检查,确定它们是否可疑/恶意以及阻止或允许调用继续进行)。

用以下代码起一个进程,后面通过自己编写ReadProcessMemory函数的调用过程读取num的值。

1
2
3
4
5
6
7
8
#include <Windows.h>
#include <stdio.h>
int main() {
    int num = 0x1234567;
    printf("num地址: %x\n",&num);
    getchar();
    return 0;
}

通过中断门进入内核

TIps: 以下代码只能在32位系统上正常运行,64位系统的调用方式不一样。

原因解释:

在微软推出64位的Windows 版本时,为了保证和现有32位应用程序的兼容性,实现了WOW64系统,它负责将所有Windows API 调用从32位用户空间转换为64位操作系统内核。在WOW64版本中,通过Wow64SystemServiceCall函数进行系统调用,而不是syscall指令。该函数会调用到wow64cpu!KiFastSystemCall,此函数会将CPU 从32位切换到64位模式,在64位上进行系统调用。所以32位程序在64位系统上运行和在32位系统上运行的调用过程完全是不一样的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <stdio.h>
#include <windows.h>
BOOL MyReadProcessMemory(
    HANDLE  hProcess,
    LPCVOID lpBaseAddress,
    LPVOID  lpBuffer,
    SIZE_T  nSize,
    SIZE_T* lpNumberOfBytesRead
)
{
    __asm
    {
        lea eax, lpNumberOfBytesRead
        push eax
        push nSize
        push lpBuffer
        push lpBaseAddress
        push hProcess
        mov  eax, 115h
        mov  edx, esp
        int 2eh
        add  esp, 20
    }
}

int main()
{
    
    DWORD dwData = 0;
    HANDLE handle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, 2900);
    MyReadProcessMemory(handle, (LPVOID)0x41fea8, &dwData, 4, NULL);
    printf("dwData=%x \n", dwData);
    getchar();
    return 0;
}

通过sysenter 快速调用进入内核

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <stdio.h>
#include <windows.h>
BOOL MyReadProcessMemory(
    HANDLE  hProcess,
    LPCVOID lpBaseAddress,
    LPVOID  lpBuffer,
    SIZE_T  nSize,
    SIZE_T* lpNumberOfBytesRead
)
{
    BOOL bRet = 0;
    __asm
    {
        lea eax, lpNumberOfBytesRead
        push eax
        push nSize
        push lpBuffer
        push lpBaseAddress
        push hProcess

        //模拟call ZwReadVirtualMemory 
        sub esp, 4

        //系统服务号
        mov eax, 0x115

        //模拟call KiFastSystemCall  必须在堆栈中保存下一条指令(返回)地址(IP)
        PUSH RETADDR

        //快速调用
        mov edx, esp

        //sysenter对应硬编码
        _emit 0x0F; _EMIT伪指令相当于MASM中的DB,一次只能定义一个字节。VC里没有DDDW之类的内联汇编指令
        _emit 0x34;

    RETADDR:
        add esp, 24
        mov bRet, eax
    }
    return bRet;
   
}

int main()
{
    
    DWORD dwData = 0;
    HANDLE handle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, 2900);
    ReadProcessMemory(handle, (LPVOID)0x41fea8, &dwData, 4, NULL);
    printf("dwData=%x \n", dwData);
    getchar();
    return 0;
}

这里解释一下为什么必须模拟两次call调用:

call 会在栈中保存call 语句下一条指令的IP地址,所以会压栈。由于在前面分析API 调用时,会将最后的栈顶给edx寄存器,所以栈顶结构必须要一致。

#中断调用与快速调用

当设置mov eax,1 并执行cpuid指令后,处理器的特征信息会被保存在ecx和edx寄存器中,而edx包含了一个SEP位(第11位),该位指明了当前处理器知否支持sysenter/sysexit指令(用于快速在R3和R0之间转换的指令)。

如果SEP为1,说明当前CPU支持 sysenter 指令,那么函数在进行系统调用时会通过ntdll.dll! KiFastSystemCall() 进行快速调用,不支持就会通过ntdll.dll! KiIntSystemCall() 进行中断门调用。

KUSER_SHARED_DATA.SystemCall对应的值就是ntdll!KiInitSystemCall的地址。

_KUSER_SHARED_DATA 结构

在所有的体系结构中,都有一个称为KUSER_SHARED_DATA的结构体,它属于进程,并且总是映射到0x7ffe0000,此结构用于 User 层和 Kernel 层共享某些数据,它们指向的是同一个物理页,但在User层是只读的,在Kernel层是可读可写的。

  1. 通过中断门进0环

    Tips: 奔腾2之前的处理器上,Windows使用中断0x2e实现系统调用。

    KiIntSystemCall 函数实现

    第一行将参数的地址放入EDX中,然后调用0x2e中断(所有的API进内核时,统一的中断号为0x2e)

    中断门进0环,需要的CS、EIP在IDT表中,需要查内存(SS与ESP由TSS提供)

    进入0环后执行的内核函数:NT!KiSystemService (在 ntkrnlpa.exe 中)

  2. 快速调用进0环

    Tips: Intel x86下Windows通过sysenter实现系统调用。

    KiFastSystemCall 函数实现

    将当前栈顶(esp)的值放入edx中,然后执行sysenter指令

    CPU如果支持sysenter指令,操作系统会提前将CS/SS/ESP/EIP的值存储在MSR寄存器中,sysenter指令执行时,CPU会将MSR寄存器中的值直接写入相关寄存器,没有读内存的过程,所以叫快速调用。

    进入0环后执行的内核函数:NT!KiFastCallEntry

无论是通过中断门还是快速调用进入0环,进入0环前的所有寄存器都会存到Trap Frame结构体中,此结构体本身处于0环,由Windows操作系统进行维护。

#64位 ReadProcessMemory 分析与实现

Tips: Intel x64下Windows通过syscall实现系统调用。

同样编译成64位后,使用DBG调试。

前面的调用过程都一样,主要看看ntdll.dll 中的内容有什么不同。 可以看到,这里的系统调用号是3F。之后会使用syscall 进入内核。使用IDA 反汇编更加直观 test byte ptr ds:[7FFE0308],1 是用来判断cpu是否支持快速调用,如果支持使用syscall快速调用进入内核,反之通过中断调用进入内核。

所以对于syscall的调用就比较简单直观了,

实现 ReadProcessMemory(x64)

Tips: x64 无法使用内联汇编。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <windows.h>

EXTERN_C NTSTATUS NTAPI NtReadVirtualMemoryProc(
    HANDLE               ProcessHandle,
    PVOID                BaseAddress,
    PVOID               Buffer,
    ULONG                NumberOfBytesToRead,
    PULONG              NumberOfBytesReaded);

int main()
{
    DWORD dwData = 0;
    HANDLE handle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, 9204);
    //ReadProcessMemory
    NtReadVirtualMemoryProc(handle, (LPVOID)0xf2f848, &dwData, 4, NULL);
    printf("dwData=%x \n", dwData);
    return 0;
}

syscall.asm

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
.code

NtReadVirtualMemoryProc proc
	mov     r10, rcx     
	mov     eax, 3Fh
	syscall
	ret
NtReadVirtualMemoryProc endp

end

#动态获取系统调用号

各系统64位系统调用号获取 | 各系统32位系统调用号获取

#x64

动态读取ntdll.dll 中的函数,获取系统调用号

思路参考idiotc4t的文章,详情见参考链接。

以NtReadVirtualMemory 为例 首先通过GetProcAddress获取ntdll内NtReadVirtualMemory 函数地址,在距离函数地址偏移四字节位置获取到系统调用号。再做一个系统调用的模板,修改模板中的系统调用号位置,然后通过指针函数对函数模板进行调用。(其实和x86的动态获取也差不多,x86 时直接复制了那一块的代码直接执行)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <stdio.h>
#include <windows.h>
#pragma comment(linker, "/section:.data,RWE")//.data段可执行
CHAR FuncExample[] = {
    0x4c,0x8b,0xd1,			  //mov r10,rcx
    0xb8,0xb9,0x00,0x00,0x00, //mov eax,0B9h
    0x0f,0x05,				  //syscall
    0xc3					  //ret
};
typedef NTSTATUS(NTAPI* NtReadVirtualMemoryProc)(
    HANDLE               ProcessHandle,
    PVOID                BaseAddress,
    PVOID               Buffer,
    ULONG                NumberOfBytesToRead,
    PULONG              NumberOfBytesReaded);

VOID SetSysCall() {
    DWORD SysCallid = 0;
    HMODULE hModule = GetModuleHandleA("ntdll.dll");
    if (hModule) {
        DWORD64 FuncAddr = (DWORD64)GetProcAddress(hModule, "NtReadVirtualMemory");
        LPVOID CallAddr = (LPVOID)(FuncAddr + 4); //读取的基地址指向0xb8的地址
        ReadProcessMemory(GetCurrentProcess(), CallAddr, &SysCallid, 1, NULL); //从0xb8开始读取一个字节
        memcpy(FuncExample + 4, (CHAR*)&SysCallid, 1); //复制一个字节到数组
    }

}
int main()
{
    SetSysCall();
    DWORD dwData = 0;
    HANDLE handle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, 7404);
    NtReadVirtualMemoryProc  NtReadVirtualMemory = (NtReadVirtualMemoryProc)&FuncExample;
    NtReadVirtualMemory(handle, (LPVOID)0x4ff800, &dwData, 4, NULL);
    printf("dwData=%x \n", dwData);
    return 0;
}

#x86

思路来自woshikele的文章,详情见参考链接。

定义函数指针,申请可执行内存。复制ntdll.dll 中NtReadVirtualMemory 函数内存中的指令到该可执行内存中,调用该函数指针执行。(该32位程序同时适用于在x86 系统和x64 系统上动态获取指令)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h>
#include <windows.h>

typedef NTSTATUS(NTAPI* NtReadVirtualMemoryProc)(
    HANDLE               ProcessHandle,
    PVOID                BaseAddress,
    PVOID               Buffer,
    ULONG                NumberOfBytesToRead,
    PULONG              NumberOfBytesReaded);

int main()
{
    HMODULE hModule = LoadLibraryA("ntdll.dll");
    PUCHAR pFunAddr = (PUCHAR)GetProcAddress(hModule, "NtReadVirtualMemory");

    //获取ZwOpenProcess函数长度
    ULONG uSize = 0;
    for (int i = 0; i < 100; i++)
    {
        //C2 == ret 
        if (pFunAddr[i] == 0xc2)
        {
            uSize = i + 2;
            break;
        }
    }

    //函数指针申请内存
    NtReadVirtualMemoryProc func = (NtReadVirtualMemoryProc)VirtualAlloc(NULL, 0x1000, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
    //拷贝默认函数数据
    memcpy(func, pFunAddr, uSize);

    DWORD dwData = 0;
    HANDLE handle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, 2900);
    func(handle, (LPVOID)0x41fea8, &dwData, 4, NULL);
    printf("dwData=%x \n", dwData);
    return 0;
}

#参考

  1. 逆向进阶

  2. [系统底层] 系统调用(R3API调用过程详解)

  3. 通过重写ring3 API函数实现免杀

  4. 动态获取系统调用(syscall)号

  5. [原创]Windows系统调用分析

加载评论