依然是 x86 的,照着 lzyddf 师傅的 blog 和 OneTrainee师傅的blog 学的
Windows API
Application Programming Interface,简称 API 函数。Windows API 是微软为 Windows 操作系统提供的一组函数、数据结构、常量和协议,允许开发者与操作系统进行交互。通过 Windows API,开发者可以创建和管理窗口、处理用户输入、执行文件操作、管理内存、进行网络通信等。
Windows 的 API 主要是存放在 C:\WINDOWS\system32 下面所有的 dll。列举几个重要的系统自带的 dll:
- Kernel32.dll:最核心的功能模块,比如管理内存、进程和线程相关的函数等
- User32.dll:是 Windows 用户界面相关应用程序接口,如创建窗口和发送消息等
- GDI32.dll:全称是 Graphical Device Interface(图形设备接口),包含用于画图和显示文本的函数。比如要显示一个程序窗口,就调用了其中的函数来画这个窗口
- Ntdll.dll:大多数 API 都会通过这个 DLL 进入内核(0环)
分析 ReadProcessMemory
如果你用的 Windows XP,那么这个函数在 kernel32.dll。但是如果你是 Windows 7 或更高的系统,那么 kernel32.dll 就只是一个转发层,实际实现位于 KernelBase.dll 内。不止是 ReadProcessMemory,很多函数的实现都被重构到了 KernelBase.dll 内。这里我们看见的函数都是 Windows 11 里面的实现。
逻辑十分简单。BaseSetLastNTError 函数实现如下,这个函数的作用就是将 NTSTATUS 错误码 转换为 Win32 错误码,并将其设置为当前线程的最后一个错误码。简单理解就是把错误码传给用户层程序的东西。
__int64 __fastcall BaseSetLastNTError(NTSTATUS a1)
{ULONG v1; // ebxv1 = RtlNtStatusToDosError(a1);RtlSetLastWin32Error(v1);return v1;
}
那我们发现,ReadProcessMemory 好像啥也没干,其实就调用了一下 NtReadVirtualMemory 函数然后进行了返回值和错误处理的设置。
分析 NtReadVirtualMemory
查一下 KernelBase.dll 的导入表可以发现 NtReadVirtualMemory 是 ntdll 导入的。然后这个函数直接在 Functions 里面还搜不到,得在 Exports 里面搜
养成先看汇编的好习惯(
我们会发现这个函数名字叫 ZwReadVirtualMemory。最上面两行可以看见它有两个导出符号,NtReadVirtualMemory 导出序号 547 和 ZwReadVirtualMemory 导出序号 2193
然后我们来分析一下这个函数。首先将第一个参数(rcx)保存到 r10,因为 syscall 会使用 rcx 寄存器,然后将系统调用号 0x3F 存入 eax。接着 test 这里是检查是否使用快速系统调用,如果不使用快速系统调用就跳转到 int 2Eh,使用 int 2Eh 进入内核,否则就直接 syscall 进入内核。
具体解释一下。0x3F 是 NtReadVirtualMemory 的系统调用号。test byte ptr ds:7FFE0308h, 1
这里,检查地址 0x7FFE0308 处的字节的最低位是否为 1。这个地址是 Windows 的 系统调用分发表(KiSystemCall64)的一部分,用于决定使用哪种方式进入内核模式。最低位为 0 就是 syscall,最低位为 1 就是 int 2Eh。syscall 是 x64 架构下的快速系统调用指令,int 2Eh 是传统的调用方法。
那么逻辑差不多就是这样:用户模式代码调用 ZwReadVirtualMemory,ZwReadVirtualMemory 准备系统调用号 0x3F 和参数,然后通过 syscall 或 int 2Eh 进入内核模式。内核模式的 KiSystemCall64 根据 eax 中的系统调用号 0x3F,调用对应的内核函数 NtReadVirtualMemory。内核函数执行完毕后,返回到用户模式的 ZwReadVirtualMemory,ZwReadVirtualMemory 再返回到调用者。
所以,真正读取进程内存的函数在 0 环实现,我们所用的函数只是系统提供给我们的函数接口。那么我们其实可以把 kernel32.dll 和 KernelBase.dll 都理解为一个分发器,或者说 Ring 3 到 Ring 0 的桥梁。
3 环进 0 环
我们看到,ReadProcessMemory 函数最终进入 0 环的方法是通过 int 2E 或者 syscall 快速系统调用,这里我们就来研究 3 环进 0 环。
在操作系统中,内存被划分为用户空间和内核空间,CPU 的运行状态也因此分为用户态和内核态。用户态下,CPU 只能访问用户空间的内存;而内核态下,CPU 可以访问整个内存空间,包括内核空间,并能够执行特权指令。因此,CPU 进入内核态意味着其运行状态从用户态切换到内核态,获得了更高的权限。
CPU 从内核态切换到用户态相对简单,因为内核态可以执行特权指令。然而,用户态无法直接执行特权指令,所以只能通过其它方式来切换。在保护模式的笔记里面我们有学到过可以通过中断和异常的方式切到内核态,但是这两种方法 CPU 都是被动地进入的内核态。还有一种由程序主动触发的方法就是自陷(Trap)。自陷是一种主动行为,通常用于实现系统调用。执行自陷指令后,CPU 会切换到内核态,并跳转到内核中预定义的处理程序。int 2Eh 和 syscall 都是经典的自陷指令。
从 3 环进 0 环需要这些寄存器的改变:
- CS 的权限由 3 变为 0,意味着需要新的 CS
- SS 与 CS 的权限永远一致,需要新的 SS
- 权限发生切换的时候,堆栈也一定会改变,需要新的 ESP
- 进 0 环后的代码位置,需要 EIP
test byte ptr ds:7FFE0308h, 1
这里,检查地址 0x7FFE0308 处的字节的最低位是否为 1。这个地址是 Windows 的 系统调用分发表(KiSystemCall64)的一部分,用于决定使用哪种方式进入内核模式。最低位为 0 就是 syscall,最低位为 1 就是 int 2Eh。
我们系统是 64 位的,上述过程也是 64 位的东西。在 x86 中,检查的是地址 0x7FFE0300,这个地址其实是结构体_KUSER_SHARED_DATA
的一个成员。Windows 在在 User 层和 KerNel 层分别定义了一个_KUSER_SHARED_DATA 结构区域,用于 User 层和 Kernel 层共享某些数据。它们使用同一段页,只是映射位置不同。虽然同一页,但 User 只读,Kernel 层可写。它们使用固定的地址值映射,_KUSER_SHARED_DATA 结构在User为 0x7FFE0000,在 Kernel 层为 0xFFDF0000。
+0x300 SystemCall : Uint4B。该成员保存着系统调用的函数入口,如果当前CPU支持快速调用,则存储着ntdll.dll!KiFastSystemCall()
函数地址;如果不支持快速调用,则存储着ntdll.dll!KiIntSystemCall()
函数地址。
int 0x2E 进 0 环
来看ntdll.dll!KiIntSystemCall()
函数
.text:77F070C0.text : 77F070C0 public KiIntSystemCall.text : 77F070C0 KiIntSystemCall proc near; DATA XREF : .text : off_77EF61B8↑o.text : 77F070C0.text : 77F070C0 arg_4 = byte ptr 8.text : 77F070C0 // 之前调用该函数时 mov eax, 0x115, 向 eax 传入一个函数号.text : 77F070C0 lea edx, [esp + arg_4] // 当前参数的指针存储在 edx 中.text : 77F070C4 int 2Eh; // 通过中断门的形式进入到内核中.text:77F070C4; DS:SI->counted CR - terminated command string.text : 77F070C6 retn.text : 77F070C6 KiIntSystemCall endp
注意在执行 KiIntSystemCall 函数前,编号已被写入 eax。其在触发 int 2Eh 中断前用到两个寄存器,一个是内核中调用函数的函数号,另外一个就是传入参数的指针。
流程大概是这样:
在系统启动初期,Windows 会初始化 IDT,并将 int 2Eh 的中断服务例程注册为 KiSystemService 函数。因此,当用户模式代码执行 int 2Eh 指令时,CPU 会通过 IDT 找到 KiSystemService 函数,并将执行权交给它。
KiSystemService 位于内核空间,在切换执行权之前,CPU 会检查源位置(用户态代码段)和目标位置(内核态代码段)的权限,确保 CPL 正确。如果权限检查失败,CPU 会触发一般保护异常(#GP)。每个线程在内核态执行时都必须使用独立的内核栈,大小通常为 8KB 或 12KB。CPU 会将当前线程的 ESP 和上下文(EFLAGS、CS、EIP 等)保存到内核栈中,然后切换到内核栈。
KiSystemService 函数会根据系统调用号从系统服务分发表 SSDT 中查找对应的服务函数地址和参数描述,随后会将用户态传递的参数从用户栈复制到当前线程的内核栈中。准备完毕后,KiSystemService 调用内核中真正的服务函数来执行请求的操作。服务函数在内核态完成操作后,将结果返回给 KiSystemService。KiSystemService 将操作结果从内核栈复制回用户栈。最后,KiSystemService 通过 IRET 指令将执行权交回给用户模式的 ntdll.dll,继续执行 int 2Eh 后面的指令。
快速系统调用进 0 环
上面那坨流程看着就慢,有一堆内存访问和查表、检查权限的操作。所以就有了快速调用。
来看ntdll.dll!KiFastSystemCall()
函数
.text:77F070B0 public KiFastSystemCall.text:77F070B0 KiFastSystemCall proc near ; DATA XREF: .text:off_77EF61B8↑o.text:77F070B0 // 之前调用该函数时 mov eax, 0x115,向eax传入一个函数号.text:77F070B0 mov edx, esp // 将当前堆栈放入edx,用它来存储参数.text:77F070B2 sysenter.text:77F070B2 KiFastSystemCall endp
这里的核心指令是 sysenter。在执行 sysenter 指令之前,操作系统必须指定 0 环的 CS 段、SS 段、EIP 以及 ESP。其中,CS 段、EIP 以及 ESP 来自 MSR 寄存器。这里列举三个 MSR 寄存器最重要的值。SS 不能直接从 MSR 寄存器获得,但 SS = IA32_SYSENTER_CS+8。
保存现场
首先了解一下 Trap Frame 结构体。
无论是通过中断门进入 0 环,还是通过快速调用进入 0 环,进入 0 环前(3 环)的所有寄存器都会存到这个结构体中。这个结构体本身处于 0 环,由 windows 操作系统进行维护。当程序通过中断门从 3 环进入 0 环时,ESP 指向 TrapFrame+0x64 的位置,当程序通过快速调用从 3 环进入 0 环时,ESP 指向 TrapFrame+0x78 的位置。
在保护模式下,最后四个成员(0x7C~0x88)并没有被使用,因此无需考虑;只有在虚拟 8086 模式下,才会用到。当中断门执行时,3 环的 SS、ESP、EFLAGS、CS、EIP 会被存储到结构体的 0x68~0x78 中,而执行快速调用时不会
上一节我们讲 int 2Eh 的流程的时候提到了一个叫内核栈的东西,Trap Frame 可以理解为内核栈的一个子集,就是专门用来保存寄存器的。
TrapFrame 结构体的其它成员通过 KiSystemService 和 KiFastCallEntry 进行赋值。不管是 KiSystemService 还是 KiFastCallEntry,最终都要执行一部分相同的代码,分为两个函数是因为进入 0 环时,堆栈里的值不一样,走同一条函数会出问题。
SST&SSDT
系统服务表(System Service Table,SST)共有两张,第一张表后紧接第二张表,里的函数都是来自内核文件导出的函数。它并不包含内核文件导出的所有函数,而是 3 环最常用的内核函数。系统服务表位于 _KTHREAD +00xE0
typedef struct _SERVICE_DESCRIPTOR_TABLE
{PULONG ServiceTableBase; // 指针,指向函数地址,每个成员占4字节PULONG ServiceCounterTableBase; // 当前系统服务表被调用的次数ULONG NumberOfService; // 服务函数的总数PUCHAR ParamTableBase; // 服务函数的参数总长度,以字节为单位,每个成员占一个字节// 如:服务函数有两个参数,每个参数占四字节,那么对应参数总长度为8// 函数地址成员 与 参数总长度成员 一一对应
} SSDTEntry, *PSSDTEntry;
系统服务描述符表(System Services Descriptor Table,SSDT)的每个成员叫做系统服务表。SSDT 的第一个成员是导出的,声明一下即可使用,第二个成员是未导出的,需要通过其它方式查找。
在 Windows 中,SSDT 的第三个成员和第四个成员未被使用
总结
还算是比较好理解的。参考的两位师傅的 blog 有一些在 xp 环境下用 windbg 做的实验就只是看了一下,没有在这里写下来,但是感觉还是很有用的。后续打算先 2 周内把毕设速通一下,做到只剩个论文没写,然后准备一下中期答辩,之后就是看进程和线程的内容和驱动开发的东西以及玩怪猎荒野,再然后就是研究一下 cs2 的 external hack,看能做到什么程度吧。