# 线程优先级

三种情况会导致线程切换:

  1. 主动调用 API:KiSwapThread → KiSwapContext → SwapContext
  2. CPU 时间片到期:KiDispatchInterrupt → KiQuantumEnd → SwapContext
  3. 存在备用线程:KiDispatchInterrupt → SwapContext

其中 KiSwapThreadKiQuantumEnd 都通过 KiFindReadyThread 查找下一个要执行的线程。那么 KiFindReadyThread 是根据什么条件选择线程的呢?

# 调度链表

  1. 在 Windows 32 位系统中,共有 32 个双向链表(调度链表,对应优先级 0~31)
  2. 在 Windows 64 位系统中,同样为 32 个双向链表(优先级范围仍为 0~31)
  3. 线程在调度链表中的下标即表示其优先级
1
dd KiDispatcherReadyListHead L50

# 分析 KiFindReadyThread

查找方式:按优先级从高到低查找:31 → 30 → 29 → … → 0。如果级别 31 的链表中有就绪线程,就不再查找更低级别的链表。

优化机制 —— _KiReadySummary

由于每次从 31 开始遍历效率太低,Windows 使用一个 DWORD 类型的全局变量 _KiReadySummary 进行位图标记:

  • 当向某个优先级的调度链表中挂入或摘除线程时,检查该链表是否为空
  • 为空:将 _KiReadySummary 对应位置 0
  • 非空:将对应位置 1

通过 bsr (Bit Scan Reverse)指令,可以快速找到最高非零位,从而定位最高优先级的非空链表。

链表状态判断

  • 链表头的 FlinkBlink 都等于链表头自身的地址 → 链表为空,不存在就绪线程
  • 链表头的 FlinkBlink 相同但不等于链表头地址 → 存在一个就绪线程
  • 链表头的 FlinkBlink 不同 → 存在多个就绪线程

其他要点

  1. 多 CPU 系统中,每个 CPU 可以并行从调度链表中取线程执行。线程可以通过 API SetThreadAffinityMask 绑定到指定 CPU
  2. 若当前 CPU 没有就绪线程,则执行空闲线程_KPRCB.IdleThread
1
2
3
4
nt!_KPRCB
+0x004 CurrentThread : Ptr32 _KTHREAD // 当前线程
+0x008 NextThread : Ptr32 _KTHREAD // 备用线程(就绪)
+0x00c IdleThread : Ptr32 _KTHREAD // 空闲线程

# 分析 KiSwapThread

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.text:0040AB8A @KiSwapThread@0 proc near
...
.text:0040AB8E mov eax, large fs:20h ; KPCR.Prcb(指向 _KPRCB)
.text:0040AB94 mov esi, eax
.text:0040AB96 mov eax, [esi+8] ; NextThread
.text:0040AB99 test eax, eax
.text:0040AB9B mov edi, [esi+4] ; CurrentThread
.text:0040AB9E jnz loc_4109B9 ; 若有备用线程,直接使用

; 没有备用线程,通过 KiFindReadyThread 查找
.text:0040ABAD call @KiFindReadyThread@8
.text:0040ABB2 test eax, eax
.text:0040ABB4 jz loc_4107BE ; 没有就绪线程,跳转到空闲线程

; 找到就绪线程,执行切换
.text:0040ABBB mov ecx, eax
.text:0040ABBD call @KiSwapContext@4
...
.text:0040ABDC @KiSwapThread@0 endp

若不存在就绪线程,则切换到 IdleThread

1
2
3
4
5
6
7
8
9
10
11
12
13
.text:004107BE loc_4107BE:
; 取出空闲线程
.text:004107BE mov eax, [esi+_KPRCB.IdleThread]
.text:004107C1 xor edx, edx
.text:004107C3 inc edx
.text:004107C4 mov ecx, ebx
.text:004107C6 shl edx, cl

; 更新 KiIdleSummary 全局变量
.text:004107C8 or ds:_KiIdleSummary, edx
...
; 获得空闲线程后跳转执行切换
.text:00410802 jnz loc_40ABBA

# 小结

  1. 调度链表的下标即线程优先级,优先级越高越先被调度
  2. CPU 通过遍历调度链表(借助 _KiReadySummary 位图加速)查找就绪线程
  3. 若没有就绪线程,则执行空闲线程( _KPRCB.IdleThread

# 进程挂靠

# 进程与线程的关系

  1. 一个进程可以包含多个线程,至少有一个线程
  2. 进程为线程提供资源 —— 提供 CR3 的值(页目录表基址)
  3. CR3 确定后,线程能访问的物理内存也就确定了。例如,CPU 执行 mov eax, dword ptr ds:[0x12345678] 时,需要通过 CR3 指向的页目录表将线性地址转换为物理地址
  4. 当前 CR3 的值来源于当前进程的 _KPROCESS.DirectoryTableBase (偏移 +0x018

# 进程与线程的关联

ETHREAD 中有两个指向所属进程的指针:

字段 含义 比喻
_ETHREAD.Tcb.ApcState.Process 当前提供 CR3 的进程 “养父母”
_ETHREAD.ThreadsProcess 创建此线程的进程 “亲生父母”

一般情况下,两者指向同一个进程。但如果将 CR3 修改为其他进程的 DirectoryTableBase ,就是所谓的 **“进程挂靠”**。

1
2
3
4
5
6
7
8
mov cr3, A.DirectoryTableBase
mov eax, dword ptr ds:[0x12345678] ; 访问 A 进程的内存

mov cr3, B.DirectoryTableBase
mov eax, dword ptr ds:[0x12345678] ; 访问 B 进程的内存

mov cr3, C.DirectoryTableBase
mov eax, dword ptr ds:[0x12345678] ; 访问 C 进程的内存

问题ETHREAD 中有两个指向进程的指针,线程切换时究竟用哪个提供 CR3?

答案SwapContext 使用 _ETHREAD.Tcb.ApcState.Process (“养父母”)来获取 DirectoryTableBase 并赋值给 CR3。

# 分析 SwapContext 中的进程判断

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
.text:00405932 SwapContext     proc near
...
; 取出当前线程"养父母"进程
.text:004059CD mov eax, [edi+_ETHREAD.Tcb.ApcState.Process]

; 比较源线程和目标线程是否属于同一个进程
.text:004059D0 cmp eax, [esi+_ETHREAD.Tcb.ApcState.Process]
.text:004059D3 mov [edi+_ETHREAD.Tcb.IdleSwapBlock], 0

; 同一进程则跳过 CR3 切换
.text:004059D7 jz short loc_405A19

; 不同进程:取出目标线程的 _KPROCESS
.text:004059D9 mov edi, [esi+_ETHREAD.Tcb.ApcState.Process]
...

; 取出目标进程的 CR3
.text:00405A01 mov eax, [edi+_EPROCESS.Pcb.DirectoryTableBase]
.text:00405A04 mov ebp, [ebx+_KPCR.TSS]

; 更新 TSS 中的 CR3
.text:00405A0A mov [ebp+1Ch], eax

; 切换 CR3
.text:00405A0D mov cr3, eax
...
.text:00405AA1 SwapContext endp

# 分析 NtReadVirtualMemory(进程挂靠的实际应用)

NtReadVirtualMemory 是跨进程读取内存的内核函数,它的实现就依赖于进程挂靠。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PAGE:004B14CA _NtReadVirtualMemory@20 proc near
...
; 传入当前线程的"养父母"进程
PAGE:004B1579 push [edi+_ETHREAD.Tcb.ApcState.Process]

; 要读取的起始地址
PAGE:004B157C push [ebp+BaseAddress]

; 目标进程的 _KPROCESS
PAGE:004B157F push [ebp+Object]

; 调用复制函数
PAGE:004B1582 call _MmCopyVirtualMemory@28
...

MmCopyVirtualMemoryMiDoPoolCopyKeStackAttachProcess

1
2
3
4
5
6
7
PAGE:004AD387 _MiDoPoolCopy@28 proc near
...
; 进程挂靠:将当前线程切换到目标进程的地址空间
PAGE:004AD4A2 push eax ; ApcState
PAGE:004AD4A3 push [ebp+arg_0] ; PROCESS
PAGE:004AD4A6 call _KeStackAttachProcess@8
...

KeStackAttachProcessKiAttachProcess

1
2
3
4
5
6
7
8
9
10
11
12
.text:0041A4C1 _KiAttachProcess@16 proc near
...
; 修改"养父母"为目标进程
.text:0041A4F5 mov [esi+_ETHREAD.Tcb.ApcState.Process], edi
.text:0041A4F8 mov [esi+_ETHREAD.Tcb.ApcState.KernelApcInProgress], 0
.text:0041A4FC mov [esi+_ETHREAD.Tcb.ApcState.KernelApcPending], 0
.text:0041A500 mov [esi+_ETHREAD.Tcb.ApcState.UserApcPending], 0
...

; 调用 KiSwapProcess 切换 CR3
.text:0041A537 call _KiSwapProcess@8
...

KiSwapProcess 完成实际的 CR3 切换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.text:00405B34 _KiSwapProcess@8 proc near
...
; 取出目标进程的 _KPROCESS
.text:00405BB0 mov edx, [esp+arg_0]
.text:00405BB4 xor eax, eax
.text:00405BB6 mov gs, eax

; 取出目标进程的 CR3
.text:00405BB8 mov eax, [edx+_KPROCESS.DirectoryTableBase]

; 更新 TSS 中的 CR3
.text:00405BBB mov [ecx+1Ch], eax

; 切换 CR3
.text:00405BBE mov cr3, eax
...
.text:00405BC9 _KiSwapProcess@8 endp

# 为什么不能只修改 CR3 而不修改 "养父母"?

假设只修改了 CR3 但不修改 ApcState.Process :如果在读取内存之前发生了线程切换,当再次切换回来时, SwapContext 会根据 ApcState.Process (“养父母”)的值重新赋值 CR3,此时 CR3 会变回原来的值,导致读取的是自己进程的内存而非目标进程的内存。

但如果我们自己编写代码,在切换 CR3 后立即用 CLI 屏蔽中断,并且不调用任何可能导致线程切换的 API,就可以不修改 "养父母"—— 因为此时不会发生线程切换。

# 小结

  1. 正常情况下,当前线程使用的 CR3 由 _ETHREAD.Tcb.ApcState.Process 提供,所以 A 进程的线程只能访问 A 的内存
  2. 要让 A 进程的线程访问 B 进程的内存,必须将 CR3 修改为 B 的 DirectoryTableBase ,同时修改 ApcState.Process 指向 B 进程 —— 这就是进程挂靠

# 跨进程读写

跨进程内存操作的本质就是进程挂靠。正常情况下,A 进程的线程只能访问 A 的地址空间;要访问 B 进程的地址空间,就需要修改 CR3。

# 直接修改 CR3 的问题

1
2
3
4
mov cr3, B.DirectoryTableBase          ; 切换到 B 进程
mov eax, dword ptr ds:[0x12345678] ; 读取 B 进程的数据
mov dword ptr ds:[0x00401234], eax ; 写入——但此时仍在 B 的地址空间!
mov cr3, A.DirectoryTableBase ; 切换回 A 进程

问题:读取 B 进程内存后,由于 CR3 还未切换回来, mov dword ptr ds:[0x00401234], eax 写入的仍然是 B 进程的地址 0x00401234 ,而非 A 进程的。

解决方案:利用高 2G 内核空间作为中转。

# 跨进程读(NtReadVirtualMemory 流程)

  1. 将当前线程的 CR3 切换至目标进程的 CR3
  2. 将要读取的数据复制到高 2G 内核空间(所有进程共享)
  3. 将 CR3 切换回原进程
  4. 将数据从高 2G 复制到目标缓冲区

# 跨进程写(NtWriteVirtualMemory 流程)

  1. 将当前线程的数据复制到高 2G 内核空间
  2. 将 CR3 切换至目标进程的 CR3
  3. 将数据从高 2G 复制到目标地址
  4. 将 CR3 切换回原进程

# 总结

每个进程的高 2G 内存(内核空间)对应的物理页几乎是相同的 —— 即内核空间在所有进程中是共享的。正是利用这一特性,操作系统通过高 2G 空间作为 "中转站",在切换 CR3 前后分两步完成数据搬运,从而实现跨进程的内存读写操作。