windows 句柄表

sky123

https://www.vergiliusproject.com/

在 Windows 操作系统中,句柄(Handle) 是一种 用于标识和管理系统对象的抽象引用。它本质上是一个 索引值或小整数,由内核分配给用户态程序,用户通过它间接访问和操作内核对象。

在内核中,句柄是通过句柄表(Handle Table)管理的:

  • 用户得到的是一个 整数句柄值(HANDLE)
  • 该值是句柄表的一个索引,通过 TableCode 分层结构定位到 HANDLE_TABLE_ENTRY
  • 每个表项指向一个真正的 内核对象(如 EPROCESS、ETHREAD 等)

通常我们研究的 Windows 的句柄表包括私有句柄表全局句柄表

  • 私有句柄表 :进程对象的 EPROCESS->ObjectTable 指向私有句柄表,它保存该进程打开的所有对象句柄(进程、线程、同步对象等)。

  • 全局句柄表 :内核的全局变量 PspCidTable 指向全局句柄表,保存系统所有的进程/线程对象句柄

另外 Windows 系统中存在一些特殊句柄(pseudo handles),它们不是从句柄表中分配来的,但可以在大多数 API 中当作常规句柄使用。最常见的包括:

句柄值 含义 等价函数调用
-1((HANDLE)-1) 当前进程句柄(Current Process) GetCurrentProcess()
-2((HANDLE)-2) 当前线程句柄(Current Thread) GetCurrentThread()
-3((HANDLE)-3) 当前进程的调试对象(Current Debug Object) NtQueryInformationProcess 可用
-4((HANDLE)-4) 当前进程的目录句柄(Current Directory Handle) 内部用(部分 API 支持)

句柄表相关结构

EXHANDLE

句柄 HANDLE 的实际结构是 EXHANDLE,该结构定义如下:

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
//
// Ex/Ob 句柄表接口包(定义于 handle.c)
//

//
// Ex/Ob 使用统一的句柄定义格式。
// 虽然句柄在 SDK 中被定义为 PVOID(void 指针),
// 但本模块只使用该指针的低 32 位(即 DWORD)。
//
// 为简化操作,这里重新定义了一个结构体类型:EXHANDLE,
// 用于解构句柄内部的位字段。
//
// EXHANDLE 的最低 2 位(TagBits)保留给用户程序使用,
// 系统在句柄解析时会忽略它们;
// 接下来的 30 位(Index)用于定位句柄表中的表项索引。
//
// ⚠ 注意:这种句柄编码格式是不可更改的,
// 因为一些外部程序已假设句柄具有这种固定格式。
//

typedef struct _EXHANDLE {

union {

struct {

//
// 用户程序可使用的标签位(bit0~1):
// - 这两位对系统无意义,不参与句柄查找;
// - 用户可用作句柄类型区分、调试标记等;
//

ULONG TagBits : 2;

//
// 句柄索引字段(bit2~31):
// - 总共 30 位;
// - 指向句柄表中的 HANDLE_TABLE_ENTRY 项;
// - 实际句柄值 / 4 即为该索引(因为句柄步长为 4);
//

ULONG Index : 30;
};

//
// 原始句柄值(PVOID 强转),对应用户层看到的 HANDLE 值
//

HANDLE GenericHandleOverlay;

#define HANDLE_VALUE_INC 4 // 句柄步长为 4,每个句柄之间间隔 4 字节(与 Index 编码一致)

//
// 句柄的整型表示,可用于位运算处理
//

ULONG_PTR Value;
};

} EXHANDLE, *PEXHANDLE;

可以看到,实际上句柄的低 2 位是保留位(通常置 0),真正有效的是高 30 位,是句柄对应在句柄表中的索引。

提示

我们常说的进程 ID(pid) 实际上也是句柄,由于句柄低 2 位是保留位,因此我们看到的 pid 都是 4 的倍数。

HANDLE_TABLE

Windows 的句柄表的是一个 HANDLE_TABLE 类型的结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 0x3C bytes (sizeof)
struct _HANDLE_TABLE
{
ULONG TableCode; // 0x00: 📌编码后的句柄表入口索引(含类型信息与偏移),用于解析句柄索引定位表项
struct _EPROCESS* QuotaProcess; // 0x04: 所属进程的 EPROCESS 指针,控制句柄创建时的配额限制与资源计费
VOID* UniqueProcessId; // 0x08: 拥有该句柄表的进程 ID(PID),辅助身份绑定与调试分析
struct _EX_PUSH_LOCK HandleLock; // 0x0C: 控制整个句柄表访问的推锁(读写共享),用于多核并发保护
struct _LIST_ENTRY HandleTableList; // 0x10: 📌所有句柄表的全局链表(每个进程的私有句柄表都会挂在此链中),便于枚举与调试
struct _EX_PUSH_LOCK HandleContentionEvent; // 0x18: 用于高并发场景下的争用通知机制,辅助调优与锁等待唤醒
struct _HANDLE_TRACE_DEBUG_INFO* DebugInfo; // 0x1C: 可选的调试结构体指针,启用句柄创建/关闭记录用于泄漏分析
LONG ExtraInfoPages; // 0x20: 分配给句柄附加信息的页数,通常用于对象属性或调试数据扩展
union
{
ULONG Flags; // 0x24: 控制句柄表行为的标志字段(组合位标志)
UCHAR StrictFIFO:1; // bit 0: 是否启用严格的 FIFO 策略管理句柄释放(用于调试和资源回收顺序)
};
ULONG FirstFreeHandle; // 0x28: 当前可用的第一个空闲句柄索引,加速分配时的搜索路径
struct _HANDLE_TABLE_ENTRY* LastFreeHandleEntry; // 0x2C: 指向最后一个空闲句柄项的指针(配合空闲链表实现句柄复用)
ULONG HandleCount; // 0x30: 当前正在使用的句柄数量(不含空闲),用于资源配额与调试统计
ULONG NextHandleNeedingPool; // 0x34: 下一次分配句柄时可能触发池内存扩展的位置(预警/增长策略)
ULONG HandleCountHighWatermark; // 0x38: 曾经达到的最大句柄数量(高水位线),用于性能评估与资源跟踪
};

由于需要支持句柄的高效管理,Windows 内核采用了分层句柄表机制(Layered Handle Table),类似于多级页表。其核心结构是 _HANDLE_TABLE,其字段 TableCode 决定了整个表的层级结构和入口地址。

HandleTable

TableCode 是一个编码指针(Encoded Pointer):

  • 低 2 位(bit0 和 bit1):表示句柄表的层级数,取值范围为 0~2。
  • 清除低 2 位后 :是句柄表根节点的实际地址(即指向第 1 层的目录页)。

句柄表的每一层结构单位是一个内存页(通常为 4KB),其具体内容取决于该层的作用:

  • 如果该层用于目录索引(即存放指针),那么这个内存页中会包含 1024 个指针项(每个指针大小为 4 字节 → 4 × 1024 = 4096 字节),64位系统则是 512 个指针项
  • 如果是最底层的句柄项页,则该页用于存放实际的 HANDLE_TABLE_ENTRY 数组。每个句柄项大小为 8 字节(32 位系统),一页可容纳 512 个句柄项(512 × 8 = 4096 字节),64位系统则是 256 个指针项

2 层结构对于 32 位系统可支持 1024 × 1024 × 512 = 超过 5 亿个句柄项;对于 64 位系统可支持 512 × 512 × 256 = 超过 6 千万个句柄项。

提示

这里提到的内存页指的是句柄表的每一层结构单位的大小是 4KB,相当于内存页的大小,但实际上并不是真正对应一个内存页,有些情况下句柄表的结构单位地址并不关于 0x1000 对齐。

HANDLE_TABLE_ENTRY

句柄项是一个 HANDLE_TABLE_ENTRY 类型的结构体,该类型定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 0x8 bytes (sizeof)
struct _HANDLE_TABLE_ENTRY
{
union
{
VOID* Object; // 0x00: 指向实际内核对象的指针(如进程、线程、事件等),是句柄的核心绑定目标
ULONG ObAttributes; // 0x00: 对象属性字段,包含保护标志、继承控制等
struct _HANDLE_TABLE_ENTRY_INFO* InfoTable; // 0x00: 指向附加信息表(扩展访问权限、审计等),由内核在必要时使用
ULONG Value; // 0x00: 原始值表示,用于快速清零、标志控制或调试用途(位字段)
};
union
{
ULONG GrantedAccess; // 0x04: 分配给句柄的访问权限掩码(如读写、同步、控制等)
struct
{
USHORT GrantedAccessIndex; // 0x04: 权限表中的访问索引,间接映射具体权限值,节省空间
USHORT CreatorBackTraceIndex; // 0x06: 创建该句柄的调用栈索引,用于调试句柄泄漏(仅开启追踪时有效)
};
ULONG NextFreeTableEntry; // 0x04: 若该项空闲,则作为空闲链表的一部分,指向下一个空闲句柄索引
};
};
  • Object:指向实际内核对象的指针。

    • 对于全局句柄表,该指针指向进程或线程对象
    • 对于私有句柄表,该指针指向内核对象前面的 OBJECT_HEADER ,我们需要加上 sizeof(_OBJECT_HEADER) 偏移才能找到句柄表项实际对应的对象。
  • ObAttributes内核对象句柄属性,与 Object 公用同一块内存,占用最低 3 位:

    1
    2
    3
    4
    #define OBJ_PROTECT_CLOSE       0x00000001L
    #define OBJ_INHERIT 0x00000002L
    #define OBJ_AUDIT_OBJECT_CLOSE 0x00000004L
    #define OBJ_HANDLE_ATTRIBUTES (OBJ_PROTECT_CLOSE | OBJ_INHERIT | OBJ_AUDIT_OBJECT_CLOSE)

    因此获取内核对象的时候需要去除这三个标志位:

    1
    (ULONG_PTR)(HandleTableEntry->Object) & ~OBJ_HANDLE_ATTRIBUTES

    这 3 个标志位的功能是:

    • OBJ_PROTECT_CLOSE:即 EXHANDLE_TABLE_ENTRY_LOCK_BIT,是句柄表项的锁。当这个位为 0 的时候表示句柄表项被加锁

      注意

      对于私有句柄表如果我们清空了句柄表表项的 Object 的这个位就表示这个句柄被加锁了,这就意味着如果我们退出进程或者关闭这个句柄都要等待这个句柄的锁,也就会把进程卡死。

    • OBJ_INHERIT:在私有句柄表的表项中使用,表示句柄是否被继承。当这个位为 1 的时候表示句柄可以被子进程继承,这个句柄表项会在该进程创建子进程的时候复制到子进程的私有表的相同位置,也就是句柄值相同

      提示

      OpenProcess 的第二个参数 bInheritHandle 对应的是 OBJ_INHERIT。我们可以向白名单进程注入代码,通过 OpenProcess 打开受保护进程获取可继承的句柄,然后再创建自己的进程,将该句柄继承到自己的进程中去。

      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
      DWORD WINAPI InjectedMain(LPVOID lpParam)
      {
      DWORD targetPid = *(DWORD *)lpParam;

      // 打开目标受保护进程(必须可访问)
      HANDLE hTarget = OpenProcess(PROCESS_ALL_ACCESS, TRUE, targetPid);
      if (!hTarget) {
      return GetLastError(); // 打开失败
      }

      // 创建一个继承句柄的新子进程(注意 bInheritHandles = TRUE)
      STARTUPINFOW si = {0};
      PROCESS_INFORMATION pi = {0};
      si.cb = sizeof(si);

      // 命令行指向你控制的程序
      WCHAR cmdLine[] = L"C:\\Windows\\System32\\notepad.exe";

      BOOL success = CreateProcessW(
      NULL,
      cmdLine,
      NULL, NULL,
      TRUE, // bInheritHandles: 允许继承句柄
      CREATE_NEW_CONSOLE,
      NULL, NULL,
      &si, &pi
      );

      if (!success) {
      CloseHandle(hTarget);
      return GetLastError(); // 创建失败
      }

      // 可选:关闭目标句柄(此时它已被子进程继承)
      CloseHandle(hTarget);

      // 等待子进程结束(可选)
      WaitForSingleObject(pi.hProcess, INFINITE);

      CloseHandle(pi.hProcess);
      CloseHandle(pi.hThread);

      return 0;
      }
    • OBJ_AUDIT_OBJECT_CLOSE:同样是在私有句柄表的表项中使用,表示当关闭这个句柄时进行审计记录(Audit),即结合对象的 SACL(系统访问控制列表)来记录谁什么时候关闭了句柄。只有在对象具有安全描述符并启用审计策略时,才有意义。

  • GrantedAccess:句柄权限,一般在私有句柄表中使用,全局句柄表不使用该字段。

对于 Windows 10 开始的 64 位系统,在获取到 HANDLE_TABLE_ENTRY 的前 16 位是 RefCnt 是句柄的引用计数值。

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
// 0x10 bytes (sizeof)
union _HANDLE_TABLE_ENTRY
{
volatile LONGLONG VolatileLowValue; // 0x00: 原子访问的低 64 位值,用于并发场景下的表项同步操作
LONGLONG LowValue; // 0x00: 表项的普通低位表示,包含句柄指向的对象或附加信息指针
struct
{
struct _HANDLE_TABLE_ENTRY_INFO* volatile InfoTable; // 0x00: 指向句柄附加信息结构的指针(可能包含审计、属性等扩展)
LONGLONG HighValue; // 0x08: 高位信息,通常与低位组合表示完整句柄信息块
union _HANDLE_TABLE_ENTRY* NextFreeHandleEntry; // 0x08: 若为空闲项时,用作空闲链表中的下一个可用句柄指针
struct _EXHANDLE LeafHandleValue; // 0x08: 表示句柄叶节点值的结构体(用于快速查找与压缩表示)
};
LONGLONG RefCountField; // 0x00: 引用计数字段(含标志位),用于内核对象生命周期控制
ULONGLONG Unlocked:1; // 0x00: 位 0:是否未加锁(1 表示空闲或未持有锁)
ULONGLONG RefCnt:16; // 0x00: 位 1–16:句柄的引用计数值(最多 65535 次引用)
ULONGLONG Attributes:3; // 0x00: 位 17–19:句柄属性标志(如是否继承、保护等)
struct
{
ULONGLONG ObjectPointerBits:44; // 0x00: 位 20–63:实际对象指针的高效编码(地址位部分)
ULONG GrantedAccessBits:25; // 0x08: 授予该句柄的访问权限掩码(压缩表示)
ULONG NoRightsUpgrade:1; // 0x08: 是否禁止权限自动升级(如 DuplicateHandle 限制)
ULONG Spare1:6; // 0x08: 保留字段,当前未使用(用于对齐或未来扩展)
};
ULONG Spare2; // 0x0C: 预留字段,用于调试或结构对齐补充空间
};

因此我们需要通过 ExGetHandlePointer 函数将 HANDLE_TABLE_ENTRY 的前 8 字节带符号右移 16 位得到对象的地址。

1
2
3
4
5
6
7
PVOID ExGetHandlePointer(PLONGLONG EncodedHandle) {
return (PVOID)((*EncodedHandle >> 16) & ~0xFuLL);
}

PHANDLE_TABLE_ENTRY CidEntry = ExMapHandleToPointer(PspCidTable, ProcessId);
// [...]
PEOROCESS lProcess = ExGetHandlePointer(&CidEntry->LowValue)

句柄表相关代码分析

句柄查找过程

ExMapHandleToPointer

ExMapHandleToPointer 函数可以从指定句柄表中根据句柄查找到对应的句柄表项

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
//
// 内核导出函数:将句柄映射为对应的句柄表项指针(已加锁)
//

NTKERNELAPI
PHANDLE_TABLE_ENTRY
ExMapHandleToPointer (
__in PHANDLE_TABLE HandleTable, // 指向目标句柄表(如进程句柄表或 PspCidTable)
__in HANDLE Handle // 输入的句柄值(用户传入的句柄)
)

/*++

函数说明:

将一个句柄值解析为其对应的句柄表项指针。
若映射成功,则返回时该表项处于加锁状态,确保后续访问安全。

参数:

HandleTable - 句柄表指针,提供句柄所属表(通常为当前进程的 ObjectTable 或系统全局表)。

Handle - 要映射的句柄(句柄值本质是 `_EXHANDLE` 结构,包含索引与标签位)。

返回值:

若映射成功,则返回已加锁的句柄表项地址;
若句柄非法或已失效,则返回 NULL。

--*/

{
EXHANDLE LocalHandle;
PHANDLE_TABLE_ENTRY HandleTableEntry;

PAGED_CODE(); // 该函数只能在可分页内存中执行,IRQL <= APC_LEVEL

// 将原始 HANDLE 强制转换为内部结构 EXHANDLE
// EXHANDLE 中 Index 字段用于标识句柄表项索引
LocalHandle.GenericHandleOverlay = Handle;

// 安全检查:句柄索引不能是 LOWLEVEL_COUNT(512) 的倍数
// 因为这些索引通常表示页边界或无效项(如空、保留)
if ((LocalHandle.Index & (LOWLEVEL_COUNT - 1)) == 0) {
return NULL;
}

//
// 根据句柄索引在句柄表中查找对应的句柄表项地址
// 若句柄无效或对应项未初始化,则返回 NULL
//
HandleTableEntry = ExpLookupHandleTableEntry(HandleTable, LocalHandle);

// 检查句柄项是否存在,且能否成功加锁(防止并发访问)
if ((HandleTableEntry == NULL) ||
!ExpLockHandleTableEntry(HandleTable, HandleTableEntry)) {

// 若开启了句柄调试(如泄漏追踪),则记录该次失败引用
if (HandleTable->DebugInfo != NULL) {
ExpUpdateDebugInfo(
HandleTable,
PsGetCurrentThread(), // 当前访问该句柄的线程
Handle, // 出问题的句柄
HANDLE_TRACE_DB_BADREF // 错误类型:非法引用
);
}

return NULL;
}

//
// 返回成功加锁的句柄表项指针
//
return HandleTableEntry;
}

ExMapHandleToPointer 主要是对用户传入的句柄做了一些类型转换和检查,另外还有一些加锁的操作。

ExMapHandleToPointer 首先会对句柄索引进行检查:

1
2
3
4
5
6
7
#define PAGE_SIZE 0x1000
#define TABLE_PAGE_SIZE PAGE_SIZE
#define LOWLEVEL_COUNT (TABLE_PAGE_SIZE / sizeof(HANDLE_TABLE_ENTRY))

if ((LocalHandle.Index & (LOWLEVEL_COUNT - 1)) == 0) {
return NULL;
}

这个判断要求句柄的索引值不能是「一个内存页中 HANDLE_TABLE_ENTRY 的数量」的倍数,也就是说每个存放 HANDLE_TABLE_ENTRY 的内存页中的第一个 HANDLE_TABLE_ENTRY 是被视为保留或特殊用途,系统拒绝使用这些特定位置的句柄项

提示

基于这个特性,我们可以通过修改 EPROCESSUniqueProcessId 字段为 LOWLEVEL_COUNT<<2 的整数倍,使得系统无法通过 ExMapHandleToPointer 查询到我们的进程。

之后会调用 ExpLookupHandleTableEntry 函数进行真正的句柄表查询操作。这个函数会根据我们传入的句柄表句柄表索引找到对应的句柄表项

1
2
3
4
5
//
// 根据句柄索引在句柄表中查找对应的句柄表项地址
// 若句柄无效或对应项未初始化,则返回 NULL
//
HandleTableEntry = ExpLookupHandleTableEntry(HandleTable, LocalHandle);

在获取到句柄表项的后需要通过 ExpLockHandleTableEntry 函数给句柄表项加锁。

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
//
// 将指定句柄表项设置为加锁状态
//
BOOLEAN
FORCEINLINE
ExpLockHandleTableEntry (
PHANDLE_TABLE HandleTable,
PHANDLE_TABLE_ENTRY HandleTableEntry
)
{
LONG_PTR NewValue;
LONG_PTR CurrentValue;

//
// 我们即将获取一个自旋锁,确保当前线程已禁止 APC 或提升到 APC_LEVEL
//
ASSERT((KeGetCurrentThread()->CombinedApcDisable != 0) || (KeGetCurrentIrql() == APC_LEVEL));

//
// 尝试循环获取加锁标志:
// - 如果对象指针为 0,说明该表项未使用,直接返回 FALSE
// - 否则,如果最低位为 1(已加锁),尝试使用原子操作(CAS)清除最低位,实现加锁
//
while (TRUE)
{
CurrentValue = ReadForWriteAccess((volatile LONG_PTR *)&HandleTableEntry->Object);

//
// 如果最低位为 1,表示该表项尚未加锁,可以尝试加锁
//
if (CurrentValue & EXHANDLE_TABLE_ENTRY_LOCK_BIT)
{
//
// 构造加锁后的值:清除最低位(加锁标志位)
//
NewValue = CurrentValue - EXHANDLE_TABLE_ENTRY_LOCK_BIT;

//
// 使用 CAS 尝试原子替换对象指针(带锁)
//
if ((LONG_PTR)(InterlockedCompareExchangePointer(
&HandleTableEntry->Object,
(PVOID)NewValue,
(PVOID)CurrentValue)) == CurrentValue)
{
return TRUE; // 加锁成功
}
}
else
{
//
// 若表项值为 0,则该项未使用,返回失败
//
if (CurrentValue == 0)
{
return FALSE;
}
}

//
// 表项已被其他线程加锁,阻塞当前线程等待
//
ExpBlockOnLockedHandleEntry(HandleTable, HandleTableEntry);
}
}

这个函数的具体操作是给 HandleTableEntry->Object 的最低位置 0。

1
2
// 这是用于加锁句柄表项的“最低位”标志(低地址位)
#define EXHANDLE_TABLE_ENTRY_LOCK_BIT 1

因此对于私有句柄表如果我们清空了句柄表表项的 ObjectEXHANDLE_TABLE_ENTRY_LOCK_BIT 位那么就表示这个句柄被加锁了,这就意味着如果我们退出进程或者关闭这个句柄都要等待这个句柄的锁,也就会把进程卡死。

ExpLookupHandleTableEntry

实际进行句柄查找的核心函数是 ExpLookupHandleTableEntry

ExpLookupHandleTableEntry 会将句柄值的高 30 位作为下标在句柄表中查找对应的句柄表项,查找过程类似页表的地址转换,只不过我们要根据句柄表指针的低 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
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
//
// 内部支持函数:在句柄表中查找指定句柄值对应的句柄表项
//

PHANDLE_TABLE_ENTRY
ExpLookupHandleTableEntry (
IN PHANDLE_TABLE HandleTable, // 目标句柄表指针
IN EXHANDLE tHandle // 要查找的句柄(结构化形式)
)

/*++

函数说明:

根据传入的句柄值,在句柄表中查找其对应的句柄表项地址。
支持 1~3 层句柄表结构。

参数:

HandleTable - 指向句柄表结构(如进程的 ObjectTable 或全局句柄表 PspCidTable)

tHandle - 要查找的句柄(封装为 EXHANDLE,便于访问 Index 字段)

返回值:

若句柄有效,返回其对应的句柄表项指针;
若句柄超出句柄表当前分配范围,或结构异常,则返回 NULL。

--*/

{
ULONG_PTR i, j, k;
ULONG_PTR CapturedTable;
ULONG TableLevel;
PHANDLE_TABLE_ENTRY Entry = NULL;
EXHANDLE Handle;

PUCHAR TableLevel1;
PUCHAR TableLevel2;
PUCHAR TableLevel3;

ULONG_PTR MaxHandle;

PAGED_CODE(); // 函数仅可在可分页内存中运行(IRQL <= APC_LEVEL)

//
// 取出句柄值索引(Index),并清除 TagBits(低2位用户标签位)
//
Handle = tHandle;
Handle.TagBits = 0;

//
// 获取当前句柄表已分配的最大句柄值(边界检查用)
//
MaxHandle = *(volatile ULONG *) &HandleTable->NextHandleNeedingPool;

//
// 若句柄值大于当前句柄表最大句柄数,则说明非法
//
if (Handle.Value >= MaxHandle) {
return NULL;
}

//
// 捕获 TableCode 值,并提取层级信息(低2位存储)
//
CapturedTable = *(volatile ULONG_PTR *) &HandleTable->TableCode;

// 提取句柄表层级数(0、1、2)
TableLevel = (ULONG)(CapturedTable & LEVEL_CODE_MASK);

// 清除层级位,得到实际的根目录地址
CapturedTable = CapturedTable - TableLevel;

//
// 根据句柄表的层数执行不同的索引逻辑
//
switch (TableLevel) {

case 0: // 单层句柄表

//
// TableLevel1 指向句柄页(句柄项数组)
//
TableLevel1 = (PUCHAR) CapturedTable;

//
// 句柄值已按 4 字节对齐,乘以 (ENTRY_SIZE / 4) 即为偏移项
//
Entry = (PHANDLE_TABLE_ENTRY) &TableLevel1[
Handle.Value * (sizeof(HANDLE_TABLE_ENTRY) / HANDLE_VALUE_INC)
];

break;

case 1: // 两层结构

//
// 一级目录页,含 1024 个指针(每页 4KB)
//
TableLevel2 = (PUCHAR) CapturedTable;

// i = 句柄值在句柄页中的偏移(页内偏移)
i = Handle.Value % (LOWLEVEL_COUNT * HANDLE_VALUE_INC); // 512 * 4 = 2048

Handle.Value -= i;

// j = 一级目录中的索引项
j = Handle.Value / ((LOWLEVEL_COUNT * HANDLE_VALUE_INC) / sizeof(PHANDLE_TABLE_ENTRY));

// 定位句柄页地址
TableLevel1 = (PUCHAR) *(PHANDLE_TABLE_ENTRY *) &TableLevel2[j];

// 最终取出句柄表项指针
Entry = (PHANDLE_TABLE_ENTRY) &TableLevel1[
i * (sizeof(HANDLE_TABLE_ENTRY) / HANDLE_VALUE_INC)
];

break;

case 2: // 三层结构(大规模句柄表)

TableLevel3 = (PUCHAR) CapturedTable;

// i = 页内偏移
i = Handle.Value % (LOWLEVEL_COUNT * HANDLE_VALUE_INC);

Handle.Value -= i;

// k = 二级目录整体偏移索引
k = Handle.Value / ((LOWLEVEL_COUNT * HANDLE_VALUE_INC) / sizeof(PHANDLE_TABLE_ENTRY));

// j = 一级目录页内偏移
j = k % (MIDLEVEL_COUNT * sizeof(PHANDLE_TABLE_ENTRY));

// k = 顶级目录索引
k -= j;
k /= MIDLEVEL_COUNT;

// 三级跳转:根 → 二级页 → 句柄页
TableLevel2 = (PUCHAR) *(PHANDLE_TABLE_ENTRY *) &TableLevel3[k];
TableLevel1 = (PUCHAR) *(PHANDLE_TABLE_ENTRY *) &TableLevel2[j];

// 最终定位句柄表项
Entry = (PHANDLE_TABLE_ENTRY) &TableLevel1[
i * (sizeof(HANDLE_TABLE_ENTRY) / HANDLE_VALUE_INC)
];

break;

default:
_assume(0); // 永远不应触发此路径
}

return Entry;
}

句柄表遍历

Windows 导出了一个 ExEnumHandleTable 函数用于遍历句柄表。这个函数本质上就是枚举句柄值,然后调用 ExpLookupHandleTableEntry 在句柄表中查找对应的句柄表项,然后调用回调函数,直到 ExpLookupHandleTableEntry 返回为 NULL。

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
NTKERNELAPI
BOOLEAN
ExEnumHandleTable (
__in PHANDLE_TABLE HandleTable, // 句柄表的指针
__in EX_ENUMERATE_HANDLE_ROUTINE EnumHandleProcedure, // 每个有效句柄的回调函数
__in PVOID EnumParameter, // 每次调用回调函数时传递的未解释的 32 位参数
__out_opt PHANDLE Handle // 可选的指针,接收枚举中停止的句柄
)

/*++

例程说明:

此函数用于遍历句柄表中的所有有效句柄,并调用指定的回调函数对每个有效句柄进行处理。
如果回调函数返回 `TRUE`,则表示停止枚举,当前的句柄会通过可选的 `Handle` 参数返回给调用者,
并且该函数会返回 `TRUE`,表示枚举在特定的句柄处停止。

参数:

HandleTable - 传入句柄表的指针。句柄表包含了所有有效的句柄条目。

EnumHandleProcedure - 传入一个回调函数的指针,该回调函数会在每个有效句柄上调用。
回调函数需要返回一个布尔值,返回 `TRUE` 表示停止枚举,返回 `FALSE` 表示继续枚举。

EnumParameter - 传入的一个 32 位参数,作为上下文数据传递给回调函数。回调函数会使用这个参数来获取附加的上下文信息。

Handle - 可选的参数,传入一个指针变量,接收枚举停止时的句柄值。只有当回调函数返回 `TRUE` 时,该句柄才会被设置。

返回值:

如果枚举在某个特定句柄处停止,则返回 `TRUE`,并通过 `Handle` 参数返回该句柄的值。
如果枚举没有在某个特定句柄处停止,则返回 `FALSE`,表示遍历了整个句柄表。

--*/

{
PKTHREAD CurrentThread;
BOOLEAN ResultValue;
EXHANDLE LocalHandle;
PHANDLE_TABLE_ENTRY HandleTableEntry;

PAGED_CODE(); // 此函数只能在分页模式下执行

// 获取当前执行的线程
CurrentThread = KeGetCurrentThread();

// 初始返回值设为 FALSE,表示枚举将继续进行,直到回调函数返回 TRUE 才会停止
ResultValue = FALSE;

// 进入临界区,防止当前线程在遍历句柄表期间被中断
KeEnterCriticalRegionThread(CurrentThread);

// 遍历句柄表中的每一个句柄条目
for (LocalHandle.Value = 0;
(HandleTableEntry = ExpLookupHandleTableEntry(HandleTable, LocalHandle)) != NULL;
LocalHandle.Value += HANDLE_VALUE_INC) {

// 如果句柄条目有效,则调用回调函数
if (ExpIsValidObjectEntry(HandleTableEntry)) {

// 锁定当前句柄条目,确保回调期间不会被其他线程修改该条目
if (ExpLockHandleTableEntry(HandleTable, HandleTableEntry)) {

// 调用回调函数处理当前句柄
// 如果回调函数返回 TRUE,表示枚举停止
ResultValue = (*EnumHandleProcedure)(HandleTableEntry, LocalHandle.GenericHandleOverlay, EnumParameter);

// 解锁句柄条目
ExUnlockHandleTableEntry(HandleTable, HandleTableEntry);

// 如果回调函数返回 TRUE,表示枚举停止
if (ResultValue) {
// 如果提供了 Handle 参数,则将当前句柄赋值给 Handle
if (ARGUMENT_PRESENT(Handle)) {
*Handle = LocalHandle.GenericHandleOverlay;
}
break; // 退出遍历,停止枚举
}
}
}
}

// 退出临界区,允许其他线程访问句柄表
KeLeaveCriticalRegionThread(CurrentThread);

// 返回是否在特定句柄处停止了枚举
return ResultValue;
}

其中 ExpIsValidObjectEntry 宏用于检查句柄条目是否有效。

1
2
3
4
#define EX_ADDITIONAL_INFO_SIGNATURE (-2)

#define ExpIsValidObjectEntry(Entry) \
( (Entry != NULL) && (Entry->Object != NULL) && (Entry->NextFreeTableEntry != EX_ADDITIONAL_INFO_SIGNATURE) )

它检查以下三个条件,当这三个条件都成立时,句柄条目才被认为是有效的。

  • Entry != NULL:检查句柄条目是否为空。
  • Entry->Object != NULL:检查句柄条目是否有有效的对象。
  • Entry->NextFreeTableEntry != EX_ADDITIONAL_INFO_SIGNATURE:检查句柄条目的 NextFreeTableEntry 是否不等于 EX_ADDITIONAL_INFO_SIGNATURE(-2),这是一个特殊的标识符,用于表示已释放的句柄条目。

如果句柄有效则先锁定句柄表项,然后调用用户传入的回调函数 EnumHandleProcedure,解锁句柄表项,最后根据回调函数的返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 锁定当前句柄条目,确保回调期间不会被其他线程修改该条目
if (ExpLockHandleTableEntry(HandleTable, HandleTableEntry)) {

// 调用回调函数处理当前句柄
// 如果回调函数返回 TRUE,表示枚举停止
ResultValue = (*EnumHandleProcedure)(HandleTableEntry, LocalHandle.GenericHandleOverlay, EnumParameter);

// 解锁句柄条目
ExUnlockHandleTableEntry(HandleTable, HandleTableEntry);

// 如果回调函数返回 TRUE,表示枚举停止
if (ResultValue) {
// 如果提供了 Handle 参数,则将当前句柄赋值给 Handle
if (ARGUMENT_PRESENT(Handle)) {
*Handle = LocalHandle.GenericHandleOverlay;
}
break; // 退出遍历,停止枚举
}
}

其中用户传入的回调函数的类型 EX_ENUMERATE_HANDLE_ROUTINE 定义如下:

1
2
3
4
5
typedef BOOLEAN (*EX_ENUMERATE_HANDLE_ROUTINE)(
IN PHANDLE_TABLE_ENTRY HandleTableEntry, // 句柄表条目的指针
IN HANDLE Handle, // 当前句柄的值
IN PVOID EnumParameter // 枚举时传递的额外参数
);

回调函数需要返回一个布尔值。如果返回 TRUE,则表示枚举停止;如果返回 FALSE,则继续枚举下一个句柄。

全局句柄表

全局句柄表 PspCidTable 是一个 HANDLE_TABLE 类型的全局指针,指向一个句柄表管理结构。这个表中存储着所有进程线程对象。

1
PHANDLE_TABLE PspCidTable;

句柄表项添加

Windows 在创建进程PspAllocateProcess)和线程PspAllocateThread)的时候都会把进程对象线程对象加入到这个全局句柄表中,并得到进程 ID线程 ID

PspAllocateProcess 为例:

1
2
3
4
5
6
7
8
9
10
//
// 创建进程 ID(即为进程分配唯一的句柄)
//

CidEntry.Object = Process; // 设置句柄表项的对象字段,指向当前进程对象(EPROCESS)
CidEntry.GrantedAccess = 0; // 不使用访问掩码,此句柄仅用于内部标识进程 ID

// 调用 ExCreateHandle 向 PspCidTable(系统全局 CID 句柄表)插入条目
// 返回值为一个唯一句柄(HANDLE),即进程的 UniqueProcessId
Process->UniqueProcessId = ExCreateHandle(PspCidTable, &CidEntry);

提示

  • 全局句柄表的表项的 Object 指向的是进程或线程对象,不指向前面的 OBJECT_HEADER
  • 全局句柄表的表项的的 GrantedAccess 为 0,即全局句柄表的表项没有句柄属性。

ExCreateHandle 会根据传入的 HandleTableEntry 在句柄表中分配一个空闲的句柄表项 NewHandleTableEntry。之后会调用 ExUnlockHandleTableEntry 解锁句柄表项,这里会将句柄表项的 Object 的最低位置 1。因此全局句柄表项的 Object 去掉最低位才是进程或线程对象的地址

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
NTKERNELAPI
HANDLE
ExCreateHandle (
__inout PHANDLE_TABLE HandleTable, // 目标句柄表(如进程的 ObjectTable 或 PspCidTable)
__in PHANDLE_TABLE_ENTRY HandleTableEntry // 输入的模板句柄表项内容(将复制到新分配表项中)
)
{
EXHANDLE Handle; // EXHANDLE 结构(包含索引 + 属性位)
PETHREAD CurrentThread; // 当前线程
PHANDLE_TABLE_ENTRY NewHandleTableEntry; // 新分配的句柄表项指针

PAGED_CODE(); // 限定此函数只能在 IRQL <= APC_LEVEL 执行

//
// 初始化句柄值为 0(表示句柄创建失败的默认返回值)
//
Handle.GenericHandleOverlay = NULL;

//
// 分配一个空闲的句柄表项,并填充句柄索引(Handle.Index)
// 分配逻辑中会加锁该表项,确保接下来的写入是安全的
//
NewHandleTableEntry = ExpAllocateHandleTableEntry(HandleTable, &Handle);

//
// 分配成功则进入正式创建流程
//
if (NewHandleTableEntry != NULL) {

//
// 获取当前线程(用于进入临界区和记录调试信息)
//
CurrentThread = PsGetCurrentThread();

//
// 进入线程临界区,防止被挂起,保护接下来的句柄写入过程
//
KeEnterCriticalRegionThread(&CurrentThread->Tcb);

//
// 将传入的模板句柄表项内容复制到新分配的表项中
// 此时该表项仍处于“加锁”状态,不会被其他线程并发访问
//
*NewHandleTableEntry = *HandleTableEntry;

//
// 若句柄表启用了调试信息(如句柄泄露追踪),则记录此次创建事件
//
if (HandleTable->DebugInfo != NULL) {
ExpUpdateDebugInfo(
HandleTable,
CurrentThread,
Handle.GenericHandleOverlay, // 记录的是句柄值
HANDLE_TRACE_DB_OPEN // 操作类型:创建
);
}

//
// 解锁句柄表项,使其可被后续使用
//
ExUnlockHandleTableEntry(HandleTable, NewHandleTableEntry);

//
// 离开线程临界区,恢复线程可挂起状态
//
KeLeaveCriticalRegionThread(&CurrentThread->Tcb);
}

//
// 返回句柄值(若失败则为 NULL)
//
return Handle.GenericHandleOverlay;
}

全局句柄表查询

获取 PspCidTable

GetPspCidTable 函数通过例如 PsLookupProcessByProcessId 的特征码来定位 PspCidTable 的地址。

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
/**
* GetPspCidTable - 获取内核中的全局句柄表 PspCidTable 的真实地址
*
* 返回值:
* 成功:返回 PspCidTable 的真实指针(类型为 PHANDLE_TABLE)
* 失败:返回 NULL
*/
PVOID
GetPspCidTable(
VOID
)
{
static PVOID g_PspCidTable = NULL;

// 若已缓存,直接返回
if (g_PspCidTable)
return g_PspCidTable;

MEMORY_REGION region = { 0 };

// 获取 ntoskrnl.exe 的加载地址范围
if (!GetKernelModuleRegion("ntoskrnl.exe", &region)) {
DbgPrint("[-] 无法获取 ntoskrnl.exe 范围\n");
return NULL;
}

//
// 初始化 PspCidTable 特征码:
// 目标字节序列(来自实际 IDA 反汇编)如下:
//
// 8B 3D XX XX XX XX mov edi, _PspCidTable ; 读全局指针 [imm32]
// E8 ?? ?? ?? ?? call ExMapHandleToPointer
// 8B F8 mov edi, eax
// 85 FF test edi, edi
// 74 ?? jz ...
// 8B 1F mov ebx, [edi]
//
// 特征码字符串如下,使用 * 表示通配(1 字节):
// "8B*****E8****8BF885FF74*8B1F"
//
// Offset = 2 表示我们从匹配地址向后偏移 2 字节(即 MOV 指令的 imm32 立即数部分),
// 再对该地址进行 **两次解引用**,获得 PspCidTable 的真实指针。
//
SIGNATURE_PATTERN sig = { 0 };
if (!InitSignaturePattern(&sig, "8B*****E8****8BF885FF74*8B1F", 2)) {
DbgPrint("[-] InitSignaturePattern 失败\n");
return NULL;
}

// 搜索特征码在 ntoskrnl.exe 范围内的匹配位置
PUCHAR addr = FindMultiplePatternsInRange(
region.BaseAddress,
region.RegionSize,
&sig,
1
);
if (!addr) {
DbgPrint("[-] 未能定位到 PspCidTable 特征码位置\n");
return NULL;
}

// 二级解引用:从 MOV 指令中获取立即地址 [imm32] -> 解引用获取 PspCidTable 值
g_PspCidTable = **(PVOID**)addr;

// 校验指针有效性
if (!MmIsAddressValid(g_PspCidTable)) {
DbgPrint("[-] 解引用 PspCidTable 值无效: %p\n", g_PspCidTable);
g_PspCidTable = NULL;
return NULL;
}

DbgPrint("[+] 成功获取 PspCidTable = %p (from 指令地址 = %p)\n", g_PspCidTable, addr);
return g_PspCidTable;
}

获取 ExpLookupHandleTableEntry

由于句柄表查询函数 ExpLookupHandleTableEntry 在不同版本操作系统的实现不太一样,因此我们既不方便自己实现一个句柄表查询函数,也不方便直接通过搜索 ExpLookupHandleTableEntry 的特征码定位该函数。

我们可以通过 ExEnumHandleTable 函数来间接定位 ExpLookupHandleTableEntry 函数。

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
/**
* GetExpLookupHandleTableEntry - 获取 ExpLookupHandleTableEntry 函数地址
*
* 本函数通过特征码从 ExEnumHandleTable 函数体内反推出其调用的 ExpLookupHandleTableEntry 函数地址。
* 原因是 ExpLookupHandleTableEntry 未导出,无法直接获取,只能依赖上下文定位。
*
* 返回值:
* 成功:返回 ExpLookupHandleTableEntry 的地址
* 失败:返回 NULL
*/
PVOID
GetExpLookupHandleTableEntry(
VOID
)
{
static PVOID g_ExpLookupHandleTableEntry = NULL;

// 如果已经缓存成功,直接返回
if (g_ExpLookupHandleTableEntry)
return g_ExpLookupHandleTableEntry;

// 使用 MmGetSystemRoutineAddress 获取 ExEnumHandleTable 函数地址
UNICODE_STRING uName = RTL_CONSTANT_STRING(L"ExEnumHandleTable");
PVOID pExEnum = MmGetSystemRoutineAddress(&uName);
if (!pExEnum) {
DbgPrint("[-] 无法获取 ExEnumHandleTable 地址\n");
return NULL;
}

//
// 初始化 ExpLookupHandleTableEntry 特征码:
// 目标字节序列如下(取自 IDA 分析):
//
// FF 75 F8 push [ebp+var_8]
// 8B 4D 08 mov ecx, [ebp+arg_0]
// E8 ?? ?? ?? ?? call ExpLookupHandleTableEntry
// 8B F0 mov esi, eax
// 85 F6 test esi, esi
// 75 ?? jnz ...
// EB ?? jmp ...
//
// 特征码字符串如下(* 表示通配任意 1 字节):
// "FF75*8B4D*E8****8BF085F675*EB*"
//
// 设置 Offset = -7,表示最终结果指向 E8 后的相对偏移字段,即:
// match - (-7) = match + 7 = CALL 的立即数起始位置
//
SIGNATURE_PATTERN sig = { 0 };
if (!InitSignaturePattern(&sig, "FF75*8B4D*E8****8BF085F675*EB*", -7)) {
DbgPrint("[-] InitSignaturePattern 初始化失败\n");
return NULL;
}

// 在 ExEnumHandleTable 附近查找特征码(通常函数大小不会超过 0x100 字节)
PUCHAR base = (PUCHAR)pExEnum;
SIZE_T searchRange = 0x100;

PUCHAR match = FindMultiplePatternsInRange(base, searchRange, &sig, 1);
if (!match) {
DbgPrint("[-] 未找到 ExpLookupHandleTableEntry 特征码匹配位置\n");
return NULL;
}

// 提取 call 指令中的相对偏移
PUCHAR relAddr = match; // Offset = -7, 刚好指向 call 的立即数部分
INT32 rel = *(INT32*)relAddr;

PUCHAR target = relAddr + 4 + rel; // CALL 指令目标 = 当前地址 + 5 + 相对偏移
g_ExpLookupHandleTableEntry = (PVOID)target;

DbgPrint("[+] 成功定位 ExpLookupHandleTableEntry = %p\n", g_ExpLookupHandleTableEntry);
return g_ExpLookupHandleTableEntry;
}

全局句柄表查询函数实现

基于前面获取的 PspCidTableExpLookupHandleTableEntry 我们可以实现全局句柄表查询。

注意

在 32 位下 ExpLookupHandleTableEntry 的调用约定是 thiscall,也就是说第一个参数放在 ECX 中,剩余参数放在堆栈上。

由于 C 语言不支持 thiscall 调用约定,除非内联汇编进行模拟,因此这里使用 fastcall 来模拟 thiscall 调用约定。而 fastcall 的前两个参数放在 ECXEDX 中,剩余参数放在堆栈上,因此我们需要多传入一个参数占位 EDX

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
/**
* QueryCidHandleEntry - 查询指定 PID/TID 对应的句柄表项
*
* 参数:
* @UniqueId: 进程 PID 或线程 TID(CID 值,句柄值)
*
* 返回值:
* 成功:返回对应对象指针(如 EPROCESS 或 ETHREAD)
* 失败:返回 NULL
*
* 注意:
* - 使用 __fastcall 调用模拟 __thiscall(ECX = PspCidTable)
* - 第二参数为占位(EDX)
* - 内部动态定位关键结构与函数
*/
PVOID
QueryCidHandleEntry(
_In_ HANDLE UniqueId
)
{
// 获取全局句柄表地址(PspCidTable)
PHANDLE_TABLE cidTable = (PHANDLE_TABLE)GetPspCidTable();
if (!cidTable) {
DbgPrint("[-] 获取 PspCidTable 失败\n");
return NULL;
}

// 定义 fastcall 函数指针(第 2 参数 dummy 用于占位)
typedef PVOID (__fastcall *EXP_LOOKUP_HANDLE_TABLE_ENTRY)(
PHANDLE_TABLE HandleTable, // 实际为 this(ECX)
PVOID Dummy, // 占位用于填充 EDX
HANDLE Handle // 要查询的句柄
);

// 获取函数地址
EXP_LOOKUP_HANDLE_TABLE_ENTRY fnLookup =
(EXP_LOOKUP_HANDLE_TABLE_ENTRY)GetExpLookupHandleTableEntry();

if (!fnLookup) {
DbgPrint("[-] 获取 ExpLookupHandleTableEntry 失败\n");
return NULL;
}

// 执行查询(传入 dummy 参数为 0)
PVOID object = fnLookup(cidTable, NULL, UniqueId);
if (!object) {
DbgPrint("[-] 未找到句柄项(Handle = %p)\n", UniqueId);
return NULL;
}

DbgPrint("[+] 成功查询 CID 对象地址 = %p (Handle = %p)\n", object, UniqueId);
return object;
}

进程保护

我们可以使用 PsLookupProcessByProcessIdPsLookupThreadByThreadId 根据进程 ID 和线程 ID 在全局句柄表中查找对应的进程对象和线程对象。

另外很多 API 例如 NtOpenProcess 内部也是通过 PsLookupProcessByProcessId 等 API 查询全局句柄表来找到进程的。

因此如果我们能够将自身进程在全局句柄表中对应的句柄表项清空,那么就可以让很多查找进程的 API 无法定位到自身进程从而达到进程保护的目的。

1
2
3
// 清除对象指针(隐藏进程)
PVOID oldObject = InterlockedExchangePointer(&entry->Object, NULL);
DbgPrint("[+] 成功隐藏进程,PID=%p,原对象地址=%p\n", pid, oldObject);

提示

PsLookupProcessByProcessId 实际上是根据进程 ID 查询出对应的全局句柄表项,因此如果将全局句柄表项置空,则会导致后续出现内存访问错误。

1
2
3
4
CidEntry = ExMapHandleToPointer(PspCidTable, ProcessId);
if (CidEntry != NULL) {
lProcess = (PEPROCESS)CidEntry->Object;
if (lProcess->Pcb.Header.Type == ProcessObject && // 👈 CidEntry->Object = NULL 导致内存访问错误

但是实际上这种事情并没有发生,这是因为我们清空全局句柄表项实际上会对句柄表项加锁,这就导致 ExMapHandleToPointer 调用的 ExpLockHandleTableEntry 函数加锁失败返回 FALSE

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
//
// 将指定句柄表项设置为加锁状态
//
BOOLEAN
FORCEINLINE
ExpLockHandleTableEntry (
PHANDLE_TABLE HandleTable,
PHANDLE_TABLE_ENTRY HandleTableEntry
)
{
LONG_PTR NewValue;
LONG_PTR CurrentValue;

// [...]

while (TRUE)
{
CurrentValue = ReadForWriteAccess((volatile LONG_PTR *)&HandleTableEntry->Object);

//
// 如果最低位为 1,表示该表项尚未加锁,可以尝试加锁
//
if (CurrentValue & EXHANDLE_TABLE_ENTRY_LOCK_BIT)
{
// [...]
}
else
{
//
// 若表项值为 0,则该项未使用,返回失败
//
if (CurrentValue == 0)
{
return FALSE; // 👈 从这里返回
}
}

// [...]
}
}

进而导致 ExMapHandleToPointer 返回 NULL 导致进程查询失败。

1
2
3
4
5
6
7
8
// 检查句柄项是否存在,且能否成功加锁(防止并发访问)
if ((HandleTableEntry == NULL) ||
!ExpLockHandleTableEntry(HandleTable, HandleTableEntry)) { // 👈 ExpLockHandleTableEntry 返回 FALSE

// [...]

return NULL;
}

然而这种方法会导致被保护进程退出时蓝屏,这是因为在 PspProcessDelete 函数中 ExDestroyHandle 查询不到我们隐藏的进程。

1
2
3
4
5
if (Process->UniqueProcessId) {  // 👈 Process->UniqueProcessId = 0 可以避免蓝屏
if (!(ExDestroyHandle (PspCidTable, Process->UniqueProcessId, NULL))) {
KeBugCheck (CID_HANDLE_DELETION);
}
}

解决方法也很简单,只要将 Process->UniqueProcessId 置 0 即可。完整代码如下:

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
/**
* GetEprocessUniqueProcessIdOffset - 获取 _EPROCESS.UniqueProcessId 字段偏移(缓存)
*
* 本函数通过分析 PsGetProcessId 函数中的汇编代码,提取访问 `UniqueProcessId` 成员的指令偏移(disp32),
* 并将其缓存以提升效率。匹配字节码如下:
*
* 8B 45 08 mov eax, [ebp+arg_0]
* 8B 80 XX XX XX XX mov eax, [eax+UniqueProcessId]
* 5D pop ebp
*
* 匹配字符串(* 表示 1 字节通配):"8B45*8B80****5D"
* 偏移设置为 Offset = -5,指向第二条 mov 指令的 disp32 位置。
*
* 返回值:
* 成功:返回 UniqueProcessId 偏移(单位:字节)
* 失败:返回 -1
*/
LONG
GetEprocessUniqueProcessIdOffset(
VOID
)
{
static LONG g_UniquePidOffset = -2; // -2 表示未初始化,-1 表示失败,>=0 为合法偏移

if (g_UniquePidOffset != -2)
return g_UniquePidOffset;

// 获取 PsGetProcessId 函数地址
UNICODE_STRING uName = RTL_CONSTANT_STRING(L"PsGetProcessId");
PVOID func = MmGetSystemRoutineAddress(&uName);
if (!func) {
DbgPrint("[-] 无法获取 PsGetProcessId 地址\n");
g_UniquePidOffset = -1;
return -1;
}

// 初始化特征码
SIGNATURE_PATTERN sig = { 0 };
if (!InitSignaturePattern(&sig, "8B45*8B80****5D", -5)) {
DbgPrint("[-] InitSignaturePattern 失败\n");
g_UniquePidOffset = -1;
return -1;
}

// 在 PsGetProcessId 函数体内搜索特征码
PUCHAR base = (PUCHAR)func;
SIZE_T range = 0x40;
PUCHAR match = FindMultiplePatternsInRange(base, range, &sig, 1);
if (!match) {
DbgPrint("[-] 未找到 UniqueProcessId 偏移匹配特征\n");
g_UniquePidOffset = -1;
return -1;
}

// 提取偏移字段(disp32)
g_UniquePidOffset = *(LONG*)match;
DbgPrint("[+] _EPROCESS.UniqueProcessId 偏移 = 0x%X\n", g_UniquePidOffset);
return g_UniquePidOffset;
}


/**
* ProtectProcess - 隐藏指定 PID 对应的进程对象
*
* 参数:
* @pid - 目标进程的 PID(句柄形式)
*
* 功能:
* - 清除全局句柄表中指定 PID 的对象指针;
* - 清零对应进程对象中的 UniqueProcessId 字段,避免 ExDestroyHandle 蓝屏。
*/
VOID
ProtectProcess(
_In_ HANDLE pid
)
{
DbgPrint("[*] 尝试隐藏 PID = %p 的进程对象\n", pid);

// 查询该 PID 对应的句柄表项
PHANDLE_TABLE_ENTRY entry = (PHANDLE_TABLE_ENTRY)QueryCidHandleEntry(pid);
if (!entry) {
DbgPrint("[-] 查询 CID 表失败,PID=%p\n", pid);
return;
}

// 原子清除该句柄表项中的对象指针
PVOID oldObject = InterlockedExchangePointer(&entry->Object, NULL);
if (!oldObject) {
DbgPrint("[-] 指定 PID 的句柄表项对象已为空\n");
return;
}

DbgPrint("[+] 已清空句柄表项,PID=%p,原对象地址=%p\n", pid, oldObject);

// 获取 _EPROCESS.UniqueProcessId 偏移(静态缓存)
LONG offset = GetEprocessUniqueProcessIdOffset();
if (offset < 0) {
DbgPrint("[-] 获取 UniqueProcessId 偏移失败,无法清零 PID 字段\n");
return;
}

// 清除 EPROCESS 对象中的 UniqueProcessId 字段,防止蓝屏
*(PHANDLE)((PUCHAR)oldObject + offset) = NULL;
DbgPrint("[+] 成功清除 EPROCESS.UniqueProcessId,PID=%p\n", pid);
}

注意

这个方法在虚拟机中依然会造成蓝屏,这是由虚拟机的驱动 vm3dmp.sys 注册的进程退出回调造成的。

私有句柄表

EPROCESSObjectTable 指针指向改进程的私有句柄表。

提示

与全局句柄表不同的是,私有句柄表中的句柄表项指向的对象是包含 OBJECT_HEADER 的,我们需要加上 sizeof(_OBJECT_HEADER) 偏移才能找到句柄表项实际对应的对象。

获取对象类型

私有句柄表中存储的是进程打开的所有句柄,因此我们在遍历其中的句柄表项时需要根据句柄表项指向的 OBJECT_HEADER 来确定对象的类型。 OBJECT_HEADER 的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 0x20 bytes (sizeof)
struct _OBJECT_HEADER
{
LONG PointerCount; // 0x00: 对象的引用计数(包括内核和非句柄引用),为 0 时表示对象可回收
union
{
LONG HandleCount; // 0x04: 通过句柄引用该对象的次数,仅当对象具有句柄时有效
VOID* NextToFree; // 0x04: 如果对象处于释放状态,该字段用于对象池中的链表指针
};
struct _EX_PUSH_LOCK Lock; // 0x08: 用于同步对对象头的并发访问(如属性、标志的修改)
UCHAR TypeIndex; // 0x0C: 📌对象类型索引(指向内核的对象类型表),如进程、线程、事件等
UCHAR TraceFlags; // 0x0D: 跟踪相关标志位(如是否启用对象创建/删除记录)
UCHAR InfoMask; // 0x0E: 指示哪些扩展信息字段(如名称、安全描述符等)已存在的掩码
UCHAR Flags; // 0x0F: 对象标志,如 Permanent、KernelOnlyAccess、DefaultSecurityQuota 等
union
{
struct _OBJECT_CREATE_INFORMATION* ObjectCreateInfo; // 0x10: 对象创建时传入的参数结构指针,仅在创建阶段有效
VOID* QuotaBlockCharged; // 0x10: 指向被收取配额的块(如进程),用于资源统计与限制
};
VOID* SecurityDescriptor; // 0x14: 指向安全描述符的指针,描述该对象的访问控制信息(ACL)
struct _QUAD Body; // 0x18: 对象的实际数据内容(正文体),类型由 TypeIndex 决定
};

提示

OBJECT_HEADER 中的 Body 只是个占位的成员,用来表示对象本体,也就是说这里 OBJECT_HEADER 的实际大小为 0x18。这么定义的好处是方便我们使用 CONTAINING_RECORD 从对象指针找到 OBJECT_HEADER

1
2
3
// 使用 CONTAINING_RECORD 宏从 Body 偏移回 _OBJECT_HEADER 的起始地址
// 相当于:Header = (POBJECT_HEADER)((PUCHAR)Object - offsetof(OBJECT_HEADER, Body));
POBJECT_HEADER Header = CONTAINING_RECORD(Object, OBJECT_HEADER, Body);

其中的 TypeIndex 字段表示的是对象的类型索引,这里的索引是在 OBJECT_TYPE 指针数组 ObTypeIndexTable 中的索引。

我们可以通过 OBJECT_TYPEName 确定对象的具体类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 0x88 bytes (sizeof)
struct _OBJECT_TYPE
{
struct _LIST_ENTRY TypeList; // 0x00: 链入全局对象类型链表中的链接,用于遍历系统中所有对象类型
struct _UNICODE_STRING Name; // 0x08: 📌对象类型名称(如 "Process"、"Thread"、"Event" 等)
VOID* DefaultObject; // 0x10: 指向默认对象实例的指针(如默认命名空间、模板对象等)
UCHAR Index; // 0x14: 对象类型在内核类型数组中的索引(与对象头中的 TypeIndex 对应)
ULONG TotalNumberOfObjects; // 0x18: 当前系统中此类型的对象总数(包括未被引用的)
ULONG TotalNumberOfHandles; // 0x1C: 当前活动的句柄数量(即用户正在使用的该类型对象总数)
ULONG HighWaterNumberOfObjects; // 0x20: 系统历史上曾经同时存在的最大该类型对象数量(对象峰值)
ULONG HighWaterNumberOfHandles; // 0x24: 历史上同时存在的最大该类型句柄数(句柄峰值)
struct _OBJECT_TYPE_INITIALIZER TypeInfo; // 0x28: 包含该类型的行为定义、权限控制、回调函数等初始化参数
struct _EX_PUSH_LOCK TypeLock; // 0x78: 保护该对象类型元数据访问的自旋锁(如计数和回调列表)
ULONG Key; // 0x7C: 类型唯一标识符,用于调试或查找用途(由内核分配)
struct _LIST_ENTRY CallbackList; // 0x80: 对象回调链表(用于注册类型相关的通知与操作钩子)
};

然而 ObTypeIndexTable 在内核中并不导出,不过我们可以通过一些内核函数例如 ObGetObjectType 函数来定位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//
// 根据对象指针返回其对应的对象类型(POBJECT_TYPE)
//

POBJECT_TYPE
ObGetObjectType (
IN PVOID Object // [in] 对象指针
)
{
POBJECT_HEADER Header;

// 使用 CONTAINING_RECORD 还原 Header 起始地址
Header = CONTAINING_RECORD(Object, OBJECT_HEADER, Body);

// 根据对象头中的 TypeIndex 从 ObTypeIndexTable 中索引对应的 OBJECT_TYPE 结构
return ObTypeIndexTable[Header->TypeIndex];
}

从 Windows 10 64 位开始,OBJECT_HEADER 中的 TypeIndex 被加密了,我们可以直接调用 ObGetObjectType 函数来获取对象对应的 OBJECT_TYPE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
POBJECT_TYPE
ObGetObjectType (
IN PVOID Object // 指向对象正文的指针
)
{
POBJECT_HEADER Header;
UCHAR EncryptedTypeIndex;
UCHAR DecodedIndex;

// 使用 CONTAINING_RECORD 还原 Header 起始地址
Header = CONTAINING_RECORD(Object, OBJECT_HEADER, Body);

// 提取加密后的 TypeIndex
EncryptedTypeIndex = Header->TypeIndex;

// 解密 TypeIndex(使用 ObHeaderCookie 与 header 地址扰动)
DecodedIndex = ObHeaderCookie
^ EncryptedTypeIndex
^ (UCHAR)(((ULONG_PTR)Header >> 8) & 0xFF);

// 返回对应的 OBJECT_TYPE 指针
return ObTypeIndexTable[DecodedIndex];
}

另外如果是判断进程类型的句柄,我们可以直接使用 Windows 导出的 PsProcessType 进行判断。

句柄防降权

句柄降权是保护进程的一个常见方法,这个方法的思路是每隔一段时间就扫描一遍所有进程的私有句柄表,一旦发现有受保护进程的句柄,就修改该句柄的权限

提示

示例代码没有处理受保护进程自身退出的情况,关闭受保护进程可能会导致蓝屏。

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
#include <ntifs.h>
#include <ntddk.h>
#include <windef.h>

extern POBJECT_TYPE *PsProcessType; // 确保引用此类型,用于检查对象类型

// 定义控制定时线程的事件
PKEVENT g_StopEvent = NULL;

/**
* HandleEnumCallback - 句柄枚举回调函数
*
* 该回调函数会在 `ExEnumHandleTable` 遍历每个句柄时被调用。
* 它的功能是检查每个句柄是否指向指定的进程,并直接清除其权限。
*/
BOOLEAN
HandleEnumCallback(
HANDLE Handle,
PHANDLE_TABLE_ENTRY HandleEntry,
PVOID Context
)
{
// 获取上下文中的目标进程
PEPROCESS targetProcess = (PEPROCESS)Context;
PVOID rawObject = HandleEntry->Object;

// 获取实际对象地址:rawObject 是指向 OBJECT_HEADER 的指针
POBJECT_HEADER header = (POBJECT_HEADER)((PUCHAR)rawObject & ~7); // 按 8 字节对齐
PEPROCESS actualObject = (PEPROCESS)((PUCHAR)header + 0x18); // OBJECT_HEADER 后是对象实际数据

// 直接比较对象地址,判断是否为目标进程
if (actualObject == targetProcess) {
// 直接移除 PROCESS_VM_READ 和 PROCESS_VM_WRITE 权限
HandleEntry->GrantedAccessBits &= ~(PROCESS_VM_READ | PROCESS_VM_WRITE);
DbgPrint("[+] 移除句柄 0x%p 权限,来自进程 PID=%p\n", Handle, PsGetCurrentProcessId());
}

return FALSE; // 继续遍历
}

/**
* ProtectProcessHandle - 隐藏指定 PID 对应的进程句柄并清除权限
*
* 参数:
* @targetProcess - 目标进程对象
*
* 功能:
* - 枚举所有进程句柄,查找指向受保护进程的句柄;
* - 清除句柄的 `PROCESS_VM_READ` 和 `PROCESS_VM_WRITE` 权限,防止其他进程读取或写入。
*/
VOID
ProtectProcessHandle(
PEPROCESS targetProcess
)
{
// 枚举所有进程句柄
for (ULONG_PTR pid = 4; pid < 0x100000; pid += 4) {
PEPROCESS process = NULL;
if (!NT_SUCCESS(PsLookupProcessByProcessId((HANDLE)pid, &process)))
continue;

// 获取进程句柄表,0xF4 偏移
PHANDLE_TABLE handleTable = *(PHANDLE_TABLE*)((PUCHAR)process + 0xF4);
if (handleTable) {
ExEnumHandleTable(
handleTable,
HandleEnumCallback,
(PVOID)targetProcess, // 直接传递目标进程
NULL
);
}

ObDereferenceObject(process);
}
}

/**
* ThreadFunction - 定时线程函数
*
* 该函数将定时调用 ProtectProcessHandleFromVmAccess 来保护指定的进程。
*/
VOID
ThreadFunction(
PVOID StartContext
)
{
PEPROCESS targetProcess = (PEPROCESS)StartContext;

while (TRUE) {
// 如果接收到停止信号,则退出
if (KeWaitForSingleObject(g_StopEvent, Executive, KernelMode, FALSE, NULL) == STATUS_WAIT_0) {
DbgPrint("[+] 驱动停止,退出保护线程\n");
return;
}

// 定时每隔 5 秒钟检查并保护进程句柄
ProtectProcessHandle(targetProcess);
KeDelayExecutionThread(KernelMode, FALSE, &((LARGE_INTEGER) { -5 * 1000 * 1000 })); // 5 秒
}
}

/**
* StartProtectingProcess - 启动保护进程的线程
*
* 通过内核线程定时执行句柄保护
*/
VOID
StartProtectingProcess(
PEPROCESS targetProcess
)
{
PKEVENT event;
PETHREAD thread;

// 创建事件,用于停止定时线程
g_StopEvent = ExAllocatePool(NonPagedPool, sizeof(KEVENT)); // 去掉了 WithTag
if (g_StopEvent == NULL) {
DbgPrint("[-] 无法创建停止事件\n");
return;
}
KeInitializeEvent(g_StopEvent, NotificationEvent, FALSE);

// 创建一个线程用于定时执行句柄保护
PsCreateSystemThread(
&thread,
THREAD_ALL_ACCESS,
NULL,
NULL,
NULL,
(PKSTART_ROUTINE)ThreadFunction,
(PVOID)targetProcess
);
}

/**
* UnloadDriver - 驱动卸载时的清理工作
*
* 停止线程并清理资源。
*/
VOID
UnloadDriver(
PDRIVER_OBJECT DriverObject
)
{
// 发送停止信号
if (g_StopEvent) {
KeSetEvent(g_StopEvent, 0, FALSE);
ExFreePool(g_StopEvent); // 去掉了 WithTag
}

DbgPrint("[+] 驱动卸载\n");
}

NTSTATUS
DriverEntry(
PDRIVER_OBJECT DriverObject,
PUNICODE_STRING RegistryPath
)
{
UNREFERENCED_PARAMETER(RegistryPath);

// 为卸载函数注册
DriverObject->DriverUnload = UnloadDriver;

// 示例保护进程 PID 为 1234
PEPROCESS targetProcess = NULL;
if (!NT_SUCCESS(PsLookupProcessByProcessId((HANDLE)1234, &targetProcess))) {
DbgPrint("[-] 无法找到进程\n");
return STATUS_UNSUCCESSFUL;
}

// 启动保护进程
StartProtectingProcess(targetProcess);

return STATUS_SUCCESS;
}

句柄表断链

通常这种降权手段是通过遍历私有句柄表链表实现的,即遍历 HANDLE_TABLEHandleTableList。如果我们将自身进程的私有句柄表从句柄表链表中断链则可以避免句柄被降权。不过这种方法会导致蓝屏,好在蓝屏需要很长一段时间才会触发。

伪造进程

另一种思路是创建一个”影子进程”,欺骗保护机制,同时保持对目标进程的完全访问权限。其对抗过程可分为以下关键步骤:

  1. 创建伪造进程对象:分配内存并复制目标进程的完整 OBJECT_HEADEREPROCESS 结构。修改关键字段:例如将 PID 设为 0,清空进程名称,进一步对抗私有句柄表扫描。
  2. 复制页表:复制目标进程的 CR3 页表到新的物理内存,更新伪造进程对象的 CR3 指向新复制的页表
  3. 重定向句柄:遍历所有进程的句柄表,将指向被保护的目标进程的句柄重定向到伪造对象,保留句柄表项的低 3 位标志位并恢复完整访问权限(PROCESS_ALL_ACCESS)。
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
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
#include <ntifs.h>
#include <ntddk.h>

// 定义关键偏移量(根据Windows版本调整)
#define EPROCESS_UNIQUE_PROCESS_ID_OFFSET 0x140 // UniqueProcessId 偏移
#define EPROCESS_IMAGE_FILE_NAME_OFFSET 0x16C // ImageFileName 偏移
#define EPROCESS_DIRECTORY_TABLE_BASE_OFFSET 0x18 // DirectoryTableBase 偏移
#define OBJECT_HEADER_BODY_OFFSET 0x18 // 对象头到对象体的偏移

// 全局变量
PEPROCESS g_targetProcess = NULL; // 目标进程真实EPROCESS
PVOID g_fakeHeader = NULL; // 伪造的OBJECT_HEADER(包含完整对象头)
PHYSICAL_ADDRESS g_fakeCr3 = { 0 }; // 伪造的CR3物理地址
PVOID g_fakePageTable = NULL; // 伪造的页表内存
ULONG g_protectedPid = 1234; // 受保护进程PID

/**
* 分配物理内存
*/
PVOID AllocatePhysicalMemory(SIZE_T size, PHYSICAL_ADDRESS* physicalAddress)
{
PVOID virtualAddress = MmAllocateContiguousMemory(
size,
{ MAXULONG_PTR } // HighestAcceptableAddress
);

if (virtualAddress) {
*physicalAddress = MmGetPhysicalAddress(virtualAddress);
}

return virtualAddress;
}

/**
* 获取当前分页模式下的页表大小
*/
SIZE_T GetPageTableSize()
{
// 默认情况下使用标准页表大小
SIZE_T tableSize = PAGE_SIZE;

// 检查是否启用PAE
if (IsProcessorFeaturePresent(PF_PAE_ENABLED)) {
// PAE模式下CR3指向PDPT(4个8字节条目)
tableSize = 32; // 4 * 8 bytes
}

return tableSize;
}

/**
* 复制页表内容
*/
NTSTATUS CopyPageTable(PHYSICAL_ADDRESS sourceCr3, PVOID* destination, PHYSICAL_ADDRESS* destCr3)
{
// 获取当前分页模式下的页表大小
SIZE_T tableSize = GetPageTableSize();

// 分配新页表(确保分配完整的页面)
PVOID newPageTable = AllocatePhysicalMemory(PAGE_SIZE, destCr3);
if (!newPageTable) {
return STATUS_INSUFFICIENT_RESOURCES;
}

// 映射源页表到系统空间
SIZE_T mapSize = (sourceCr3.LowPart & 0xFFF) + tableSize > PAGE_SIZE ?
PAGE_SIZE : tableSize;

PVOID sourcePageTable = MmMapIoSpace(sourceCr3, mapSize, MmNonCached);
if (!sourcePageTable) {
MmFreeContiguousMemory(newPageTable);
return STATUS_UNSUCCESSFUL;
}

// 复制页表内容
RtlCopyMemory(newPageTable, sourcePageTable, tableSize);

// 解除映射
MmUnmapIoSpace(sourcePageTable, mapSize);

*destination = newPageTable;
return STATUS_SUCCESS;
}

/**
* 伪造进程对象(包含完整OBJECT_HEADER)
*/
NTSTATUS CreateFakeProcessObject()
{
NTSTATUS status = STATUS_SUCCESS;

// 计算目标进程OBJECT_HEADER地址
POBJECT_HEADER targetHeader = (POBJECT_HEADER)((PUCHAR)g_targetProcess - OBJECT_HEADER_BODY_OFFSET);

// 分配伪造的OBJECT_HEADER
g_fakeHeader = ExAllocatePool(NonPagedPool, PAGE_SIZE);
if (!g_fakeHeader) {
return STATUS_INSUFFICIENT_RESOURCES;
}

// 复制完整OBJECT_HEADER和EPROCESS内容
RtlCopyMemory(g_fakeHeader, targetHeader, PAGE_SIZE);

// 计算伪造的EPROCESS地址
PEPROCESS fakeProcess = (PEPROCESS)((PUCHAR)g_fakeHeader + OBJECT_HEADER_BODY_OFFSET);

// 修改PID为0(无效PID)
*(HANDLE*)((PUCHAR)fakeProcess + EPROCESS_UNIQUE_PROCESS_ID_OFFSET) = 0;

// 清空进程名称
PUCHAR imageFileName = (PUCHAR)fakeProcess + EPROCESS_IMAGE_FILE_NAME_OFFSET;
RtlZeroMemory(imageFileName, 15);

// 获取原始CR3(直接从DirectoryTableBase读取物理地址)
PHYSICAL_ADDRESS originalCr3;
originalCr3.QuadPart = *(ULONG_PTR*)((PUCHAR)g_targetProcess + EPROCESS_DIRECTORY_TABLE_BASE_OFFSET);

// 复制页表
status = CopyPageTable(originalCr3, &g_fakePageTable, &g_fakeCr3);
if (!NT_SUCCESS(status)) {
ExFreePool(g_fakeHeader);
g_fakeHeader = NULL;
return status;
}

// 设置伪造的CR3
*(ULONG_PTR*)((PUCHAR)fakeProcess + EPROCESS_DIRECTORY_TABLE_BASE_OFFSET) = g_fakeCr3.QuadPart;

return status;
}

/**
* 重定向指定进程的句柄
*/
VOID RedirectProcessHandles(PEPROCESS process)
{
// 获取进程句柄表(偏移0xF4,根据Windows版本调整)
PHANDLE_TABLE handleTable = *(PHANDLE_TABLE*)((PUCHAR)process + 0xF4);
if (!handleTable) {
return;
}

// 枚举并重定向句柄
ExEnumHandleTable(
handleTable,
[](HANDLE, PHANDLE_TABLE_ENTRY HandleEntry, PVOID) -> BOOLEAN {
PVOID object = HandleEntry->Object;
POBJECT_HEADER header = (POBJECT_HEADER)((ULONG_PTR)object & ~7);
PVOID body = (PUCHAR)header + OBJECT_HEADER_BODY_OFFSET;

// 检查是否指向目标进程
if (body == g_targetProcess) {
// 构造新的句柄对象指针(保留低3位标志)
ULONG_PTR fakeObject = (ULONG_PTR)g_fakeHeader;
fakeObject |= (ULONG_PTR)object & 7;

// 重定向句柄到伪造对象
HandleEntry->Object = (PVOID)fakeObject;

// 恢复必要权限(确保攻击者进程可以访问)
HandleEntry->GrantedAccessBits |= PROCESS_ALL_ACCESS;

DbgPrint("[+] 重定向句柄到伪造对象 (PID=%d)\n",
PsGetProcessId(PsGetCurrentProcess()));
}
return TRUE; // 继续枚举
},
NULL,
NULL
);
}

/**
* 仅重定向特定进程的句柄
*/
VOID RedirectSpecificProcessHandles()
{
// 重定向所有非攻击者进程
for (ULONG_PTR pid = 4; pid < 0x100000; pid += 4) {
PEPROCESS process = NULL;
if (!NT_SUCCESS(PsLookupProcessByProcessId((HANDLE)pid, &process))) {
continue;
}

RedirectProcessHandles(process);
ObDereferenceObject(process);
}
}

/**
* 清理资源
*/
VOID CleanupFakeProcess()
{
if (g_fakePageTable) {
MmFreeContiguousMemory(g_fakePageTable);
g_fakePageTable = NULL;
}

if (g_fakeHeader) {
ExFreePool(g_fakeHeader);
g_fakeHeader = NULL;
}
}

/**
* 驱动卸载函数
*/
VOID UnloadDriver(PDRIVER_OBJECT DriverObject)
{
UNREFERENCED_PARAMETER(DriverObject);
CleanupFakeProcess();
DbgPrint("[+] 驱动卸载完成\n");
}

/**
* 驱动入口函数
*/
NTSTATUS DriverEntry(
PDRIVER_OBJECT DriverObject,
PUNICODE_STRING RegistryPath
)
{
UNREFERENCED_PARAMETER(RegistryPath);
DriverObject->DriverUnload = UnloadDriver;

// 获取目标进程
NTSTATUS status = PsLookupProcessByProcessId((HANDLE)g_protectedPid, &g_targetProcess);
if (!NT_SUCCESS(status)) {
DbgPrint("[-] 无法找到目标进程 (PID=%d)\n", g_protectedPid);
return status;
}

// 创建伪造的进程对象
status = CreateFakeProcessObject();
if (!NT_SUCCESS(status)) {
DbgPrint("[-] 创建伪造进程对象失败: 0x%X\n", status);
ObDereferenceObject(g_targetProcess);
return status;
}

// 重定向非攻击者进程的句柄
RedirectSpecificProcessHandles();

DbgPrint("[+] 进程保护已启用! 真实进程: 0x%p, 伪造对象: 0x%p\n",
g_targetProcess, g_fakeHeader);

return STATUS_SUCCESS;
}
  • Title: windows 句柄表
  • Author: sky123
  • Created at : 2022-09-28 11:45:14
  • Updated at : 2025-07-04 15:56:32
  • Link: https://skyi23.github.io/2022/09/28/windows 句柄表/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments