# 前言
本文是 "系统调用" 系列笔记的第一篇,通过分析 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.dll( ReadProcessMemory 函数在这个 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
|
总结:
- 若
NtReadVirtualMemory 返回结果小于 0,则 ReadProcessMemory 调用失败,返回 0。
- 若
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
要求:绕过 ReadProcessMemory → NtReadVirtualMemory 的调用链,直接通过 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; HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, 0, ); MyReadProcessMemory(hProcess, (PVOID)0x12ff7c, &pBuffer, 4, 0);
printf("pBuffer = %x \n", pBuffer);
getchar(); return 0; }
|
运行后,成功从进程 1 中读取了变量 num 的值:
# ReadProcessMemory 的 3 环代码总结
ReadProcessMemory 函数存在于 kernel32.dll 中。
- 它并没有做什么实质性工作,只是调用了另一个函数(
NtReadVirtualMemory ),然后设置返回值。
NtReadVirtualMemory 函数来自 ntdll.dll。
NtReadVirtualMemory 也没做实质性工作,只是提供了一个系统调用号( 0xBA )和一个函数地址( 0x7FFE0300 ),然后进行内核调用。
待解决的问题: 0x7FFE0300 到底是什么?
# _KUSER_SHARED_DATA
- 在 User 层和 Kernel 层分别定义了一个
_KUSER_SHARED_DATA 结构区域,用于 User 层和 Kernel 层共享某些数据。
- 它们使用固定的地址值映射,地址分别为:
- 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 +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 层的上下文中:
此时就可以通过 0x7FFE0000 访问到 _KUSER_SHARED_DATA 的内容:
注意:虽然两个线性地址指向的是同一个物理页,但在 User 层是只读的,在 Kernel 层是可写的。
下一篇:系统调用(二)| 3 环进 0 环