[部分翻译] 通过 PTE 欺骗以达到近乎完美的注入效果

Making The Perfect Injector: Abusing Windows Address Sanitization And CoW

我的目标是在这篇博文结尾的时候,制作出一个与众不同的注入器:让你的dll无法被用户模式的调试器调试,让你的内存页对NtQueryVirtualMemoryNtReadVirtualMemory不可见,最后,你的执行代码在目标进程甚至不会有一个句柄。在实现这些操作的同时,我希望它不会触发 patchguard, 并且在目标进程运行时没有驱动运行。

这个目标看起来似乎很蠢,但其实它非常简单,因为windows会帮助我们。

(源码可以在博文底部找到)

0x1:利用Windows地址过滤

当我们把ntoskrnl.exe拖入IDA的时候,我们可以注意一个地址检查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
__int64 __usercall MiReadWriteVirtualMemory@<rax>(ULONG_PTR BugCheckParameter1@<rcx>, unsigned __int64 a2@<rdx>, unsigned __int64 a3@<r8>, __int64 a4@<r9>, __int64 a5, int a6)
{
...
if ( v10 < a3 || v9 > 0x7FFFFFFEFFFFi64 || v10 > 0x7FFFFFFEFFFFi64 )
return 0xC0000005i64;
...
}
__int64 __fastcall MmQueryVirtualMemory(__int64 a1, unsigned __int64 a2, __int64 a3, unsigned __int64 a4, unsigned __int64 a5, unsigned __int64 *a6)
{
...
if ( v12 > 0x7FFFFFFEFFFFi64 )
return 0xC000000Di64;
...
}

有趣的是,操作系统使用硬编码的方式来确保内核内存不会泄露,而这个方式并非CPU用来检查地址是否可以被用户层访问的方式

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
#pragma pack(push, 1)
typedef union CR3_
{
uint64_t value;
struct
{
uint64_t ignored_1 : 3;
uint64_t write_through : 1;
uint64_t cache_disable : 1;
uint64_t ignored_2 : 7;
uint64_t pml4_p : 40;
uint64_t reserved : 12;
};
} PTE_CR3;
typedef union VIRT_ADDR_
{
uint64_t value;
void *pointer;
struct
{
uint64_t offset : 12;
uint64_t pt_index : 9;
uint64_t pd_index : 9;
uint64_t pdpt_index : 9;
uint64_t pml4_index : 9;
uint64_t reserved : 16;
};
} VIRT_ADDR;
typedef uint64_t PHYS_ADDR;
typedef union PML4E_
{
uint64_t value;
struct
{
uint64_t present : 1;
uint64_t rw : 1;
uint64_t user : 1;
uint64_t write_through : 1;
uint64_t cache_disable : 1;
uint64_t accessed : 1;
uint64_t ignored_1 : 1;
uint64_t reserved_1 : 1;
uint64_t ignored_2 : 4;
uint64_t pdpt_p : 40;
uint64_t ignored_3 : 11;
uint64_t xd : 1;
};
} PML4E;
typedef union PDPTE_
{
uint64_t value;
struct
{
uint64_t present : 1;
uint64_t rw : 1;
uint64_t user : 1;
uint64_t write_through : 1;
uint64_t cache_disable : 1;
uint64_t accessed : 1;
uint64_t dirty : 1;
uint64_t page_size : 1;
uint64_t ignored_2 : 4;
uint64_t pd_p : 40;
uint64_t ignored_3 : 11;
uint64_t xd : 1;
};
} PDPTE;
typedef union PDE_
{
uint64_t value;
struct
{
uint64_t present : 1;
uint64_t rw : 1;
uint64_t user : 1;
uint64_t write_through : 1;
uint64_t cache_disable : 1;
uint64_t accessed : 1;
uint64_t dirty : 1;
uint64_t page_size : 1;
uint64_t ignored_2 : 4;
uint64_t pt_p : 40;
uint64_t ignored_3 : 11;
uint64_t xd : 1;
};
} PDE;
typedef union PTE_
{
uint64_t value;
VIRT_ADDR vaddr;
struct
{
uint64_t present : 1;
uint64_t rw : 1;
uint64_t user : 1;
uint64_t write_through : 1;
uint64_t cache_disable : 1;
uint64_t accessed : 1;
uint64_t dirty : 1;
uint64_t pat : 1;
uint64_t global : 1;
uint64_t ignored_1 : 3;
uint64_t page_frame : 40;
uint64_t ignored_3 : 11;
uint64_t xd : 1;
};
} PTE;
#pragma pack(pop)

如上所示,这是CPU翻译逻辑地址时会用到的结构,而这些众多的标志位则表明着这个逻辑地址的一些属性,在这些属性中,user/supervisor来决定这片内存是否能够被用户层访问。所以与人们想的不一样的是,cpu并非使用

1
Va >= 0xFFFFFFFF80000000

来检查内存访问,而是使用

1
Pte->user & Pde->user & Pdpte->user & Pml4e->user

利用操作系统与cpu检测机制不一样这点,我们可以实现内存对所有用户层api不可见,但它又能够执行在用户模式上。操作非常简单:

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
BOOL ExposeKernelMemoryToProcess( MemoryController& Mc, PVOID Memory, SIZE_T Size, uint64_t EProcess )
{
Mc.AttachTo( EProcess );
BOOL Success = TRUE;
Mc.IterPhysRegion( Memory, Size, [ & ] ( PVOID Va, uint64_t Pa, SIZE_T Sz )
{
auto Info = Mc.QueryPageTableInfo( Va );
Info.Pml4e->user = TRUE;
Info.Pdpte->user = TRUE;
Info.Pde->user = TRUE;
if ( !Info.Pde || ( Info.Pte && ( !Info.Pte->present ) ) )
{
Success= TRUE;
}
else
{
if ( Info.Pte )
Info.Pte->user = TRUE;
}
} );
Mc.Detach();
return Success;
}
PVOID Memory = AllocateKernelMemory( CpCtx, KrCtx, Size );
ExposeKernelMemoryToProcess( Controller, Memory, Size, Controller.CurrentEProcess );
ZeroMemory( Memory, Size );

看吧,现在我们有了一个秘密的内存页。

0x2:利用Copy-on-Write

现在我们已经有了一片隐藏的内存了,最后我们需要做的只是让它跑起来。

CoW 是操作系统为了节省内存,让进程共享某些物理内存,直到它的内存数据被改变的时候才重新映射的一种技术

我们知道ntdll.dll是每个进程都会加载的一个链接库,而它的代码段(.text)几乎不会改变,所以为什么要一次又一次地为成百上千个进程分配用于储存它的物理内存呢?

实现非常简单

  1. 如果一个PE文件在其他进程中被映射,并且它的虚拟地址在本进程中并未被使用,那么当本进程映射这个PE文件时,操作系统只会拷贝它的PFN到本进程,并设置这片内存属性为只读。
  2. 如果有指令试图写入这片内存,将会产生一个PageFualt,这个时候操作系统将重新为这片内存分配物理地址空间,并移除只读属性。

这意味着,当我们通过物理内存HOOK一个dll的时候,我们实际上是创建了一个系统范围有效的HOOK。

我们选择HOOK一个常用的API:TlsGetValue

因为PML4E在各个进程间是不同的,因此我们不能直接跳转到我们的隐藏内存,我们可以在KERNEL32.DLL找到一片区域用于执行检查PID的代码:

1
2
3
4
5
6
7
8
9
std::vector<BYTE> PidBasedHook =
{
0x65, 0x48, 0x8B, 0x04, 0x25, 0x30, 0x00, 0x00, 0x00, // mov rax, gs:[0x30]
0x8B, 0x40, 0x40, // mov eax,[rax+0x40] ; pid
0x3D, 0xDD, 0xCC, 0xAB, 0x0A, // cmp eax, TargetPid
0x0F, 0x85, 0x00, 0x00, 0x00, 0x00, // jne 0xAABBCC
0x48, 0xB8, 0xAA, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0x00, 0x00, // mov rax, KernelMemory
0xFF, 0xE0 // jmp rax
};

因为PE始终是0x1000对齐的,所以找到一片35字节的可用区域非常简单。

最后,添加上收尾代码,确保我们的HOOK只执行一次,并且在执行前恢复HOOK。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::vector<BYTE> Prologue =
{
0x00, 0x00, // data
0xF0, 0xFE, 0x05, 0xF8, 0xFF, 0xFF, 0xFF, // lock inc byte ptr [rip-n]
// wait_lock:
0x80, 0x3D, 0xF0, 0xFF, 0xFF, 0xFF, 0x00, // cmp byte ptr [rip-m], 0x0
0xF3, 0x90, // pause
0x74, 0xF5, // je wait_lock
0x48, 0xB8, 0xAA, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0x00, 0x00, // mov rax, 0xAABBCCDDEEAA
// data_sync_lock:
0x0F, 0x0D, 0x08, // prefetchw [rax]
0x81, 0x38, 0xDD, 0xCC, 0xBB, 0xAA, // cmp dword ptr[rax], 0xAABBCCDD
0xF3, 0x90, // pause
0x75, 0xF3, // jne data_sync_lock
0xF0, 0xFE, 0x0D, 0xCF, 0xFF, 0xFF, 0xFF, // lock dec byte ptr [rip-n]
0x75, 0x41, // jnz continue_exec
0x53, // --- start executing DllMain ---

至此,所有的工作都完成了。我们成功注入了并隐藏了dll。通过一个系统范围有效的HOOK来确保在目标进程执行前驱动程序就可以卸载。

源码

省略了一些细枝末节,此文核心是PTE欺骗,至于后面通过COW劫持线程,个人认为实用性不大。