# 前言

上一篇我们分析了 KiSystemServiceKiFastCallEntry 如何保存 3 环现场。本篇将分析它们的共同代码部分 —— 如何根据系统调用号找到并调用内核函数。

# SST(System Service Table,系统服务表)

  1. 系统服务表共有两张,第一张表后紧接第二张表。
  2. 第一张表的函数来自 ntoskrnl.exe (基本系统调用),第二张表的函数来自 win32k.sys (图形 / 窗口相关调用)。
  3. 它并不包含内核文件导出的所有函数,而是 3 环最常用的内核函数。
  4. 系统服务表指针位于 _KTHREAD + 0xE0ServiceTable 成员)。
1
2
3
4
5
6
7
8
9
typedef struct _SERVICE_DESCRIPTOR_TABLE
{
PULONG ServiceTableBase; // 函数地址表指针,每个成员占 4 字节
PULONG ServiceCounterTableBase; // 当前系统服务表被调用的次数
ULONG NumberOfService; // 服务函数的总数
PUCHAR ParamTableBase; // 参数总长度表指针,每个成员占 1 字节
// 例:函数有两个 DWORD 参数,则对应参数总长度为 8
// 函数地址 与 参数总长度 一一对应
} SSDTEntry, *PSSDTEntry;

# 实验 1:分析共同代码

KiSystemServiceKiFastCallEntry 最终都跳转到 loc_406922 执行以下共同代码:

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
; 从 eax 寄存器取出 3 环传入的系统调用号
.text:00406922 mov edi, eax

; 系统调用号右移 8 位
.text:00406924 shr edi, 8

; 与 0x30 做与运算,检测第 12 位的值
; WindowsNT 基本的系统调用编号小于 0x1000
; 编号大于 0x1000 的系统调用来自 win32k.sys(图形/窗口相关)
; 若第 12 位为 0(结果为 0x00)→ 在 ntoskrnl.exe 中查找
; 若第 12 位为 1(结果为 0x10)→ 在 win32k.sys 中查找
.text:00406927 and edi, 30h

; 将运算结果赋值给 ecx
.text:0040692A mov ecx, edi

; KTHREAD + 0xE0 = ServiceTable(系统服务表指针)
; 将系统服务表地址加上 edi 的运算结果
; 巧妙地选择使用哪张表(两张表连续,每张占 16 字节)
.text:0040692C add edi, [esi+0E0h]

; 将系统调用号赋值给 ebx
.text:00406932 mov ebx, eax

; 保留调用号的低 12 位,作为函数地址的下标
.text:00406934 and eax, 0FFFh

; 校验系统调用号是否越界
; ServiceTable + 0x8 = NumberOfService(服务函数总数)
.text:00406939 cmp eax, [edi+8]
.text:0040693C jnb _KiBBTUnexpectedRange

; 若系统调用号 < 0x1000,跳过 GUI 批处理
.text:00406942 cmp ecx, 10h
.text:00406945 jnz short loc_406962

; 只有 ecx == 0x10(win32k.sys 调用)才会执行以下代码
; 作用是动态加载 GUI 等图形相关函数
.text:00406947 mov ecx, large fs:18h
.text:0040694E xor ebx, ebx
.text:00406950 or ebx, [ecx+0F70h]
.text:00406956 jz short loc_406962
.text:00406958 push edx
.text:00406959 push eax
.text:0040695A call ds:_KeGdiFlushUserBatch
.text:00406960 pop eax
.text:00406961 pop edx

.text:00406962 loc_406962:
; 系统调用计数 +1
; KPRCB + 0x518 = KeSystemCalls
.text:00406962 inc large dword ptr fs:638h

; edx = 3 环参数指针(esi 保存)
.text:00406969 mov esi, edx

; 取出参数表指针
; ServiceTable + 0xC = ParamTableBase
.text:0040696B mov ebx, [edi+0Ch]

; ecx 清零
.text:0040696E xor ecx, ecx

; 取出当前内核函数的参数总长度(单位:字节)
; ParamTableBase[eax] → cl
.text:00406970 mov cl, [eax+ebx]

; 取出函数地址表指针
; ServiceTable + 0x0 = ServiceTableBase
.text:00406973 mov edi, [edi]

; 函数地址 = ServiceTableBase[eax * 4]
.text:00406975 mov ebx, [edi+eax*4]

; 提升堆栈,为参数腾出空间
.text:00406978 sub esp, ecx

; 参数总长度 / 4 = 参数个数
.text:0040697A shr ecx, 2

; 设置复制的目的地
.text:0040697D mov edi, esp

; 检查 3 环参数是否越界
.text:0040697F cmp esi, ds:_MmUserProbeAddress
.text:00406985 jnb loc_406B33

.text:0040698B loc_40698B:
; 将 3 环参数复制到 0 环堆栈
.text:0040698B rep movsd

; 调用内核函数
.text:0040698D call ebx

总结

  1. 解析系统调用号:通过检查第 12 位决定查找 ntoskrnl.exe (基本系统调用)还是 win32k.sys (GUI 扩展调用)。
  2. 校验调用号合法性:与 NumberOfService 比较,防止越界。
  3. 复制用户态参数到内核栈:通过 rep movsd 指令将 3 环参数拷贝到 0 环堆栈。
  4. 跳转执行call ebx 调用对应的内核函数。

# SSDT(System Service Descriptor Table,系统服务描述符表)

  1. SSDT 的每个成员叫做系统服务表(SST)。
  2. SSDT 的第一个成员是导出的,声明一下即可使用。
  3. SSDT 的第二个成员是未导出的,需要通过其他方式查找。
  4. 在 Windows 中,SSDT 的第三个和第四个成员未被使用

在 WinDbg 中查看已导出成员:

1
kd> dd KeServiceDescriptorTable

在 WinDbg 中查看未导出成员:

1
kd> dd KeServiceDescriptorTableShadow

# 实验 2:在 SSDT 中查找内核函数信息

说明

  1. 在之前的实验中,我们知道 ReadProcessMemory 即将进入内核时传递了一个系统调用号 0xBA
  2. 本次实验在 SSDT 中查找编号 0xBA 对应的内核函数信息。

一、查看函数地址。

函数地址表:

[函数地址表 + 系统调用号 × 4] = 内核函数地址:

二、查看参数个数。

参数表:

[参数表 + 系统调用号] = 内核函数参数长度(单位:字节):

三、查看内核函数反汇编。

# 练习:在 SSDT 中新增函数并调用

要求:在 SSDT 表中追加一个函数地址( NtReadVirtualMemory ),自己编写 API 的 3 环部分调用这个新增的函数。

一、在 ServiceTableBase 末尾新增一个函数。

查看 SSDT 表:

查看 NtReadVirtualMemory 地址:

查看 ServiceTableBase 的最后一项:

NtReadVirtualMemory 的地址写入 ServiceTableBase 的末尾:

二、函数个数加 1。

三、在参数表末尾加入新增函数的参数长度(单位:字节)。

四、编写 3 环代码,通过新的调用号调用 NtReadVirtualMemory。

进程 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;
}

进程 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
#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, 11ch ; 新的系统调用号
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 */);
MyReadProcessMemory(hProcess, (PVOID)0x12ff7c, &pBuffer, 4, 0);

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

getchar();
return 0;
}

运行结果:

进程 2 通过新的调用号 0x11C 成功读出了进程 1 的内存,说明 SSDT 中新增的函数调用成功。


上一篇:系统调用(三)| 保存现场