进程与线程(五)| 线程优先级、进程挂靠与跨进程读写
# 线程优先级
三种情况会导致线程切换:
- 主动调用 API:KiSwapThread → KiSwapContext → SwapContext
- CPU 时间片到期:KiDispatchInterrupt → KiQuantumEnd → SwapContext
- 存在备用线程:KiDispatchInterrupt → SwapContext
其中 KiSwapThread 和 KiQuantumEnd 都通过 KiFindReadyThread 查找下一个要执行的线程。那么 KiFindReadyThread 是根据什么条件选择线程的呢?
# 调度链表
- 在 Windows 32 位系统中,共有 32 个双向链表(调度链表,对应优先级 0~31)
- 在 Windows 64 位系统中,同样为 32 个双向链表(优先级范围仍为 0~31)
- 线程在调度链表中的下标即表示其优先级
1 | dd KiDispatcherReadyListHead L50 |
# 分析 KiFindReadyThread
查找方式:按优先级从高到低查找:31 → 30 → 29 → … → 0。如果级别 31 的链表中有就绪线程,就不再查找更低级别的链表。
优化机制 —— _KiReadySummary :
由于每次从 31 开始遍历效率太低,Windows 使用一个 DWORD 类型的全局变量 _KiReadySummary 进行位图标记:
- 当向某个优先级的调度链表中挂入或摘除线程时,检查该链表是否为空
- 为空:将
_KiReadySummary对应位置 0 - 非空:将对应位置 1
通过 bsr (Bit Scan Reverse)指令,可以快速找到最高非零位,从而定位最高优先级的非空链表。
链表状态判断:
- 链表头的
Flink和Blink都等于链表头自身的地址 → 链表为空,不存在就绪线程 - 链表头的
Flink和Blink相同但不等于链表头地址 → 存在一个就绪线程 - 链表头的
Flink和Blink不同 → 存在多个就绪线程
其他要点:
- 多 CPU 系统中,每个 CPU 可以并行从调度链表中取线程执行。线程可以通过 API
SetThreadAffinityMask绑定到指定 CPU - 若当前 CPU 没有就绪线程,则执行空闲线程(
_KPRCB.IdleThread)
1 | nt!_KPRCB |
# 分析 KiSwapThread
1 | .text:0040AB8A @KiSwapThread@0 proc near |
若不存在就绪线程,则切换到 IdleThread :
1 | .text:004107BE loc_4107BE: |
# 小结
- 调度链表的下标即线程优先级,优先级越高越先被调度
- CPU 通过遍历调度链表(借助
_KiReadySummary位图加速)查找就绪线程 - 若没有就绪线程,则执行空闲线程(
_KPRCB.IdleThread)
# 进程挂靠
# 进程与线程的关系
- 一个进程可以包含多个线程,至少有一个线程
- 进程为线程提供资源 —— 提供 CR3 的值(页目录表基址)
- CR3 确定后,线程能访问的物理内存也就确定了。例如,CPU 执行
mov eax, dword ptr ds:[0x12345678]时,需要通过 CR3 指向的页目录表将线性地址转换为物理地址 - 当前 CR3 的值来源于当前进程的
_KPROCESS.DirectoryTableBase(偏移+0x018)
# 进程与线程的关联
在 ETHREAD 中有两个指向所属进程的指针:
| 字段 | 含义 | 比喻 |
|---|---|---|
_ETHREAD.Tcb.ApcState.Process |
当前提供 CR3 的进程 | “养父母” |
_ETHREAD.ThreadsProcess |
创建此线程的进程 | “亲生父母” |
一般情况下,两者指向同一个进程。但如果将 CR3 修改为其他进程的 DirectoryTableBase ,就是所谓的 **“进程挂靠”**。
1 | mov cr3, A.DirectoryTableBase |
问题: ETHREAD 中有两个指向进程的指针,线程切换时究竟用哪个提供 CR3?
答案: SwapContext 使用 _ETHREAD.Tcb.ApcState.Process (“养父母”)来获取 DirectoryTableBase 并赋值给 CR3。
# 分析 SwapContext 中的进程判断
1 | .text:00405932 SwapContext proc near |
# 分析 NtReadVirtualMemory(进程挂靠的实际应用)
NtReadVirtualMemory 是跨进程读取内存的内核函数,它的实现就依赖于进程挂靠。
1 | PAGE:004B14CA _NtReadVirtualMemory@20 proc near |
MmCopyVirtualMemory → MiDoPoolCopy → KeStackAttachProcess :
1 | PAGE:004AD387 _MiDoPoolCopy@28 proc near |
KeStackAttachProcess → KiAttachProcess :
1 | .text:0041A4C1 _KiAttachProcess@16 proc near |
KiSwapProcess 完成实际的 CR3 切换:
1 | .text:00405B34 _KiSwapProcess@8 proc near |
# 为什么不能只修改 CR3 而不修改 "养父母"?
假设只修改了 CR3 但不修改 ApcState.Process :如果在读取内存之前发生了线程切换,当再次切换回来时, SwapContext 会根据 ApcState.Process (“养父母”)的值重新赋值 CR3,此时 CR3 会变回原来的值,导致读取的是自己进程的内存而非目标进程的内存。
但如果我们自己编写代码,在切换 CR3 后立即用
CLI屏蔽中断,并且不调用任何可能导致线程切换的 API,就可以不修改 "养父母"—— 因为此时不会发生线程切换。
# 小结
- 正常情况下,当前线程使用的 CR3 由
_ETHREAD.Tcb.ApcState.Process提供,所以 A 进程的线程只能访问 A 的内存 - 要让 A 进程的线程访问 B 进程的内存,必须将 CR3 修改为 B 的
DirectoryTableBase,同时修改ApcState.Process指向 B 进程 —— 这就是进程挂靠
# 跨进程读写
跨进程内存操作的本质就是进程挂靠。正常情况下,A 进程的线程只能访问 A 的地址空间;要访问 B 进程的地址空间,就需要修改 CR3。
# 直接修改 CR3 的问题
1 | mov cr3, B.DirectoryTableBase ; 切换到 B 进程 |
问题:读取 B 进程内存后,由于 CR3 还未切换回来, mov dword ptr ds:[0x00401234], eax 写入的仍然是 B 进程的地址 0x00401234 ,而非 A 进程的。
解决方案:利用高 2G 内核空间作为中转。
# 跨进程读(NtReadVirtualMemory 流程)
- 将当前线程的 CR3 切换至目标进程的 CR3
- 将要读取的数据复制到高 2G 内核空间(所有进程共享)
- 将 CR3 切换回原进程
- 将数据从高 2G 复制到目标缓冲区
# 跨进程写(NtWriteVirtualMemory 流程)
- 将当前线程的数据复制到高 2G 内核空间
- 将 CR3 切换至目标进程的 CR3
- 将数据从高 2G 复制到目标地址
- 将 CR3 切换回原进程
# 总结
每个进程的高 2G 内存(内核空间)对应的物理页几乎是相同的 —— 即内核空间在所有进程中是共享的。正是利用这一特性,操作系统通过高 2G 空间作为 "中转站",在切换 CR3 前后分两步完成数据搬运,从而实现跨进程的内存读写操作。