一、前言
在前面的课程中,我们研究了模拟线程切换的代码,学习了 _KPCR,ETHREAD,EPROCESS 等内核结构体,这些都是为了学习Windows线程切换做的准备。
线程切换是操作系统的核心内容,几乎所有的内核API都会调用切换线程的函数。这次课我们就来逆向 KiSwapContext 和 SwapContext 这两个函数,看看Windows是怎么切换线程的。
我们要带着问题开始逆向:
- SwapContext 有几个参数,分别是什么?
- SwapContext 在哪里实现了线程切换
- 线程切换的时候,会切换CR3吗?切换CR3的条件是什么?
- 中断门提权时,CPU会从TSS得到ESP0和SS0,TSS中存储的一定是当前线程的ESP0和SS0吗?如何做到的?
- FS:[0]在3环指向TEB,但是线程有很多,FS:[0]指向的是哪个线程的TEB,如何做到的?
- 0环的 ExceptionList 在哪里备份的?
- IdleThread是什么?什么时候执行?找到这个函数.
- 如何找到下一个就绪线程?
- 模拟线程切换与Windows线程切换有哪些区别?
其中,问题 7,8 的答案暂时无法解答,因为相关的操作不在这两个函数里,我会在下一篇博客通过分析 KiSwapThread 函数来解答这些问题。
二、分析 KiSwapContext
这个函数调用了 SwapContext,我们通过逆它可以判断出 SwapContext 有几个参数。
KiSwapContext 做的工作是保存旧线程的寄存器到自己的栈顶,更新 KPCR 里的 CurrentThread 属性,然后调用 SwapContext 函数切换线程,SwapContext 返回后就已经完成线程切换的工作了。
所以说 KiSwapContext 函数做的事情其实不多,我们分析它主要是看看 SwapContext 接收了几个参数。
我这里已经分析完了,有3个参数:
ebx: _KPCR
esi: 新线程 _ETHREAD
edi: 旧线程 _ETHREAD
.text:0046A7E4 ; __fastcall KiSwapContext(x)
.text:0046A7E4 @KiSwapContext@4 proc near ; CODE XREF: KiSwapThread()+41↑p
.text:0046A7E4
.text:0046A7E4 var_10 = dword ptr -10h
.text:0046A7E4 var_C = dword ptr -0Ch
.text:0046A7E4 var_8 = dword ptr -8
.text:0046A7E4 var_4 = dword ptr -4
.text:0046A7E4
.text:0046A7E4 sub esp, 10h ; 使用寄存器传参,因此要将使用到的寄存器暂时保存到堆栈中
.text:0046A7E4 ; 这里和 push 是等效的
.text:0046A7E7 mov [esp+10h+var_4], ebx
.text:0046A7EB mov [esp+10h+var_8], esi
.text:0046A7EF mov [esp+10h+var_C], edi
.text:0046A7F3 mov [esp+10h+var_10], ebp ; ebp 没用
.text:0046A7F6 mov ebx, ds:0FFDFF01Ch ; _KPCR.Self
.text:0046A7FC mov esi, ecx ; ecx:新线程的 _ETHREAD
.text:0046A7FE mov edi, [ebx+_KPCR.PrcbData.CurrentThread] ; edi:当前线程的 _ETHREAD
.text:0046A804 mov [ebx+_KPCR.PrcbData.CurrentThread], esi ; 修改 _KPCR,更新当前线程
.text:0046A80A mov cl, [edi+_ETHREAD.Tcb.WaitIrql]
.text:0046A80D call SwapContext ; 参数有4个,均通过寄存器保存
.text:0046A80D ; ebx: _KPCR
.text:0046A80D ; esi: 新线程 _ETHREAD
.text:0046A80D ; edi: 旧线程 _ETHREAD
.text:0046A80D ; cl: 旧线程的 WaitIrql,这个参数用来控制是否执行APC
.text:0046A80D ;
.text:0046A80D ; 调用 SwapContext 后,已经完成了线程切换
.text:0046A80D ; 后面就是新线程从它自己的堆栈里恢复寄存器的值的过程
.text:0046A812 mov ebp, [esp+10h+var_10]
.text:0046A815 mov edi, [esp+10h+var_C]
.text:0046A819 mov esi, [esp+10h+var_8]
.text:0046A81D mov ebx, [esp+10h+var_4]
.text:0046A821 add esp, 10h
.text:0046A824 retn
.text:0046A824 @KiSwapContext@4 endp
三、分析 SwapContext
这个函数是切换线程最终发生的地方,代码也比较长,我也不是每一句都看懂了,所以要跟着问题分析。我最后再贴出完整的注释。
2. SwapContext 在哪里实现了线程切换?
找给 esp 赋值的语句就是了。
mov esp, [esi+_ETHREAD.Tcb.KernelStack] ; 此处是切换线程,切换线程本质是切换堆栈
3. 线程切换的时候,会切换CR3吗?切换CR3的条件是什么?
如果新旧线程属于同一个进程,就不换 cr3,;否则就要换。
判断是否属于同一进程的代码:
mov eax, [edi+_ETHREAD.Tcb.ApcState.Process] ;
; 通常情况下,ApcState.Process 和 _ETHREAD.ThreadsProcess 是同一个
; 但是当A进程调用API访问B进程的内存时,ApcState.Process 存的就是B进程
cmp eax, [esi+_ETHREAD.Tcb.ApcState.Process]
mov [edi+_ETHREAD.Tcb.IdleSwapBlock], 0
jz short loc_46A994 ; 如果是同一个进程内的线程切换,就跳转
;
; 如果不是同一个进程的,那么就要做额外的工作,主要就是切换CR3
切换 cr3 的代码:
loc_46A975: ; 修改 LDT 寄存器
lldt ax
xor eax, eax
mov gs, eax ; gs 寄存器清零
; 这就是 Windows 不使用 gs 的依据
assume gs:GAP
mov eax, [edi+_EPROCESS.Pcb.DirectoryTableBase]
mov ebp, [ebx+_KPCR.TSS]
mov ecx, dword ptr [edi+_EPROCESS.Pcb.IopmOffset]
mov [ebp+TSS.CR3], eax
mov cr3, eax ; 关键步骤:切换 cr3
mov [ebp+TSS.IOMap], cx
jmp short loc_46A994
4. 中断门提权时,CPU会从TSS得到ESP0和SS0,TSS中存储的一定是当前线程的ESP0和SS0吗?如何做到的?
往 _KPCR.TSS 存 ESP0 的代码就在线程切换上面几句,但是并没有存 SS0 的代码,因为所有线程的 SS0 的值是固定不变的,系统启动时已经填到 TSS 里,不需要在这里改了。
.text:0046A940 loc_46A940: ; CODE XREF: SwapContext+11F↓j
.text:0046A940 test dword ptr [eax-1Ch], 20000h ; SegCs & 20000h
.text:0046A940 ; 判断是否是虚拟8086模式,如果不是,直接减掉
.text:0046A940 ; +0x07c V86Es : Uint4B
.text:0046A940 ; +0x080 V86Ds : Uint4B
.text:0046A940 ; +0x084 V86Fs : Uint4B
.text:0046A940 ; +0x088 V86Gs : Uint4B
.text:0046A940 ;
.text:0046A940 ; 如果是,那么就不减
.text:0046A940 ;
.text:0046A940 ; 这样做了之后,eax 就指向了0环栈顶,接下来就会存储到 TSS 里
.text:0046A940 ; 以后这个线程进0环,不论是中断门还是快速调用,都会从 TSS 里获取 ESP0
.text:0046A947 jnz short loc_46A94C
.text:0046A949 sub eax, 10h
.text:0046A94C
.text:0046A94C loc_46A94C: ; CODE XREF: SwapContext+67↑j
.text:0046A94C mov ecx, [ebx+_KPCR.TSS] ;
.text:0046A94C ; ecx 指向 TSS
.text:0046A94C ; TSS 的用途是3环进0环时,要从 TSS 取 SS0 和 ESP0
.text:0046A94F mov [ecx+_KTSS.Esp0], eax ; 更新 TSS 中存储的0环栈顶 ESP0
5. FS:[0]在3环指向TEB,但是线程有很多,FS:[0]指向的是哪个线程的TEB,如何做到的?
loc_46A94C: ;
mov ecx, [ebx+_KPCR.TSS] ; ecx 指向 TSS
; TSS 的用途是3环进0环时,要从 TSS 取 SS0 和 ESP0
mov [ecx+TSS.ESP0], eax ; 更新 TSS 中存储的0环栈顶 ESP0
mov esp, [esi+_ETHREAD.Tcb.KernelStack] ; 此处是切换线程,切换线程本质是切换堆栈
; 将 esp 修改为新线程的栈顶,然后就可以从堆栈里取数据恢复现场了
mov eax, [esi+_ETHREAD.Tcb.Teb]
mov [ebx+_KPCR.NtTib.Self], eax ; 暂时存储 TEB 到 ffdff000
这里把新线程的 TEB 暂存到 ffdff000,在 SwapContext 快结束的地方又取了出来,填充了 GDT表 0x3B 对应那项的基址,因为3环FS的选择子就是 0x3B,所以这样3环才能通过 FS 找到当前线程的 TEB:
loc_46A994: ;
mov eax, [ebx+_KPCR.NtTib.Self] ; 此时 eax 指向了 TEB
mov ecx, [ebx+_KPCR.GDT] ; 假设 GDT表在 0x8003f000
; ecx = 0x8003f000
; 3环 FS = 0x3B
; 所以 FS 在 GDT表里的地址是 0x8003f03B
; 下面的操作是修改 FS 的段描述符,这样3环 FS 就能找到 TEB 了
; ;
mov [ecx+3Ah], ax ; BaseAddress 15:00
shr eax, 10h ; eax 指向 TEB 的地址高16位
mov [ecx+3Ch], al ; BaseAddress 23:16
mov [ecx+3Fh], ah ; BaseAddress 31:24
inc [esi+_ETHREAD.Tcb.ContextSwitches]
inc [ebx+_KPCR.PrcbData.KeContextSwitches]
pop ecx
mov [ebx], ecx
cmp [esi+_ETHREAD.Tcb.ApcState.KernelApcPending], 0
jnz short loc_46A9BD
6. 0环的 ExceptionList 在哪里备份的?
在 SwapContext 开头附近保存的,从 _KPCR 里取出来,存到旧线程的栈顶了。
loc_46A8E8: ;
mov ecx, [ebx+_KPCR.NtTib.ExceptionList] ; 保存本线程切换时的内核seh链表
cmp [ebx+_KPCR.PrcbData.DpcRoutineActive], 0 ; 是否有DPC,有就蓝屏
push ecx
jnz loc_46AA2D
四、完整的逆向注释
KiSwapContext
.text:0046A7E4 ; __fastcall KiSwapContext(x)
.text:0046A7E4 @KiSwapContext@4 proc near ; CODE XREF: KiSwapThread()+41↑p
.text:0046A7E4
.text:0046A7E4 var_10 = dword ptr -10h
.text:0046A7E4 var_C = dword ptr -0Ch
.text:0046A7E4 var_8 = dword ptr -8
.text:0046A7E4 var_4 = dword ptr -4
.text:0046A7E4
.text:0046A7E4 sub esp, 10h ; 使用寄存器传参,因此要将使用到的寄存器暂时保存到堆栈中
.text:0046A7E4 ; 这里和 push 是等效的
.text:0046A7E7 mov [esp+10h+var_4], ebx
.text:0046A7EB mov [esp+10h+var_8], esi
.text:0046A7EF mov [esp+10h+var_C], edi
.text:0046A7F3 mov [esp+10h+var_10], ebp ; ebp 没用
.text:0046A7F6 mov ebx, ds:0FFDFF01Ch ; _KPCR.Self
.text:0046A7FC mov esi, ecx ; ecx:新线程的 _ETHREAD
.text:0046A7FE mov edi, [ebx+_KPCR.PrcbData.CurrentThread] ; edi:当前线程的 _ETHREAD
.text:0046A804 mov [ebx+_KPCR.PrcbData.CurrentThread], esi ; 修改 _KPCR,更新当前线程
.text:0046A80A mov cl, [edi+_ETHREAD.Tcb.WaitIrql]
.text:0046A80D call SwapContext ; 参数有4个,但实际使用的只有3个,均通过寄存器保存
.text:0046A80D ; ebx: _KPCR
.text:0046A80D ; esi: 新线程 _ETHREAD
.text:0046A80D ; edi: 旧线程 _ETHREAD
.text:0046A80D ; cl: 旧线程的 WaitIrql,这个参数没用,一进去 eax 就被覆盖了
.text:0046A80D ;
.text:0046A80D ; 调用 SwapContext 后,已经完成了线程切换
.text:0046A80D ; 后面就是新线程从它自己的堆栈里恢复寄存器的值的过程
.text:0046A812 mov ebp, [esp+10h+var_10]
.text:0046A815 mov edi, [esp+10h+var_C]
.text:0046A819 mov esi, [esp+10h+var_8]
.text:0046A81D mov ebx, [esp+10h+var_4]
.text:0046A821 add esp, 10h
.text:0046A824 retn
.text:0046A824 @KiSwapContext@4 endp
SwapContext
.text:0046A8E0 ; 参数有4个,均通过寄存器保存
.text:0046A8E0 ; ebx: _KPCR
.text:0046A8E0 ; esi: 新线程 _ETHREAD
.text:0046A8E0 ; edi: 旧线程 _ETHREAD
.text:0046A8E0 ; cl: 旧线程的 WaitIrql,貌似用不到,直接覆盖了
.text:0046A8E0
.text:0046A8E0 SwapContext proc near ; CODE XREF: KiUnlockDispatcherDatabase(x)+72↑p
.text:0046A8E0 ; KiSwapContext(x)+29↑p ...
.text:0046A8E0 or cl, cl
.text:0046A8E2 mov es:[esi+_ETHREAD.Tcb.State], 2 ; 修改新线程状态为 2
.text:0046A8E2 ; 1 就绪
.text:0046A8E2 ; 2 运行
.text:0046A8E2 ; 5 等待
.text:0046A8E7 pushf
.text:0046A8E8
.text:0046A8E8 loc_46A8E8: ; CODE XREF: KiIdleLoop()+5A↓j
.text:0046A8E8 mov ecx, [ebx+_KPCR.NtTib.ExceptionList] ;
.text:0046A8E8 ; 保存本线程切换时的内核seh链表
.text:0046A8EA cmp [ebx+_KPCR.PrcbData.DpcRoutineActive], 0 ; 是否有DPC,有就蓝屏
.text:0046A8F1 push ecx
.text:0046A8F2 jnz loc_46AA2D
.text:0046A8F8 cmp ds:_PPerfGlobalGroupMask, 0
.text:0046A8FF jnz loc_46AA04
.text:0046A905
.text:0046A905 loc_46A905: ; CODE XREF: SwapContext+12C↓j
.text:0046A905 ; SwapContext+13D↓j ...
.text:0046A905 mov ebp, cr0 ; cr0 控制寄存器可以判断当前环境是实模式还是保护模式,是否开启分页模式,写保护
.text:0046A908 mov edx, ebp ; edx = ebp = cr0
.text:0046A90A mov cl, [esi+_ETHREAD.Tcb.DebugActive]
.text:0046A90D mov [ebx+_KPCR.DebugActive], cl ; 更新 _KPCR 中当前线程的调试状态位,此时存的是新线程的值
.text:0046A910 cli ; 屏蔽时钟中断
.text:0046A911 mov [edi+_ETHREAD.Tcb.KernelStack], esp
.text:0046A914 mov eax, [esi+_ETHREAD.Tcb.InitialStack]
.text:0046A917 mov ecx, [esi+_ETHREAD.Tcb.StackLimit]
.text:0046A91A sub eax, 210h ; 线程堆栈的前 0x210 字节是浮点寄存器
.text:0046A91A ; 此时 eax 指向 _KTRAP_FRAME.V86Gs
.text:0046A91F mov [ebx+_KPCR.NtTib.StackLimit], ecx
.text:0046A922 mov [ebx+_KPCR.NtTib.StackBase], eax
.text:0046A925 xor ecx, ecx
.text:0046A927 mov cl, [esi+_ETHREAD.Tcb.NpxState]
.text:0046A92A and edx, 0FFFFFFF1h ; 判断 NpxState 是否支持浮点
.text:0046A92A ;
.text:0046A92A ; 根据判断结果决定是否更新 cr0
.text:0046A92D or ecx, edx
.text:0046A92F or ecx, [eax+20Ch]
.text:0046A935 cmp ebp, ecx
.text:0046A937 jnz loc_46A9FC
.text:0046A93D lea ecx, [ecx+0]
.text:0046A940
.text:0046A940 loc_46A940: ; CODE XREF: SwapContext+11F↓j
.text:0046A940 test dword ptr [eax-1Ch], 20000h ; SegCs & 20000h
.text:0046A940 ; 判断是否是虚拟8086模式,如果不是,直接减掉
.text:0046A940 ; +0x07c V86Es : Uint4B
.text:0046A940 ; +0x080 V86Ds : Uint4B
.text:0046A940 ; +0x084 V86Fs : Uint4B
.text:0046A940 ; +0x088 V86Gs : Uint4B
.text:0046A940 ;
.text:0046A940 ; 如果是,那么就不减
.text:0046A940 ;
.text:0046A940 ; 这样做了之后,eax 就指向了0环栈顶,接下来就会存储到 TSS 里
.text:0046A940 ; 以后这个线程进0环,不论是中断门还是快速调用,都会从 TSS 里获取 ESP0
.text:0046A947 jnz short loc_46A94C
.text:0046A949 sub eax, 10h
.text:0046A94C
.text:0046A94C loc_46A94C: ; CODE XREF: SwapContext+67↑j
.text:0046A94C mov ecx, [ebx+_KPCR.TSS] ;
.text:0046A94C ; ecx 指向 TSS
.text:0046A94C ; TSS 的用途是3环进0环时,要从 TSS 取 SS0 和 ESP0
.text:0046A94F mov [ecx+TSS.ESP0], eax ; 更新 TSS 中存储的0环栈顶 ESP0
.text:0046A952 mov esp, [esi+_ETHREAD.Tcb.KernelStack] ; 此处是切换线程,切换线程本质是切换堆栈
.text:0046A952 ; 将 esp 修改为新线程的栈顶,然后就可以从堆栈里取数据恢复现场了
.text:0046A955 mov eax, [esi+_ETHREAD.Tcb.Teb]
.text:0046A958 mov [ebx+_KPCR.NtTib.Self], eax ; 暂时存储 TEB 到 ffdff000
.text:0046A95B sti
.text:0046A95C mov eax, [edi+_ETHREAD.Tcb.ApcState.Process]
.text:0046A95F cmp eax, [esi+_ETHREAD.Tcb.ApcState.Process]
.text:0046A962 mov [edi+_ETHREAD.Tcb.IdleSwapBlock], 0
.text:0046A966 jz short loc_46A994 ; 如果是同一个进程内的线程切换,就跳转
.text:0046A966 ;
.text:0046A966 ; 如果不是同一个进程的,那么就要做额外的工作,主要就是切换CR3
.text:0046A968 mov edi, [esi+_ETHREAD.Tcb.ApcState.Process] ; edi: 新线程所属进程
.text:0046A96B test [edi+_EPROCESS.Pcb.LdtDescriptor.LimitLow], 0FFFFh ; 判断 LDT
.text:0046A971 jnz short loc_46A9CE
.text:0046A973 xor eax, eax
.text:0046A975
.text:0046A975 loc_46A975: ; CODE XREF: SwapContext+117↓j
.text:0046A975 lldt ax ; 修改 LDT 寄存器
.text:0046A978 xor eax, eax
.text:0046A97A mov gs, eax ; gs 寄存器清零
.text:0046A97A ; 这就是 Windows 不使用 gs 的依据
.text:0046A97C assume gs:GAP
.text:0046A97C mov eax, [edi+_EPROCESS.Pcb.DirectoryTableBase]
.text:0046A97F mov ebp, [ebx+_KPCR.TSS]
.text:0046A982 mov ecx, dword ptr [edi+_EPROCESS.Pcb.IopmOffset]
.text:0046A985 mov [ebp+TSS.CR3], eax
.text:0046A988 mov cr3, eax ; 关键步骤:切换 cr3
.text:0046A98B mov [ebp+TSS.IOMap], cx
.text:0046A98F jmp short loc_46A994
.text:0046A98F ; ---------------------------------------------------------------------------
.text:0046A991 align 4
.text:0046A994
.text:0046A994 loc_46A994: ; CODE XREF: SwapContext+86↑j
.text:0046A994 ; SwapContext+AF↑j
.text:0046A994 mov eax, [ebx+_KPCR.NtTib.Self] ;
.text:0046A994 ; 此时 eax 指向了 TEB
.text:0046A997 mov ecx, [ebx+_KPCR.GDT] ; 假设 GDT表在 0x8003f000
.text:0046A997 ; ecx = 0x8003f000
.text:0046A997 ; 3环 FS = 0x3B
.text:0046A997 ; 所以 FS 在 GDT表里的地址是 0x8003f03B
.text:0046A997 ; 下面的操作是修改 FS 的段描述符,这样3环 FS 就能找到 TEB 了
.text:0046A997 ; ;
.text:0046A99A mov [ecx+3Ah], ax ; BaseAddress 15:00
.text:0046A99E shr eax, 10h ; eax 指向 TEB 的地址高16位
.text:0046A9A1 mov [ecx+3Ch], al ; BaseAddress 23:16
.text:0046A9A4 mov [ecx+3Fh], ah ; BaseAddress 31:24
.text:0046A9A7 inc [esi+_ETHREAD.Tcb.ContextSwitches]
.text:0046A9AA inc [ebx+_KPCR.PrcbData.KeContextSwitches]
.text:0046A9B0 pop ecx
.text:0046A9B1 mov [ebx], ecx
.text:0046A9B3 cmp [esi+_ETHREAD.Tcb.ApcState.KernelApcPending], 0
.text:0046A9B7 jnz short loc_46A9BD
.text:0046A9B9 popf
.text:0046A9BA xor eax, eax
.text:0046A9BC retn