此节概念引用自《加密与解密》第7章
现代操作系统一般分为应用层和内核层两部分。应用层通过系统调用进入内核,由系统底层完成响应的功能,这时候内核执行处在该进程的上下文空间中。同时内核处理某些硬件发来的中断请求,代替硬件完成某些功能,这时候内核处在中断的上下文空间中。
系统内核层又叫零环(Ring 0),与此对应的应用层叫3环(即Ring 3)。
CPU 设计者将CPU 的运行级别从内向外分为4个,依次为R0,R1,R2,R3
,运行权限从R0到R3依次降低。操作系统设计者在设计操作系统的时候,并没有使用R1和R2 两个级别(本来应该用来运行设备驱动),而是将设备驱动运行在与内核同级别的R0级。(在AMD64 CPU 之后,CPU 也只保留了R0和R3两个级别)
当应用程序调用一个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 全称是系统服务描述符表,在内核中的实际名称是KeServiceDescriptorTable。
SSDT的每个成员叫做系统服务表,系统服务表里的函数都是来自内核文件导出的函数,但并不包含内核文件导出的所有函数,而是3环最常用的内核函数,系统服务表位于 _KTHREAD +00xE0
。
SSDT的第一个成员是导出的,声明一下即可使用,第二个成员是未导出的,需要通过其它方式查找,第三个成员和第四个成员未被使用。
SSDT 用于处理应用层通过 kernel32.dll 下发的各个API 操作请求。当kernel32.dll 中的API 通过 ntdll.dll 时会先完成对参数的检查,再调用一个中断,从而实现从R3层进入R0层,并将要调用的服务号(也就是SSDT 数组的索引号)存放到EAX 中。最后根据存放在EAX的索引值在SSDT 数组中调用指定的服务。(Nt* 系列函数)
[函数地址表 + 系统服务号*4] = 内核函数地址
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里没有DD及DW之类的内联汇编指令
_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层是可读可写的。
-
通过中断门进0环
Tips: 奔腾2之前的处理器上,Windows使用中断0x2e实现系统调用。
KiIntSystemCall 函数实现
第一行将参数的地址放入EDX中,然后调用0x2e中断(所有的API进内核时,统一的中断号为0x2e)
中断门进0环,需要的CS、EIP在IDT表中,需要查内存(SS与ESP由TSS提供)
进入0环后执行的内核函数:NT!KiSystemService (在 ntkrnlpa.exe 中)
-
快速调用进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操作系统进行维护。
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位系统调用号获取
动态读取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;
}
|
思路来自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;
}
|
-
逆向进阶
-
[系统底层] 系统调用(R3API调用过程详解)
-
通过重写ring3 API函数实现免杀
-
动态获取系统调用(syscall)号
-
[原创]Windows系统调用分析