0%

博客相关知识点整理

进程强杀

因为ring0的特权级别是比ring3高的,那么我们肯定不能在ring3调用windows提供的api杀死ring0特权级别的进程,那么这时候我们就需要使用ring0的函数来强行结束一些处于ring0级别的进程。

ZwTerminateProcess

该函数是一个Ring0函数,结构参数如下:

1
2
3
4
NTSYSAPI NTSTATUS ZwTerminateProcess(
[in, optional] HANDLE ProcessHandle,
[in] NTSTATUS ExitStatus
);

但是这个函数在RIng0层面下,已经被杀软hook掉了,所以如果调用这个函数Kill杀软会被拒绝。

PspTerminateProcess

image-20220711173115524

这个函数是没有被导出的,要调用该函数有两种方法

  1. 暴力搜索,提取该函数的特征码,全盘搜索。
  2. 如果有已文档化的函数调用了PspTerminateProcess,那我们就可以通过指针加偏移的方式获取到他的地址,同样可以调用。

我们的驱动在被系统加载的同时,内存中会出现一个描述我们驱动信息的对象:DRIVER OBJECT,而这个对象的地址,其实就保存在我们驱动的入口函数 Driver Entry 的第1个参数中。

DriverObject 对象中,有一个 Driver Section 成员,它所指向的是一个名叫 _LDR_DATA_TABLE_ENTRY 的结构体,该结构体每个驱动模块都有一份,在这个结构体中就保存着一个驱动模块的所有信息。

系统中有一个双向链表,其中每一个节点都保存着一个驱动模块的所有信息,而 InLoadOrderLinks 就是该链表中的节点类型,Flink 指向下一个驱动对象的 _LDR_DATA_TABLE_ENTRYBlink 指向上一个驱动对象的 _LDR_DATA_TABLE_ENTRY

因此,我们只要遍历这个 InLoadOrderLinks ,就能获取系统中所有驱动的模块信息。

接下来就是特征码的搜索匹配,在上图中可以看到,前一部分压栈操作不能作为特征码,所以可以选取中间部分,知道后再减去相应偏移。

遍历代码如下:

image-20220712103431244

结束进程代码:

image-20220712103453477

特征码查找:

image-20220712103646640

上图中最后减去5是特征匹配字段与函数开头的距离(示例代码是在x86位下)

1.将程序编译成sys文件后安装驱动,即可关闭进程;

2.可以利用ring3常规方式传输数据到ring0的方式结束进程(其实就是多写一个客户端程序与驱动通信)

image-20220712114148635

请注意第一个红框,这是老写法,查阅资料如下

image-20220712114301237

总结

总结一下,这篇进程强杀,主要用到了PspTerminateProcess函数,该函数并未导出,采取的是DRIVER_OBJECTDriver Section成员,它所指向的是一个名叫 _LDR_DATA_TABLE_ENTRY 的结构体,该结构体大家都比较熟悉,通过遍历其中的 InLoadOrderLinks 加上匹配特征码的方式就能得到函数地址。

Ring0下的进程保护

SSDT表

主要用到SSDT HOOK,SSDT表并不仅仅只包含一个庞大的地址索引表,它还包含着一些其它有用的信息,诸如地址索引的基地址、服务函数个数等。通过修改此表的函数地址可以对常用 Windows 函数及 API 进行 Hook,从而实现对一些关心的系统动作进行过滤、监控的目的。一些 HIPS、防毒软件、系统监控、注册表监控软件往往会采用此接口来实现自己的监控模块。

因为x64位中ssdt表是加密的,ssdt中的每一项占4个字节但并不是对应的系统服务的地址,因为x64中地址为64位,而ssdt每一项只有4个字节所以无法直接存放服务的地址。其实际存储的4个字节的前28位表示的是对应的系统服务相对于SSDT表基地址的偏移,而后4位如果对应的服务的参数个数小于4则其值为0,不小于4则为参数个数减去4。

所以我们在ssdt hook时向ssdt表项中填入的函数得在ntoskrnl.exe模块中,原因是因为函数到SSDT表基地址的偏移大小小于4个字节。所以我们需要选取一个ntoskrnl.exe中很少使用的函数KeBugCheckEx作为中转函数,将需要hook的ssdt项的改为KeBugCheckEx函数,然后在Inlinehook KeBugCheck函数,jmp到我们的函数中进行过滤。

KeServiceDescriptorTableKeServiceDescriptorTableShadow,其中 KeServiceDescriptorTable 主要是处理来自 Ring3 层 Kernel32.dll 中的系统调用,而 KeServiceDescriptorTableShadow 则主要处理来自 User32.dllGDI32.dll 中的系统调用,并且KeServiceDescriptorTablentoskrnl.exe(Windows 操作系统内核文件,包括内核和执行体层)是导出的,而 KeServiceDescriptorTableShadow 则是没有被 Windows 操作系统所导出。

通过SSDT表中OpenProcess函数的索引(系统调用号)来找到该函数。或者利用Terminateprocess函数也是可以的。

image-20220712162202878

CR0寄存器

修改内存页属性,SSDT表所在的内存页属性是只读,没有写入的权限,所以需要把该地址设置为可写入,这样才能写入自己的函数,使用的是CR0寄存器关闭只读属性。

获取SSDT表基址

在x86下,可以通过代码的方式直接读取 KeServiceDescriptorTable 这个被导出的表结构从而可以直接读取到SSDT表的基址,而在Win64系统中 KeServiceDescriptorTable 这个表并没有被导出,所以我们必须手动搜索到它的地址。

1.这里我们可以通过MSR(特别模块寄存器),读取C0000082寄存器,从而得到KiSystemCall64的地址,在内核调试模式下直接输入 rdmsr c0000082 即可读取到该地址,反汇编可看到 nt!KiSystemCall64Shadow 函数。(Win10中如果开启了KVA Shadow那么这个地址是nt!KiSystemCall64Shadow,否则是nt!KiSystemCall64。

image-20220712161035727

KiSystemCall64Shadow函数的最后,有一个jmp指令,会跳转到nt!KiSystemServiceUser函数。

image-20220712161456407

继续跟进KiSystemServiceUser函数,可以看到对nt!KeServiceDescriptorTable的读取。KeServiceDescriptorTable就是SSDT表。

image-20220712161541476

总结一下,获取SSDT基址流程:

MSR寄存器(0xC0000082)-> nt!KiSystemCall64Shadow ->nt!KiSystemServiceUser -> nt!KeServiceDescriptorTable。具体查找也是通过字节码匹配。

具体实现

下面是InlineHook KeBugCheckEx()的部分

image-20220712163341384

关键匹配部分

image-20220712164037294

获取函数部分

image-20220712164429035

总结

这篇主题是进程保护,实际上主要知识点是关于SSDT_HOOK,需要注意的是x86和x64下操作方式不同,现在网上很多的x64下的也是基于win7的,并且x64下需要绕过PG。

参考链接:x64下的进程保护x64下的SSDTHOOK

Ring3下API的逆向分析及重构

首先分析ReadProcessMemory函数整个调用过程,我们知道函数虽然是Kernel32.dll中的函数,但是只不过是封装好的函数,最终调用还是会到ntdll.dll中去。

我们先随便写个程序,然后生成64位exe动态分析。

image-20220712192755554

ReadProcessMemory实际上会调用红框中的函数

image-20220712192744224

NtReadVirtualMemory函数如下图,test执行与操作,结果为0,ZF被置位,所以不执行下面的跳转(注意与cmp指令的差别

image-20220712193240271

image-20220712193728459

这个地方首先是将内核函数的编号给了eax,然后test命令比较判断是采取中断的方式还是syscall的方式进入0环(以前x86的方式是调用KiFastSystemCall函数,也就是0x7ffe0300这个地址,该函数决定了采取哪种方式进入0环)。

image-20220712194929243

_KUSER_SHARED_DATA

前面说到了0x7ffe0308这个地址,如下图,其实就是shareddata+0x308

image-20220712195644152

image-20220712195706609

在 User 层和 Kernel 层分别定义了一个 _KUSER_SHARED_DATA结构区域,用于 User 层和 Kernel 层共享某些数据,它们使用固定的地址值映射,_KUSER_SHARED_DATA 结构区域在 User 和 Kernel 层地址分别为:

User 层地址为:0x7ffe0000

Kernel 层地址为:0xffdf0000

虽然指向的是同一个物理页,但在ring3层是只读的,在ring0层是可写的,在0x308偏移处SystemCall存放的地址就是真正进入ring0的实现方法。

总结

这篇API逆向,主要涉及到了3环进入0环的详细结构,关于重构其实网上示例代码是有关call KiFastSystemCall的,但是x64并没有调用这个函数,而是直接进行了判断然后选取以哪种方式进入0环。

基于PEB断链实现隐藏

模块隐藏

前面提到的关于DRIVER_OBJECT结构中的Driver Section成员,实际上是一个结构体

image-20220714113338981

偏移处有一个DllBase,这里存放的就是dll的地址,所以这里我们如果要想隐藏某个指定的dll,就可以通过DllBase的方式,通过GetModuleHandleA获取dll的句柄,来进行比对。

前面三个双向链表结构都是一样的,我们以InloadOrderModuleList来进行断链示范(其他两个一样的操作),要实现断链,最简单的做法就是让Head的Flink和Blink指向它自己。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PPEB_LDR_DATA ldr;  
PLDR_DATA_TABLE_ENTRY ldte;
PLIST_ENTRY Head, Cur;
Head = &(ldr->InLoadOrderModuleList);
Cur = Head->Flink;

do
{

ldte = CONTAINING_RECORD( Cur, LDR_DATA_TABLE_ENTRY, InLoadOrderModuleList);
if (ldte->BaseAddress == hModule)
{

ldte->InLoadOrderModuleList.Blink->Flink = ldte->InLoadOrderModuleList.Flink;
ldte->InLoadOrderModuleList.Flink->Blink = ldte->InLoadOrderModuleList.Blink;
}
Cur = Cur->Flink;
} while(Head != Cur);

进程隐藏

在windows系统中,所有的活动进程都是连在一起的,构成一个双链表,表头是全局变量PsActiveProcessHead,当一个进程被创建时,其ActiveProcessList域将被作为节点加入到此链表中;当进程被删除时,则从此链表中移除,如果windows需要枚举所有的进程,直接操纵此链表即可。

image-20220714150402209

前八个字节指向的是下一个EPROCESS的偏移0x448处

image-20220714151607623

如下图,可以看到进程名为system

image-20220714151816955

image-20220714151851947

我们继续看下一个,可以看到进程名Registry

image-20220714152047989

image-20220714152033134

实现

在驱动中通过 PsGetCurrentProcess(),来获取当前进程的EPROCESS结构体,然后通过链表遍历其余的EPROCESS,可以通过比较ImageFileName字段来筛选自己想要隐藏的进程。然后对其进行断链操作。

1
2
3
4
5
6
7
curNode = (PLIST_ENTRY)((ULONG)pCurProcess + 0x448);
nextNode = curNode->Flink;
preNode = curNode->Blink;

preNode->Flink = curNode->Flink;

nextNode->Blink = curNode->Blink;

总结

这样的隐藏技术主要是基于关键结构体的成员,来进行摸链操作,可以详细复习一下EPROCESS、ETHREAD等关键数据结构

全局句柄表发现隐藏进程

前面说了0环进行PEB断链可以达到隐藏进程的效果,但是这只是作为权限维持的一种方法。所以这篇讲了基于全局句柄表PsdCidTable,来找到隐藏进程的效果。

在EPROCESS中有一个成员ObjectTable

image-20220714163125878

TableCode就是句柄表地址

image-20220714163148316

句柄表结构如下,句柄表以分级的方式存储,最多三级

image-20220714173229625

句柄共八个字节,64位

  • (bit48~bit63)这一块共计两个字节,16位;高位字节是给SetHandleInformation这个函数用的,例如当执行如下语句:
1
SetHandleInformation(Handle,HANDLE_FLAG_PROTECT_FROM_CLOSE,HANDLE_FLAG_PROTECT_FROM_CLOSE);

这个位置将会被写入0x02

  • (bit32~bit47)这一块也是两个字节,16位;这块是访问掩码,是给OpenProcess这个函数用的,即OpenProcess的第一个参数 dwDesiredAccess的值
  • (bit0~bit31)这两块共计四个字节,32位,各个位主要含义如下:
    • bit0:OBJ_PROTECT_CLOSE,表示调用者是否允许关闭该句柄;默认值为1
    • bit1:OBJ_INHERIT,指示该进程创建的子进程是否可以继承该句柄,即是否将该句柄项拷贝到它们的 句柄表中
    • bit2:OBJ_AUDIT_OBJECT_CLOSE,指示关闭该对象时是否产生一个审计事件;默认值为0
    • bit3~31:存放该内核对象在内核中的具体地址

句柄表中存放的是指针,32位就是32位指针

x64 算法:
进程ID(句柄索引) / 4 = 表索引
表索引 * 16 = 全局句柄表条目

x86算法:

进程ID / 4 = 表索引
表索引 * 8 = 全局句柄表条目

为什么 * 8 (16)因为每一项占8(16)字节,前4(8)字节为头,后4(8)字节为地址

_OBJECT_HEADER

当一个进程创建或者打开一个内核对象时,将获得一个句柄,通过这个句柄可以访问对应的内核对象。上面知道可以从句柄低32位获取到内核对象的地址,内核对象在开头都有一个0x30字节的_OBJECT_HEADER结构,这是内核对象的头部,也就是说从0x30字节开始, 才是进程结构体开始的位置。

image-20220714165444894

全局句柄表

全局变量 PspCidTable 存储了全局句柄表 _HANDLE_TABLE 的地址,全局句柄表存储了所有 EPROCESS ETHREAD ,全局句柄表项低32位指向的就是内核对象,而非 OBJECT_HEADER

image-20220714193904141

TableCode最后一位为1,代表句柄表中有两层,当最后一位为0时,代表直接指向句柄表,当最后一位为1时代表指向一个数组,数组中的值才指向的是句柄,这个数组可以存放句柄表的指针,为2时就变成了一个三维数组

image-20220714194647467

X86下_HANDLE_TABLE_ENTRY结构大小为8,x64下结构大小为16,每张表4k大小4k/16=256,所以一张表可以存256个handle。二级三级表存放指针,x64下一个指针地址占用8字节,x86下占用4字节。4k/8=512

1
2
3
4
5
6
7
/*为0时*/

Handle Handle_table[256] = {};

/*为1时*/

Handle_table Handle_table[512][0] = Handle_table;

根据上面的分析我们知道,当前句柄表存储的是512个句柄表数组(大小为0x8)的数组指针,每个句柄表数组有256个成员,每个成员大小为0x10大小, 这里我们查询的目标明显在第一张句柄表中,我们查看目标内存。

image-20220714201137389

image-20220714201250273

这里我们查看PID为4的进程对象,先4/4 = 1(0x01)计算地址公式为:

1
ffffad09`de6b0000 + 0x01 * 16 = ffffad09`de6b0010

**(note:前面提到过PspCidTable表中其表项低32位指向目标的Object,内核句柄表中存储的是Object_Header。)**所以:

1
(c40ae409`80401dc5 >> 0x10) & 0xFFFFFFFFFFFFFFF0 == ffffc40ae4098040

image-20220714203006143

image-20220714203021436

我们成功在全局句柄表中查了目标进程对象,这里我们可以看其对应的Object_Header。圈起来的地方就是64与32的区别,64位Object_Header中已经没有Object_Type结构体指针,取而代之的是TypeIndex

image-20220714203256351

其是一个XOR加密的数据,我们可以逆向解出其数据,公式如下:

1
0x2 ^ 0x80(上图中OBJECT_HEADER地址的倒数第二字节) ^ ObHeaderCookie

其中 ObHeaderCookie 为一个BYTE字节的全局变量,我么可以通过WinDbg查看

image-20220714203628443

计算结果如下:

1
0x2 ^ 0x80 ^ ObHeaderCookie(0x85) == 0x7

这里的0x7对应的是Object_Type中的Index字段(外提一句线程是0x8),我们查看PsProcessType(其为Object_Type结构)

image-20220714203852783

第二种方法:

TypeIndex就在说明内核对象的类型,说明放在了ObTypeIndexTable中,将ObTypeIndexTable首地址:fffff807670fce80,取出来然后+0x2* 8(x86为0x2 * 4):

image-20220714204313626

image-20220714204329658

然后使用_OBJECT_TYPE查看是什么类型(也不知道为啥我这里是Type,大家也可以试试hhh):

image-20220714204746773

第三种方法:既然Object_Header中去除了Object_Type, 那么我们如何快速获取Object对应的Object_Type呢,这里我们可以利用内核导出函数ObGetObjectType,来获取对象对应的OBJECT_TYPE, 函数原型如下:

1
2
// 返回_OBJECT_TYPE指针
NTKERNELAPI PVOID NTAPI ObGetObjectType(IN PVOID pObject/*目标对象结构体指针*/);

可以看一下IDA:

image-20220714205128464

如上图可以看到其是在ObTypeIndexTable中获取对应的OBJECT_TYPE指针, 可以知道其是个OBJECT_TYPE指针数组, 获取公式:

1
ObTypeIndexTable[OBJECT_HEADER.TypeIndex ^ BYTE((OBJECT_HEADER地址 >> 8)) ^ ObHeaderCookie]

实现

首先需要解决的问题是,获取PspCidTable

win7:

1
2
PsLookupProcessByProcessId(被导出) -> PspCidTable
1

win10:

1
PsLookupProcessByProcessId(被导出) -> PspReferenceCidTableEntry -> PspCidTable

image-20220714210434972

image-20220714210523795

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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
// 获取 PspCidTable
BOOLEAN get_PspCidTable(ULONG64* tableAddr) {

// 获取 PsLookupProcessByProcessId 地址
UNICODE_STRING uc_funcName;
RtlInitUnicodeString(&uc_funcName, L"PsLookupProcessByProcessId");
ULONG64 ul_funcAddr = MmGetSystemRoutineAddress(&uc_funcName);
if (ul_funcAddr == NULL) {
//DbgPrint("[LYSM] MmGetSystemRoutineAddress error.\n");
return FALSE;
}
//DbgPrint("[LYSM] PsLookupProcessByProcessId:%p\n", ul_funcAddr);

// 前 40 字节有 call(PspReferenceCidTableEntry)
ULONG64 ul_entry = 0;
for (INT i = 0; i < 40; i++) {
if (*(PUCHAR)(ul_funcAddr + i) == 0xe8) {
ul_entry = ul_funcAddr + i;
break;
}
}
if (ul_entry != 0) {
// 解析 call 地址
INT i_callCode = *(INT*)(ul_entry + 1);
//DbgPrint("[LYSM] i_callCode:%X\n", i_callCode);
ULONG64 ul_callJmp = ul_entry + i_callCode + 5;
//DbgPrint("[LYSM] ul_callJmp:%p\n", ul_callJmp);
// 来到 call(PspReferenceCidTableEntry) 内找 PspCidTable
for (INT i = 0; i < 20; i++) {
if (*(PUCHAR)(ul_callJmp + i) == 0x48 &&
*(PUCHAR)(ul_callJmp + i + 1) == 0x8b &&
*(PUCHAR)(ul_callJmp + i + 2) == 0x05) {
// 解析 mov 地址
INT i_movCode = *(INT*)(ul_callJmp+i + 3);
//DbgPrint("[LYSM] i_movCode:%X\n", i_movCode);
ULONG64 ul_movJmp = ul_callJmp+i + i_movCode + 7;
//DbgPrint("[LYSM] ul_movJmp:%p\n", ul_movJmp);
// 得到 PspCidTable
*tableAddr = ul_movJmp;
return TRUE;
}
}
}

// 前 40字节没有 call
else {
// 直接在 PsLookupProcessByProcessId 找 PspCidTable
for (INT i = 0; i < 70; i++) {
if(*(PUCHAR)(ul_funcAddr + i) == 0x49 &&
*(PUCHAR)(ul_funcAddr + i + 1) == 0x8b &&
*(PUCHAR)(ul_funcAddr + i + 2) == 0xdc &&
*(PUCHAR)(ul_funcAddr + i + 3) == 0x48 &&
*(PUCHAR)(ul_funcAddr + i + 4) == 0x8b &&
*(PUCHAR)(ul_funcAddr + i + 5) == 0xd1 &&
*(PUCHAR)(ul_funcAddr + i + 6) == 0x48 &&
*(PUCHAR)(ul_funcAddr + i + 7) == 0x8b){
// 解析 mov 地址
INT i_movCode = *(INT*)(ul_funcAddr+i+6 + 3);
//DbgPrint("[LYSM] i_movCode:%X\n", i_movCode);
ULONG64 ul_movJmp = ul_funcAddr+i+6 + i_movCode + 7;
//DbgPrint("[LYSM] ul_movJmp:%p\n", ul_movJmp);
// 得到 PspCidTable
*tableAddr = ul_movJmp;
return TRUE;
}
}
}

return FALSE;
}

/* 解析一级表
BaseAddr:一级表的基地址
index1:第几个一级表
index2:第几个二级表
*/
VOID parse_table_1(ULONG64 BaseAddr,INT index1,INT index2) {

//DbgPrint("[LYSM] BaseAddr 1:%p\n", BaseAddr);

// 获取系统版本
RTL_OSVERSIONINFOEXW OSVersion = { 0 };
OSVersion.dwOSVersionInfoSize = sizeof(RTL_OSVERSIONINFOEXW);
RtlGetVersion((PRTL_OSVERSIONINFOW)&OSVersion);

// 遍历一级表(每个表项大小 16 ),表大小 4k,所以遍历 4096/16 = 526 次
PEPROCESS p_eprocess = NULL;
PETHREAD p_ethread = NULL;
INT i_id = 0;
for (INT i = 0; i < 256; i++) {
if (!MmIsAddressValid((PVOID64)(BaseAddr + i * 16))) {
//DbgPrint("[LYSM] 非法地址:%p\n", BaseAddr + i * 16);
continue;
}
// win10
if (OSVersion.dwMajorVersion == 10 && OSVersion.dwMinorVersion == 0) {
ULONG64 ul_recode = *(PULONG64)(BaseAddr + i * 16);
// 解密
ULONG64 ul_decode = (LONG64)ul_recode >> 0x10;
ul_decode &= 0xfffffffffffffff0;
// 判断是进程还是线程
i_id = i*4 + 1024*index1 + 512*index2*1024;
if (PsLookupProcessByProcessId(i_id , &p_eprocess) == STATUS_SUCCESS) {
DbgPrint("[LYSM] PID:%d , i:%d , addr:%p , object:%p\n", i_id , i, BaseAddr + i*0x10, ul_decode);
}
else if (PsLookupThreadByThreadId(i_id , &p_ethread) == STATUS_SUCCESS) {
DbgPrint("[LYSM] TID:%d , i:%d , addr:%p , object:%p\n", i_id , i, BaseAddr + i*0x10, ul_decode);
}

}
// win7
if (OSVersion.dwMajorVersion == 6 && OSVersion.dwMinorVersion == 1) {
ULONG64 ul_recode = *(PULONG64)(BaseAddr + i * 16);
// 解密
ULONG64 ul_decode = ul_recode & 0xfffffffffffffff0;
// 判断是进程还是线程
i_id = i*4 + 1024*index1 + 512*index2*1024;
if (PsLookupProcessByProcessId(i_id , &p_eprocess) == STATUS_SUCCESS) {
DbgPrint("[LYSM] PID:%d , i:%d , addr:%p , object:%p\n", i_id , i, BaseAddr + i*0x10, ul_decode);
}
else if (PsLookupThreadByThreadId(i_id , &p_ethread) == STATUS_SUCCESS) {
DbgPrint("[LYSM] TID:%d , i:%d , addr:%p , object:%p\n", i_id , i, BaseAddr + i*0x10, ul_decode);
}
else { continue; }
}
}
}

/* 解析二级表
BaseAddr:二级表基地址
index2:第几个二级表
*/
VOID parse_table_2(ULONG64 BaseAddr, INT index2) {

//DbgPrint("[LYSM] BaseAddr 2:%p\n", BaseAddr);

// 遍历二级表(每个表项大小 8),表大小 4k,所以遍历 4096/8 = 512 次
ULONG64 ul_baseAddr_1 = 0;
for (INT i = 0; i < 512; i++) {
if (!MmIsAddressValid((PVOID64)(BaseAddr + i * 8))) {
//DbgPrint("[LYSM] 非法二级表指针(1):%p\n", BaseAddr + i * 8);
continue;
}
if (!MmIsAddressValid((PVOID64)*(PULONG64)(BaseAddr + i * 8))) {
//DbgPrint("[LYSM] 非法二级表指针(2):%p\n", BaseAddr + i * 8);
continue;
}
ul_baseAddr_1 = *(PULONG64)(BaseAddr + i * 8);
parse_table_1(ul_baseAddr_1, i, index2);
}
}

/* 解析三级表
BaseAddr:三级表基地址
*/
VOID parse_table_3(ULONG64 BaseAddr) {

//DbgPrint("[LYSM] BaseAddr 3:%p\n", BaseAddr);

// 遍历三级表(每个表项大小 8),表大小 4k,所以遍历 4096/8 = 512 次
ULONG64 ul_baseAddr_2 = 0;
for (INT i = 0; i < 512; i++) {
if (!MmIsAddressValid((PVOID64)(BaseAddr + i * 8))) { continue; }
if (!MmIsAddressValid((PVOID64) * (PULONG64)(BaseAddr + i * 8))) { continue; }
ul_baseAddr_2 = *(PULONG64)(BaseAddr + i * 8);
parse_table_2(ul_baseAddr_2, i);
}
}

/* 遍历进程和线程
cidTableAddr:PspCidTable 地址
*/
BOOLEAN enum_PspCidTable(ULONG64 cidTableAddr) {

// 获取 _HANDLE_TABLE 的 TableCode
ULONG64 ul_tableCode = *(PULONG64)(((ULONG64)*(PULONG64)cidTableAddr) + 8);
//DbgPrint("[LYSM] ul_tableCode:%p\n", ul_tableCode);

// 取低 2位(二级制11 = 3)
INT i_low2 = ul_tableCode & 3;
//DbgPrint("[LYSM] i_low2:%X\n", i_low2);

// 一级表
if (i_low2 == 0) {
// TableCode 低 2位抹零(二级制11 = 3)
parse_table_1(ul_tableCode & (~3),0,0);
}
// 二级表
else if (i_low2 == 1) {
// TableCode 低 2位抹零(二级制11 = 3)
parse_table_2(ul_tableCode & (~3),0);
}
// 三级表
else if (i_low2 == 2) {
// TableCode 低 2位抹零(二级制11 = 3)
parse_table_3(ul_tableCode & (~3));
}
else {
DbgPrint("[LYSM] i_low2 非法!\n");
return FALSE;
}

return TRUE;
}

tips

除了进程遍历外,还可以用于反调试。一个进程加载进内存后,可以起一个线程,专门去遍历其他所有进程的句柄表,如果发现,某个进程的句柄表中有自己进程的句柄,说明自己的这个进程可能正在被调试,就算没有在被调试,也至少被打开了,这时就可以强行关闭自己的程序,不被调试,达到反调试的目的。

总结

这一篇知识点比较多,其实主要还是围绕进程句柄表和全局句柄表,现在大多基于64位进行开发,但是网上很多资料都是32位的,参考性较小,所以这里参考了几篇博客进行了总结归纳。

首先从句柄表入手,X86下_HANDLE_TABLE_ENTRY结构大小为8,x64下结构大小为16,每张表4k大小4k/16=256,所以一张表可以存256个handle。二级三级表存放指针,x64下一个指针地址占用8字节,x86下占用4字节。4k/8=512。

EPROCESS偏移0x570处有一个成员ObjectTable,查看该结构类型可以看到偏移0x8处是TableCode也就是句柄表地址,该地址后三位是有关句柄表是几级表的,该地址指向的是OBJECT_HEADER,该结构相当于链表的头部,需要加上0x30的偏移才能定位到真正的链表。

全局变量 PspCidTable 存储了全局句柄表 _HANDLE_TABLE 的地址,全局句柄表存储了所有 EPROCESS ETHREAD ,全局句柄表项低32位指向的就是内核对象,而非 OBJECT_HEADER

关于全局句柄表的ObjectHeader有一个TypeIndex字段,采用了XOR加密,可以采用公式解密,也可以利用导出函数ObGetObjectType,(0x7为进程,0x8为线程)

参考链接:x64下的全局句柄表解析x64遍历枚举解析全局句柄表

x64下隐藏可执行内存

前面讲了PEB断链实现隐藏进程的效果,但是只是表面的进程隐藏,所有内存的详细信息都会被存储在vad树里面。

vad

内存管理器使用需求分页算法来知道何时将页面加载到内存中,直到线程引用地址并导致页面故障,然后再从磁盘中检索页面。与写时复制一样,需求分页是一种惰性评估的形式(等待直到需要时才执行任务。)

内存管理器使用惰性计算不仅可以将页面带入内存,还可以构造描述新页面所需的页面表。例如,当一个线程使用VirtualAlloc提交一个大区域的虚拟内存时,内存管理器可以立即构造访问已分配的整个内存范围所需的页面表。但如果其中一些范围从未被访问过呢?为整个范围创建页面表将是一个浪费时间的工作。相反,内存管理器等待创建一个页面表,直到线程导致页面故障。然后,它将为该页面创建一个页面表。这种方法显著提高了保留以及提交大量内存但访问量稀疏的进程的性能。

使用惰性计算算法,即使是很大的内存块也是一个快速的操作。当一个线程分配内存时,内存管理器必须响应该线程要使用的地址范围。为此,内存管理器维护另一组数据结构,以跟踪哪些虚拟地址已保留在进程的地址空间中,而哪些没有保留。这些数据结构被称为虚拟地址描述符(VADs)。VADs被分配在非分页池中。

VAD(虚拟地址描述符)是管理虚拟内存的,每一个进程有自己单独的一个VAD树,使用VirtualAlloc申请一个内存,则会在VAD树上增加一个结点,其是_MMVAD结构体

image-20220718153145153

StartingVpn和这个EndingVpn是以页为单位

image-20220718163930794

image-20220718164517748

EPROCESS结构中偏移0x7d8处有一个VadRoot成员,其是根结点

image-20220718161129090

VADs被组织成一个自平衡的AVL tree(以其发明者阿德尔森-维尔斯基和兰迪斯的名字命名,其中任何节点的两个子子树的高度最多相差1;这使得插入、查找和删除非常快)。

image-20220718164732040

通过下面可以看到VadRoot,采用指令!vad 红框中地址就可以查看到该进程的vad树

image-20220718161413070

image-20220718161736598

由以上可知,可以通过_EPROCESS.VadRoot遍历VAD二叉树。进程内的所有用户模式线程使用Thread Control Stack上的不同内存区域(Shadow Stack),可以通过遍历进程的VAD自平衡二叉树(self-balancing AVL tree)获取描述进程Thread Control Stack的_MMVAD结构

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
typedef struct _MMVAD {
/* 0x0000 */ struct _MMVAD_SHORT Core;
union {
union {
/* 0x0040 */ unsigned long LongFlags2;
/* 0x0040 */ struct _MMVAD_FLAGS2 VadFlags2;
}; /* size: 0x0004 */
} /* size: 0x0004 */ u2;
/* 0x0044 */ long Padding_;
/* 0x0048 */ struct _SUBSECTION* Subsection;
/* 0x0050 */ struct _MMPTE* FirstPrototypePte;
/* 0x0058 */ struct _MMPTE* LastContiguousPte;
/* 0x0060 */ struct _LIST_ENTRY ViewLinks;
/* 0x0070 */ struct _EPROCESS* VadsProcess;
union {
union {
/* 0x0078 */ struct _MI_VAD_SEQUENTIAL_INFO SequentialVa;
/* 0x0078 */ struct _MMEXTEND_INFO* ExtendedInfo;
}; /* size: 0x0008 */
} /* size: 0x0008 */ u4;
/* 0x0080 */ struct _FILE_OBJECT* FileObject;
} MMVAD, *PMMVAD; /* size: 0x0088 */

typedef struct _MMVAD_SHORT {
union {
/* 0x0000 */ struct _RTL_BALANCED_NODE VadNode;
/* 0x0000 */ struct _MMVAD_SHORT* NextVad;
}; /* size: 0x0018 */
/* 0x0018 */ unsigned long StartingVpn;
/* 0x001c */ unsigned long EndingVpn;
/* 0x0020 */ unsigned char StartingVpnHigh;
/* 0x0021 */ unsigned char EndingVpnHigh;
/* 0x0022 */ unsigned char CommitChargeHigh;
/* 0x0023 */ unsigned char SpareNT64VadUChar;
/* 0x0024 */ long ReferenceCount;
/* 0x0028 */ struct _EX_PUSH_LOCK PushLock;
union {
union {
/* 0x0030 */ unsigned long LongFlags;
/* 0x0030 */ struct _MMVAD_FLAGS VadFlags;
}; /* size: 0x0004 */
} /* size: 0x0004 */ u;
union {
union {
/* 0x0034 */ unsigned long LongFlags1;
/* 0x0034 */ struct _MMVAD_FLAGS1 VadFlags1;
}; /* size: 0x0004 */
} /* size: 0x0004 */ u1;
/* 0x0038 */ struct _MI_VAD_EVENT_BLOCK* EventList;
} MMVAD_SHORT, *PMMVAD_SHORT; /* size: 0x0040 */

typedef struct _RTL_BALANCED_NODE {
union {
/* 0x0000 */ struct _RTL_BALANCED_NODE* Children[2];
struct {
/* 0x0000 */ struct _RTL_BALANCED_NODE* Left;
/* 0x0008 */ struct _RTL_BALANCED_NODE* Right;
}; /* size: 0x0010 */
}; /* size: 0x0010 */
union {
/* 0x0010 */ unsigned char Red : 1; /* bit position: 0 */
/* 0x0010 */ unsigned char Balance : 2; /* bit position: 0 */
/* 0x0010 */ unsigned __int64 ParentValue;
}; /* size: 0x0008 */
} RTL_BALANCED_NODE, *PRTL_BALANCED_NODE; /* size: 0x0018 */

typedef struct _RTL_AVL_TREE {
/* 0x0000 */ struct _RTL_BALANCED_NODE* Root;
} RTL_AVL_TREE, *PRTL_AVL_TREE; /* size: 0x0008 */

typedef struct _EPROCESS {

struct _RTL_AVL_TREE VadRoot;

}
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
typedef struct _MMVAD_FLAGS {
struct /* bitfield */ {
/* 0x0000 */ unsigned long VadType : 3; /* bit position: 0 */
/* 0x0000 */ unsigned long Protection : 5; /* bit position: 3 */
/* 0x0000 */ unsigned long PreferredNode : 6; /* bit position: 8 */
/* 0x0000 */ unsigned long NoChange : 1; /* bit position: 14 */
/* 0x0000 */ unsigned long PrivateMemory : 1; /* bit position: 15 */
/* 0x0000 */ unsigned long PrivateFixup : 1; /* bit position: 16 */
/* 0x0000 */ unsigned long ManySubsections : 1; /* bit position: 17 */
/* 0x0000 */ unsigned long Enclave : 1; /* bit position: 18 */
/* 0x0000 */ unsigned long DeleteInProgress : 1; /* bit position: 19 */
/* 0x0000 */ unsigned long PageSize64K : 1; /* bit position: 20 */
/* 0x0000 */ unsigned long RfgControlStack : 1; /* bit position: 21 */
/* 0x0000 */ unsigned long Spare : 10; /* bit position: 22 */
}; /* bitfield */
} MMVAD_FLAGS, *PMMVAD_FLAGS; /* size: 0x0004 */

typedef struct _MI_VAD_EVENT_BLOCK {
/* 0x0000 */ struct _MI_VAD_EVENT_BLOCK* Next;
union {
/* 0x0008 */ struct _KGATE Gate;
/* 0x0008 */ struct _MMADDRESS_LIST SecureInfo;
/* 0x0008 */ struct _RTL_BITMAP_EX BitMap;
/* 0x0008 */ struct _MMINPAGE_SUPPORT* InPageSupport;
/* 0x0008 */ struct _MI_LARGEPAGE_IMAGE_INFO LargePage;
/* 0x0008 */ struct _ETHREAD* CreatingThread;
/* 0x0008 */ struct _MI_SUB64K_FREE_RANGES PebTebRfg;
/* 0x0008 */ struct _MI_RFG_PROTECTED_STACK RfgProtectedStack;
}; /* size: 0x0038 */
/* 0x0040 */ unsigned long WaitReason;
/* 0x0044 */ long __PADDING__[1];
} MI_VAD_EVENT_BLOCK, *PMI_VAD_EVENT_BLOCK; /* size: 0x0048 */

typedef struct _MI_RFG_PROTECTED_STACK {
/* 0x0000 */ void* ControlStackBase;
/* 0x0008 */ struct _MMVAD_SHORT* ControlStackVad;
} MI_RFG_PROTECTED_STACK, *PMI_RFG_PROTECTED_STACK; /* size: 0x0010 */

虚拟内存分为两类:

(1)通过VirtualAlloc/VirtualAllocEx 申请的:Private Memory ,独享物理页

(2)通过CreateFlieMapping映射的:Mapped Memory,多个进程共享物理页。

分页机制

在32位里面有2-9-9-1210-10-12两种分页模式,而在64位下只有一种分页模式,即9-9-9-9-12分页模式。在64位系统中,实际上CPU只使用了其中的48位用于寻址。

9-9-9-9-12分页表示物理地址拥有四级页表,在Intel开发手册中,将这四级页表分别称为PML4EPDPTEPDEPTE,但微软的命名方式略有不同,将这四级页表分别称为PXEPPEPDEPTE,WinDbg中也是如此

启用分页模式条件:cr0.PG = 1cr0.PE = 1

根据不同CPU架构及特性主要分为三种模式,处于哪种模式视寄存器属性不同:

  • 32-bit paging(32位OS): cr0.PG = 1cr4.PAE = 0
  • PAE paging(32位OS且开启了PAE): cr0.PG = 1cr4.PAE = 1IA32_EFER.LME = 0
  • IA-32e paging(64位OS): cr0.PG = 1cr4.PAE = 1IA32_EFER.LME = 1

主要研究的是IA-32e模式下的内存,这里IA-32e提供了三种页转换模型:

  • 4k:PML4T,PDPT,PDT和PT
  • 2M:PML4T,PDPT和PDT
  • 1G:PML4T和PDPT

在4kb小页的情况下,64位可以拆分为以下几段,即9-9-9-9-9-12分页

sign extended – 符号扩展位 — 在线性地址48~63bit

PML4 entry – 在线性地址39~47bit用于索引PML4 entry,指向PDP

PDP entry – 在线性地址的30~38bit用来索引PDP entry,指向PDE

PDE entry – 在线性地址的21~29bit用来索引PDEentry,指向PTE

PTE entry – 在线性地址的12~20bit用来索引PTE entry,指向page offset

page offse t – 在线性地址的0~11bit提供在页中的offset

页表基址

CR3中保存的页表基址是物理地址,程序如果直接访问这个地址,实际上访问的是一个线性地址,会被虚拟内存管理器解析成另一个地址.实际上,操作系统会将当前进程的物理页映射在某个线性地址中,以供程序读取自己的页表内容。

在x86系统中,页表基址是固定的,位于0xC0000000,将这个线性地址进行解析,访问其物理页的内容,会发现从这个地址开始,里面保存的数据为当前程序的所有物理页地址。

而在x64系统中,页表基址不再是固定的值,而是每次系统启动后随机生成的可以在WinDbg中查看0地址对应的线性地址来确定当前的页表基址(!pte 0)。每个物理页占8个字节,例如,第一个物理页地址位于线性地址0xFFFFF38000000000,第二个物理页地址位于线性地址0xFFFF800000000008,每个物理页中包含1024个字节的数据。

MmIsAddressValid

探索系统的分页机制最好的办法是对内核中的 MiIsAddressValid 函数的实现进行分析。win7和win10的64位系统下使用的是两种不同的查找 ptepdepdptepml4e 的方式。接下来分别对两个系统的该函数进行相应的分析

win7 64位下的函数分析

image-20220718173242799

首先通过移位,查看前16位是否为全0或者全1,如果不是,返回不合法。然后通过移位分别分出9-9-9-9-12这5部分,然后加上相应的基址,找到对应的 pte,pde,pdpte,pml4e ,并判断P位是否为0。如果为0返回不合法。

该函数较为简单,将减去的数值取反加一可以得到对应的基址。可得在win7 64位下,其 PTE_BASEfffff68000000000

win10 64位下的函数分析

win10 1607 以上版本,微软为分页基址加上了随机页目录基址,这让定位 pte 变得更加困难。 PTE_BASE 不再是之前写死的值,而是每次开机都会随机在一定的范围内挑选一个值。而在 win10 64 位下的 MmIsAddressValidEx 函数中,其更换了另外一套寻找 pte,pde,pdpte,pml4e 的方法,如下

image-20220718173735557

可以看到这里计算 pte,pde,pdpte,pml4e 的时候用的都只有一个 pte_base ,不再像win7那样求每个值的时候都使用一个不同的基址。

实现

在申请一块不可执行的内存后通过修改 ptepde 手动将页面设置为可执行,达到隐藏可执行内存的目的。

首先是定位定位PTEPDEPPEPXEPTE_BASE有两种情况,当是Win10 1607以上,就需要自己通过逆向的方式提取硬编码进行定位,这里通过MmGetVirtualForPhysical函数加偏移的方式进行定位。

image-20220718195402087

image-20220718195002005

image-20220718193319749

然后是一个分配内存的函数,可以往指定pid的进程中写入shellcode,并隐藏其可执行属性,使这块内存在vad树中看来是不可执行的。

我们找到目标进程,然后通过KeStackAttachProcess函数实现进程挂靠,即把自己的cr3换成目标进程的cr3(CR3含有存放页目录表页面的物理地址)。

然后使用ZwAllocateVirtualMemory先分配一块可读可写的内存。

image-20220718195106508

首先将前3位符号位去掉得到内存的起始地址和结束地址,然后循环判断,必须每一块内存都需要修改。结合MmIsAddressValid并判断valid是否为1,这里如果valid为0则该块内存无效,然后将no_execute置0即可获得可执行权限

image-20220718195537373

总结

其实就是通过修改pde和pte属性隐藏可执行内存,在编写这样的驱动的过程中也能巩固对x64分页机制的认识。

参考链接:x64下隐藏可执行内存hide_excute_memory

参考链接:主要参考博客