# 前言

本文是 "系统调用" 系列笔记的第一篇,通过分析 ReadProcessMemory 函数的调用过程,揭示 Windows API 从用户态(3 环)到内核态(0 环)的调用链。

参考资料:Intel 白皮书第二卷、第三卷

# Windows API

Application Programming Interface,简称 API 函数。Windows 的 API 主要存放在 C:\WINDOWS\system32 下面的 DLL 文件中。

几个重要的 DLL:

DLL 功能
Kernel32.dll 最核心的功能模块,管理内存进程线程相关的函数
User32.dll Windows 用户界面相关应用程序接口,如创建窗口发送消息
GDI32.dll 图形设备接口(Graphical Device Interface),包含画图显示文本的函数
Ntdll.dll 大多数 API 都会通过这个 DLL 进入内核(0 环)

# 实验 1:分析 ReadProcessMemory 调用过程

一、使用 IDA 打开 kernel32.dllReadProcessMemory 函数在这个 DLL 中)。

二、分析 ReadProcessMemory。

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
.text:7C8021D0 _ReadProcessMemory@20 proc near
.text:7C8021D0
.text:7C8021D0 hProcess = dword ptr 8
.text:7C8021D0 lpBaseAddress = dword ptr 0Ch
.text:7C8021D0 lpBuffer = dword ptr 10h
.text:7C8021D0 nSize = dword ptr 14h
.text:7C8021D0 lpNumberOfBytesRead= dword ptr 18h
.text:7C8021D0
.text:7C8021D0 mov edi, edi
.text:7C8021D2 push ebp
.text:7C8021D3 mov ebp, esp
.text:7C8021D5 lea eax, [ebp+nSize]
; 首先将传入的参数进行压栈
.text:7C8021D8 push eax ; NumberOfBytesRead
.text:7C8021D9 push [ebp+nSize] ; NumberOfBytesToRead
.text:7C8021DC push [ebp+lpBuffer] ; Buffer
.text:7C8021DF push [ebp+lpBaseAddress] ; BaseAddress
.text:7C8021E2 push [ebp+hProcess] ; ProcessHandle
; 调用了其它模块的函数:NtReadVirtualMemory
.text:7C8021E5 call ds:__imp__NtReadVirtualMemory@20
.text:7C8021EB mov ecx, [ebp+lpNumberOfBytesRead]
.text:7C8021EE test ecx, ecx
.text:7C8021F0 jnz short loc_7C8021FD

; 若返回结果小于 0,跳转至 loc_7C802204
.text:7C8021F2 loc_7C8021F2:
.text:7C8021F2 test eax, eax
.text:7C8021F4 jl short loc_7C802204
; 若返回结果大于等于 0,设置 eax 为 1 并返回
.text:7C8021F6 xor eax, eax
.text:7C8021F8 inc eax
.text:7C8021F9
.text:7C8021F9 loc_7C8021F9:
.text:7C8021F9 pop ebp
.text:7C8021FA retn 14h

三、分析错误处理分支 loc_7C802204。

1
2
3
4
5
6
7
; 调用 BaseSetLastNTError 设置错误码
.text:7C802204 loc_7C802204:
.text:7C802204 push eax ; Status
.text:7C802205 call _BaseSetLastNTError@4
; 将 eax 清零后返回
.text:7C80220A xor eax, eax
.text:7C80220C jmp short loc_7C8021F9

四、分析 BaseSetLastNTError。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.text:7C8093FD _BaseSetLastNTError@4 proc near
.text:7C8093FD
.text:7C8093FD mov edi, edi
.text:7C8093FF push ebp
.text:7C809400 mov ebp, esp
.text:7C809402 push esi
; 调用 RtlNtStatusToDosError,将 NT 状态码转换为 DOS 错误码
.text:7C809403 push [ebp+Status]
.text:7C809406 call ds:__imp__RtlNtStatusToDosError@4
.text:7C80940C mov esi, eax
; 调用 SetLastError 设置最后的错误码
.text:7C80940E push esi
.text:7C80940F call _SetLastError@4
.text:7C809414 mov eax, esi
.text:7C809416 pop esi
.text:7C809417 pop ebp
.text:7C809418 retn 4
.text:7C809418 _BaseSetLastNTError@4 endp

总结

  1. NtReadVirtualMemory 返回结果小于 0,则 ReadProcessMemory 调用失败,返回 0
  2. NtReadVirtualMemory 返回结果大于等于 0,则 ReadProcessMemory 调用成功,返回 1

# 实验 2:分析 NtReadVirtualMemory

一、定位函数。

查看 kernel32.dll 的导入表(Imports),找到 NtReadVirtualMemory ,可以发现该函数来自 ntdll.dll

二、使用 IDA 打开 ntdll.dll,找到 NtReadVirtualMemory 并分析。

1
2
3
4
5
6
7
8
9
10
11
12
.text:7C92D9E0 _NtReadVirtualMemory@20 proc near

; 0BAh:系统调用号,对应操作系统内核中某个函数的编号
; 所有 API 函数进 0 环时,都要提供一个编号
.text:7C92D9E0 mov eax, 0BAh ; NtReadVirtualMemory

; 7FFE0300h:函数地址,该函数决定用什么方式进 0 环
.text:7C92D9E5 mov edx, 7FFE0300h
.text:7C92D9EA call dword ptr [edx]

.text:7C92D9EC retn 14h
.text:7C92D9EC _NtReadVirtualMemory@20 endp

总结:真正读取进程内存的函数在 0 环实现,我们所用的函数只是系统提供给我们的函数接口

# 实验 3:编写自定义 ReadProcessMemory

要求:绕过 ReadProcessMemoryNtReadVirtualMemory 的调用链,直接通过 INT 0x2E 进行系统调用。

意义:自己实现的系统调用入口可以绕过 3 环 DLL 层面的 Hook(挂钩),提高安全性。

一、获得变量地址。

编译并运行以下代码(进程 1):

1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main()
{
int num = 0x12345678;
printf("&num = %x \n", &num);
getchar();
return 0;
}

运行结果:

注意:不要让进程结束。

二、构造自定义 ReadProcessMemory 并调用(进程 2)。

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
#include <stdio.h>
#include <windows.h>

void MyReadProcessMemory(
HANDLE hProcess,
LPCVOID lpBaseAddress,
LPVOID lpBuffer,
DWORD nSize,
LPDWORD lpNumberOfBytesRead)
{
__asm
{
lea eax, [ebp+0x14]
push eax ; ReturnLength
push [ebp+0x14] ; BufferLength
push [ebp+0x10] ; Buffer
push [ebp+0x0C] ; BaseAddress
push [ebp+0x08] ; ProcessHandle

mov eax, 0BAh ; 系统调用号
mov edx, esp ; 参数指针
int 02eh ; 通过 IDT[0x2E] 进行内核调用

add esp, 20 ; 平衡堆栈(5 个参数 × 4 字节 = 20 字节)
}
}

int main()
{
DWORD pBuffer;
// 获得进程句柄,PID 修改为进程 1 的 PID
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, 0, /* PID */);
// 调用自定义的 ReadProcessMemory
MyReadProcessMemory(hProcess, (PVOID)0x12ff7c, &pBuffer, 4, 0);

printf("pBuffer = %x \n", pBuffer);

getchar();
return 0;
}

运行后,成功从进程 1 中读取了变量 num 的值:

# ReadProcessMemory 的 3 环代码总结

  1. ReadProcessMemory 函数存在于 kernel32.dll 中。
  2. 它并没有做什么实质性工作,只是调用了另一个函数( NtReadVirtualMemory ),然后设置返回值。
  3. NtReadVirtualMemory 函数来自 ntdll.dll
  4. NtReadVirtualMemory 也没做实质性工作,只是提供了一个系统调用号0xBA )和一个函数地址0x7FFE0300 ),然后进行内核调用

待解决的问题0x7FFE0300 到底是什么?

# _KUSER_SHARED_DATA

  1. 在 User 层和 Kernel 层分别定义了一个 _KUSER_SHARED_DATA 结构区域,用于 User 层和 Kernel 层共享某些数据
  2. 它们使用固定的地址值映射,地址分别为:
    • User 层0x7FFE0000
    • Kernel 层0xFFDF0000
1
2
3
4
5
6
7
8
9
10
11
12
13
14
ntdll!_KUSER_SHARED_DATA
+0x000 TickCountLow : Uint4B
+0x004 TickCountMultiplier : Uint4B
+0x008 InterruptTime : _KSYSTEM_TIME
+0x014 SystemTime : _KSYSTEM_TIME
+0x020 TimeZoneBias : _KSYSTEM_TIME
// ... 省略部分成员 ...
+0x2d5 NXSupportPolicy : UChar
+0x2f8 TestRetInstruction : Uint8B
+0x300 SystemCall : Uint4B // ← 0x7FFE0300 就是这个成员
+0x304 SystemCallReturn : Uint4B
+0x308 SystemCallPad : [3] Uint8B
+0x320 TickCount : _KSYSTEM_TIME
+0x330 Cookie : Uint4B

# 实验:查看 _KUSER_SHARED_DATA

一、在 Kernel 层访问 0xFFDF0000

WinDbg 默认处于 Kernel 层,因此通过 0x7FFE0000 访问 _KUSER_SHARED_DATA 是无效的:

但是可以通过 0xFFDF0000 访问:

二、记录当前的 EPROCESS(Executive Process)块地址。

三、切换到 User 层,访问 0x7FFE0000

使用 !process 0 0 列出进程列表,选择任意 User 层的 EPROCESS 地址:

从 Kernel 层切换到 User 层的上下文中:

1
.process /p /r 85f10458

此时就可以通过 0x7FFE0000 访问到 _KUSER_SHARED_DATA 的内容:

注意:虽然两个线性地址指向的是同一个物理页,但在 User 层是只读的,在 Kernel 层是可写的。


下一篇:系统调用(二)| 3 环进 0 环