深入研究一下线程调度,由于篇幅较多,分章节分析,第二篇。

进程挂靠

一个进程可以包含多个线程,线程结构体中会指向自己所属的进程。切换到这个线程的时候,会将对应的 cr3 切换到该进程的页目录基址,那么这个线程就可以访问这个进程的所有资源了。

前面逆向的时候看到过,在切换 cr3 的时候,是拿到了 KTHREAD.ApcState.Process,而并不是 KTHREAD.Process,这个因为没学 APC 暂时保留一些疑问。一个线程在切换时改成其它进程的 CR3 值就称为进程挂靠

进程挂靠在 Windows 内核层有一个关键 API,叫 KeStackAttachProcess,由于大量用到了 APC 的知识,因此选择学了 APC 之后再分析,mark一下,有空回来更新传送门(现在还是空的)

跨进程读写

一个进程肯定不能够直接读写另一个进程,因为它们页目录基址都不同,不会正常情况下不会有共享的物理页,但是通常来说,内核空间每个进程都是共享的,所以跨进程读写可以这么操作:

假设 A 进程要读取 B 进程的内存,那么先挂靠到 B 进程,从对应的内存读取数据到内核区。再挂靠回 A 进程,将内存写入 A 进程的用户空间。

跨进程读的视图如下所示

跨进程写的试图如下所示

NtReadVirtualMemory

代码分析

下面来分析 NtReadVirtualMemory 函数。

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
NTSTATUS __stdcall NtReadVirtualMemory(
HANDLE ProcessHandle,
PVOID BaseAddress,
PVOID Buffer,
SIZE_T NumberOfBytesToRead,
PSIZE_T NumberOfBytesRead)
{
_KTHREAD *CurrentThread; // edi
PSIZE_T NumberOfBytesReadPtr; // ebx
PSIZE_T v7; // ecx
int v9; // [esp+14h] [ebp-24h] BYREF
MEMORY_CACHING_TYPE Object; // [esp+18h] [ebp-20h] BYREF
DEVICE_RELATION_TYPE AccessMode; // [esp+1Ch] [ebp-1Ch]
int v12; // [esp+34h] [ebp-4h]
PSIZE_T NumberOfBytesReada; // [esp+50h] [ebp+18h]

CurrentThread = KeGetCurrentThread();
LOBYTE(AccessMode) = CurrentThread->PreviousMode;
if ( (_BYTE)AccessMode )
{
if ( (char *)BaseAddress + NumberOfBytesToRead < BaseAddress
|| (char *)Buffer + NumberOfBytesToRead < Buffer
|| (char *)BaseAddress + NumberOfBytesToRead > MmHighestUserAddress
|| (char *)Buffer + NumberOfBytesToRead > MmHighestUserAddress )
{
return 0xC0000005;
}
NumberOfBytesReadPtr = NumberOfBytesRead;
if ( NumberOfBytesRead )
{
v12 = 0;
v7 = NumberOfBytesRead;
if ( (unsigned int)NumberOfBytesRead >= MmUserProbeAddress )
v7 = (PSIZE_T)MmUserProbeAddress;
*v7 = *v7;
v12 = -2;
}
}
else
{
NumberOfBytesReadPtr = NumberOfBytesRead;
}
v9 = 0;
NumberOfBytesReada = 0;
if ( NumberOfBytesToRead )
{
NumberOfBytesReada = (PSIZE_T)ObReferenceObjectByHandle(
ProcessHandle,
0x10u,
(POBJECT_TYPE)PsProcessType,
AccessMode,
(PVOID *)&Object,
0);
if ( !NumberOfBytesReada )
{
NumberOfBytesReada = (PSIZE_T)MmCopyVirtualMemory(
(struct _KPROCESS *)Object,
(char *)BaseAddress,
CurrentThread->ApcState.Process,
Buffer,
NumberOfBytesToRead,
AccessMode,
(SIZE_T *)&v9);
ObfDereferenceObject((PVOID)Object);
}
}
if ( NumberOfBytesReadPtr )
{
*NumberOfBytesReadPtr = v9;
v12 = -2;
}
return (NTSTATUS)NumberOfBytesReada;
}

前面做一个基本的判断,如果先前模式非 0,也就是说进来的时候不是 CPU 等级不为 R0,那么判断读取的虚拟内存地址是否在用户空间,如果不在就说明 R3 调用的接口试图读取内核的地址,那么直接拒绝返回 0xC0000005 即可。

做完一系列检查之后会调用 MmCopyVirtualMemory,下面来分析这个函数

MmCopyVirtualMemory

代码分析

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
int __stdcall MmCopyVirtualMemory(
struct _KPROCESS *Target_Process,
char *src,
struct _KPROCESS *Origin_Process,
volatile void *dest,
SIZE_T Length,
DEVICE_RELATION_TYPE AccessMode,
SIZE_T *NumberOfBytesCopy)
{
SIZE_T InitialSize; // edi
int flag_1; // ecx
_KTHREAD *v9; // edi
int flag_1_copy; // edi
char v12; // [esp+10h] [ebp-2D4h] BYREF
struct _MDL MemoryDescriptorList; // [esp+210h] [ebp-D4h] BYREF
struct _KAPC_STATE ApcState; // [esp+268h] [ebp-7Ch] BYREF
int v15; // [esp+290h] [ebp-54h]
_KTHREAD *CurrentThread; // [esp+294h] [ebp-50h]
int v17; // [esp+298h] [ebp-4Ch]
int v18; // [esp+29Ch] [ebp-48h]
PMDL p_MemoryDescriptorList; // [esp+2A0h] [ebp-44h]
void *DestPtr; // [esp+2A4h] [ebp-40h]
SIZE_T restLength; // [esp+2ACh] [ebp-38h]
PVOID mdlAddress; // [esp+2B0h] [ebp-34h]
unsigned __int8 v23; // [esp+2B7h] [ebp-2Dh]
void *Src; // [esp+2B8h] [ebp-2Ch]
PVOID P; // [esp+2BCh] [ebp-28h]
size_t MaxCount; // [esp+2C0h] [ebp-24h]
unsigned int flag; // [esp+2C4h] [ebp-20h]
unsigned int v28; // [esp+2E0h] [ebp-4h]

if ( !Length )
return 0;
*NumberOfBytesCopy = 0;
flag = 2;
Src = src;
DestPtr = (void *)dest;
restLength = Length;
p_MemoryDescriptorList = &MemoryDescriptorList;
CurrentThread = KeGetCurrentThread();
v17 = 0;
P = 0;
v15 = 0;
v18 = 0;
while ( 2 )
{
if ( Length >= 0x200 && (flag & 2) != 0 )
{
InitialSize = 0xE000;
if ( Length <= 0xE000 )
InitialSize = Length;
}
else
{
flag &= ~2u;
InitialSize = 0x10000;
if ( Length <= 0x10000 )
InitialSize = Length;
if ( Length <= 0x200 )
{
LABEL_12:
P = &v12;
}
else
{
while ( 1 )
{
P = ExAllocatePoolWithTag(PagedPool, InitialSize, 0x77526D4Du);
if ( P )
break;
InitialSize >>= 1;
if ( InitialSize <= 0x200 )
goto LABEL_12;
}
flag |= 1u;
}
}
for ( MaxCount = InitialSize; ; DestPtr = (char *)DestPtr + MaxCount )
{
if ( !restLength )
{
if ( (flag & 1) != 0 )
ExFreePoolWithTag(P, 0);
*NumberOfBytesCopy = Length;
return 0;
}
if ( restLength < MaxCount )
MaxCount = restLength;
KeStackAttachProcess(Target_Process, &ApcState);
mdlAddress = 0;
if ( Src == src && (_BYTE)AccessMode )
{
v28 = 0;
if ( (unsigned int)&src[Length] > MmUserProbeAddress || &src[Length] < src )
*(_BYTE *)MmUserProbeAddress = 0;
v28 = 0xFFFFFFFE;
}
flag_1 = (flag >> 1) & 1;
if ( flag_1 )
{
MemoryDescriptorList.Next = 0;
MemoryDescriptorList.Size = 4
* ((MaxCount >> 12)
+ ((((unsigned __int16)Src & 0xFFF) + (MaxCount & 0xFFF) + 4095) >> 12))
+ 28;
MemoryDescriptorList.MdlFlags = 0;
MemoryDescriptorList.StartVa = (void *)((unsigned int)Src & 0xFFFFF000);
MemoryDescriptorList.ByteOffset = (unsigned __int16)Src & 0xFFF;
MemoryDescriptorList.ByteCount = MaxCount;
}
v9 = CurrentThread;
v23 = _bittestandset((signed __int32 *)&CurrentThread->60, 7u);
v28 = 1;
if ( flag_1 )
MmProbeAndLockPages(&MemoryDescriptorList, AccessMode, IoReadAccess);
else
memcpy(P, Src, MaxCount);
v28 = -2;
if ( !v23 )
v9->MiscFlags &= ~0x80u;
if ( v17 < 0 )
break;
flag_1_copy = (flag >> 1) & 1;
if ( flag_1_copy )
{
mdlAddress = MmMapLockedPagesSpecifyCache(&MemoryDescriptorList, 0, MmCached, 0, 0, 0x20u);
if ( !mdlAddress )
{
MmUnlockPages(&MemoryDescriptorList);
goto LABEL_34;
}
}
KeUnstackDetachProcess(&ApcState);
KeStackAttachProcess(Origin_Process, &ApcState);
if ( Src == src && (_BYTE)AccessMode )
{
v28 = 2;
ProbeForWrite(dest, Length, 1u);
v28 = -2;
}
v28 = 3;
if ( flag_1_copy )
memcpy(DestPtr, mdlAddress, MaxCount);
else
memcpy(DestPtr, P, MaxCount);
v28 = -2;
KeUnstackDetachProcess(&ApcState);
if ( flag_1_copy )
{
MmUnmapLockedPages(mdlAddress, &MemoryDescriptorList);
MmUnlockPages(&MemoryDescriptorList);
}
restLength -= MaxCount;
Src = (char *)Src + MaxCount;
}
if ( (flag & 2) != 0 )
{
LABEL_34:
flag &= ~2u;
KeUnstackDetachProcess(&ApcState);
continue;
}
break;
}
*NumberOfBytesCopy = Length - restLength;
KeUnstackDetachProcess(&ApcState);
if ( (flag & 1) != 0 )
ExFreePoolWithTag(P, 0);
return 0x8000000D;
}

乍一看逻辑根本没法看,感觉最关键的来源于 flag 参数,交叉一下可以大概看出

只对最低位(第零位)和第一位做了判断,并且第一位只在最开始被赋值了。

开始去设置每次循环拷贝的字节数,大约是 min(Length,0xE000),如果flag 第一位为0 或者是 Length<0x200,则设置为 min(Length,0x10000)

else 分支里面 Length<=0x200 则直接用栈分配内存,超过则用分页池分配内存,从设置的大小(InitialSize)开始分配,若分配失败则每次尝试 /2,直到 0x200 为止再次转而用栈中转。这里可以看出,flag 的最低位是判断是否成功分配了分页内存,以便于出错的时候判断释放。

可以看出,如果开始就走了 if 分支,那么线程挂靠之后,会使用 MDL 锁住对应进程的虚拟内存并将它转化为物理页(通过调用 API:MmProbeAndLockPages)。如果顺利,则使用 MmProbeAndLockPages 再将对应物理页映射到虚拟页便于拷贝。如果发现无法映射,则直接将 flag 的第一位清零,所以这一位可以认为是判断能否使用 MDL 读取内存。

最后,直接 memcpy 拷贝内存即可,当然一些善后工作也要做好。

如果发现 MDL 失败了,则程序会尝试分配分页池内存,然后挂靠目标进程将内存直接拷贝到分页池,再挂靠回原进程将分页池的内存写入源进程。

所以 MmCopyVirtualMemory 函数优先使用 MDL 的方式拷贝内存,如果失败则直接在内核空间分配内存拷贝。MDL 拷贝的速度会比较快,因为事实上只进行了一次拷贝,从目标进程拷贝出来的过程只用到了挂物理页,而采用内存池分配内存拷贝则需要多进行一次拷贝,速度相对来说会更慢。

总结

  1. 初始化阶段
    • 如果拷贝长度 Length 为 0,直接返回成功。
    • 设置 flag,用于控制流程判断:
      • 第一位(flag & 1):是否分配了分页池内存。
      • 第二位(flag & 2):是否尝试使用 MDL。
    • 计算单次拷贝的最大长度(InitialSize),规则如下:
      • 如果 Length >= 0x200 且可以尝试 MDL(flag & 2),初始大小为 min(Length, 0xE000)
      • 否则,设置为 min(Length, 0x10000)
  2. 内存分配策略
    • 优先选择栈内存(小于 0x200 时)。
    • 对较大的内存使用分页池分配(ExAllocatePoolWithTag),如果失败则逐步减小分配大小(/2),直至退回栈内存。
  3. MDL 拷贝流程
    • 尝试挂靠目标进程上下文(KeStackAttachProcess)。
    • 调用 MmProbeAndLockPages 锁定虚拟页并转为物理页。
    • 成功后,利用 MmMapLockedPagesSpecifyCache 映射到内核虚拟地址,并完成拷贝。
    • 如果 MDL 操作失败,清除第二位标志(flag & 2),切换为分页池拷贝。
  4. 分页池拷贝流程
    • 挂靠目标进程,将目标内存数据拷贝到分页池内存中。
    • 挂靠源进程,从分页池将数据写入到目标位置。
  5. 清理和退出
    • 每次循环后更新剩余长度 restLength,直到完成或发生错误。
    • 根据 flag 判断是否需要释放分页池内存(ExFreePoolWithTag)。
    • 成功返回 0,失败返回特定错误码(如 0x8000000D)。

跨进程写的代码就不分析了,大概率也是同理可得。

进程创建分析

咕咕咕~

参考文献