深入理解进程线程

SD 2022-03-16 09:45:00

进程

进程结构体

在操作系统层面上,进程本质上就是一个结构体,当操作系统想要创建一个进程时,就分配一块内存,填入一个结构体,并为结构体中的每一项填充一些具体值。

而这个结构体,就是EPROCESS。每个进程在0环都有一个对应的EPROCESS,这里和PEB是不同的,PEB是属于3环结构体,

使用dt _EPROCESS查看对应的结构体。首先注意该结构体的第一项,也是一个结构体,名为·KPROCESS·。

1.png

KPROCESS结构体

dt _KPROCESS

2.png

+0x000 Heade : _DISPATCHER_HEADER

表明是一个可等待的对象,比如Mutex互斥体、Event事件等(WaitForSingleObject)。

+0x018 DirectoryTableBase : [2] Uint4B

页目录表的基址,当操作系统要做进程切换的时候,就会将这个值放到Cr3寄存器中,所以Cr3的切换实际上就是进程的切换。

+0x038 KernelTime : Uint4B

+0x03c UserTime : Uint4B

统计信息 记录了一个进程在内核模式/用户模式下所花的时间。

+0x05c Affinity : Uint4B

规定进程里面的所有线程能在哪个CPU上跑,如果值为1,那这个进程的所以线程只能在0号CPU上跑(00000001)

如果值为3,那这个进程的所有线程能在0、1号CPU上跑(000000011)

如果值为4,那这个进程的所有线程能在2号CPU上跑(000000100)

如果值为5,那这个进程的所有线程能在0,2号CPU上跑(000000101)

4个字节共32位 所以最多32核 如果是64位 就最多64核。

如果只有一个CPU 把这个设置为4 那么这个进程就死了。

+0x062 BasePriority : Char

基础优先级或最低优先级 该进程中的所有线程最起码的优先级。

EPROCESS其他成员

+0x070 CreateTime : _LARGE_INTEGER

+0x078 ExitTime : _LARGE_INTEGER

进程的创建/退出时间

+0x084 UniqueProcessId : Ptr32 Void

进程的编号,任务管理器中的PID就是从这里取出来的。

+0x088 ActiveProcessLinks : _LIST_ENTRY

双向链表,所有的活动进程都连接在一起,构成了一个链表。

那么链表总有一个头,全局变量PsActiveProcessHead(八个字节)指向全局链表头。

dd PsActiveProcessHead

4.png

前四个字节指向的是下一个EPROCESS结构,但指向的并不是EPROCESS的首地址,而是每一个进程的 _EPROCESS + 0x88的位置。

3.png

所以当我们要查询下一个进程结构时,需要 -0x88。

比如当前PsActiveProcessHead指向的下一个地址为0x825b98b8

kd> dt _EPROCESS 825b98b8-0x88

可以看到进程名是system。

5.png

6.png

这个链表跟进程隐藏有关,只要我们把想要隐藏进程对应的EPROCESS的链断掉,就可以达到在0环进程隐藏的目的。

+0x090 QuotaUsage : [3] Uint4B

+0x09c QuotaPeak : [3] Uint4B

物理页相关的统计信息,我们说虚拟空间4GB不是说就实际上用了物理内存4GB,而是用的时候才挂物理页。

+0x0a8 CommitCharge : Uint4B

+0x0ac PeakVirtualSize : Uint4B

+0x0b0 VirtualSize : Uint4B

虚拟内存相关的统计信息

+0x11c VadRoot : Ptr32 Void

一颗二叉树,标识0-2G哪些地址被分配了哪些地址没被分配。这项与模块隐藏有关。

+0x0bc DebugPort : Ptr32 Void

+0x0c0 ExceptionPort : Ptr32 Void

调试相关,DebugPort里面也存储了一个结构体,相当于是调试进程和被调式进程之间的一个“桥梁”。一种反调试手段就是不断地将DebugPort清0,实际上即便清0也没有办法阻止调试,聪明的安全人员会自己搭一座“桥梁”。

+0x0c4 ObjectTable : Ptr32 _HANDLE_TABLE

句柄表,同样与反调试有关:比如A进程遍历所有进程的句柄表,如果A进程发现在其他进程的句柄表中发现了我自己EPROCESS的值,说明其他有进程打开我,那么即可断定他是在调试我,直接结束自己。

+0x174 ImageFileName : [16] UChar

进程镜像文件名 最多16个字节。

+0x1a0 ActiveThreads : Uint4B

活动线程的数量

+0x1b0 Peb : Ptr32 _PEB

PEB(Process Environment Block 进程环境块):进程在3环的一个结构体,里面包含了进程的模块列表、是否处于调试状态等信息。

7.png

+0x002 BeingDebugged : UChar

这个值当被调试的时候会置1。简单的对抗就是不断地将BeingDebugged这个值置为0。

8.png

+0x00c Ldr : Ptr32 _PEB_LDR_DATA

_PEB_LDR_DATA是一个结构体。

kd> dt _PEB_LDR_DATA

9.png

其中在这个结构体的+0xc,+0x14和+0x1c三个位置是三个双向链表,存储的是模块相关的信息,与_EPROCESS + 0x88那个位置的链表一样,可以断链来实现模块隐藏。

实验

断链实验

三环通过快照的方式遍历进程都是通过EPROCESS+0x88这个成员的链表来寻找的,我们将通过断链的方式来隐藏notepad.exe进程。

打开一个记事本,通过windbg进行调试。通过全局变量PsActiveProcessHead来获取到进程链。

kd> dd PsActiveProcessHead
8055b158  825b96e8 81aa2a68 00000001 f8ac9c7c
8055b168  00000000 00040001 00000000 8055b174
8055b178  8055b174 00000000 7c920000 00000000
8055b188  00000000 00000000 00000000 00000000
8055b198  8052894c 00000000 00000000 00000000
8055b1a8  81b39b10 81b39b10 00000000 00000000
8055b1b8  00000000 00000000 00000001 f8af1d50
8055b1c8  00000000 00040001 00000000 8055b1d4

通过整理可得到如下关系(每一项值都为各自进程EPROCESS+0x88处)

image-20220205201053395.png

kd> ed 0x81aec948 0x8055b158 
kd> ed 0x8055b158+0x4 0x81aec948 

断链后,notepad.exe进程将被隐藏。

image-20220205201430919.png

可以看到进程中并没有notepad.exe,但记事本确实打开了。重新打开一个notepad.exe,这时为新的EPROCESS,进程列表又重新有了notepad.exe进程的信息。

image-20220205201622594.png

并且被隐藏的notepad.exe进程可以正常使用。

image-20220205202037883.png

这是由于cpu的执行单位是任务,在操作系统层面上就是线程,操作系统是通过线程来决定程序是否执行而并非进程。

debugport清0

通过调试程序调试一个进程,正常情况下是可以进行单步,下断点等操作。

image-20220206113006435.png

通过进程链找到被调试的程序debugview

image-20220206114329477.png

然后清0debugport的值。

kd> ed 0x81aba6e8-0x88+0xbc 0

image-20220206114710389.png

再次F8单步,会报异常

image-20220206122716918.png

image-20220206123020603.png

是无法进行调试的。

线程

线程结构体

与进程类似,在操作系统层面上,线程就是一个结构体。

在该结构体的第一项,也是一个结构体,名为KTHREAD。

kd> dt _ETHREAD

10.png

KTHREAD结构体

11.png

+0x000 Header : _DISPATCHER_HEADER

这个结构体同样表明“可等待”对象,比如Mutex互斥体、Event事件等(WaitForSingleObject)。

+0x018 InitialStack : Ptr32 Void

+0x01c StackLimit : Ptr32 Void

+0x028 KernelStack : Ptr32 Void

与线程0环堆栈有关,当我们通过调用门或者中断门陷阱门提权到0环时,会切换到0环堆栈,这个ESP就是从TSS中取出来的,TSS中的ESP实际上就是从每个线程中的KTHREAD结构体中的上面三项取出来的。

+0x020 Teb : Ptr32 Void

TEB(Thread Environment Block),线程环境块。

大小4KB,位于用户地址空间(三环)。

在三环FS:[0]就指向TEB。(0环时FS执行KPCR)

找到TEB,就可以通过偏移在三环找到peb,从而实现遍历模块等功能。

12.png

+0x02c DebugActive : UChar

如果值为-1 不能使用调试寄存器:Dr0 - Dr7

+0x034 ApcState : _KAPC_STATE

+0x0e8 ApcQueueLock : Uint4B

+0x138 ApcStatePointer : [2] Ptr32 _KAPC_STATE

+0x14c SavedApcState : _KAPC_STATE

APC相关

+0x02d State : UChar

线程状态:就绪、等待还是运行

+0x06c BasePriority : Char

其初始值等于所属进程的BasePriority值(KPROCESS->BasePriority),以后可以通过KeSetBasePriorityThread()函数重新设定

+0x070 WaitBlock : [4] _KWAIT_BLOCK

能够知道该线程在等待哪个对象(WaitForSingleObject)。

+0x0e0 ServiceTable : Ptr32 Void

指向系统服务表基址

+0x134 TrapFrame

进0环时保存环境,一个0环结构体

+0x140 PreviousMode : Char

某些内核函数会判断程序是0环调用还是3环调用的

+0x1b0 ThreadListEntry : _LIST_ENTRY

双向链表 一个进程所有的线程 都挂在一个链表中 挂的就是这个位置

一共有两个这样的链表

image-20220206162526788.png

当我们进行线程断链的时候,我们需要将两条链都断掉。

+0x1ec Cid: _CLIENT_ID

进程ID、线程ID

image-20220207151400784.png

+0x220 ThreadsProcess : Ptr32 _EPROCESS

指向自己所属进程

简单拿DbgView做个实验

image-20220207153857475.png

kd> dt _ETHREAD 824ae810

image-20220207154309242.png

可以看到又指向了DbgView进程的_EPROCESS。

+0x22c ThreadListEntry : _LIST_ENTRY

双向链表 一个进程所有的线程 都挂在一个链表中 挂的就是这个位置

一共有两个这样的链表

线程断链实验

在上次我们通过段进程链实现了进程的隐藏,但程序能正常的运行,那么通过断线程链的方式能不能直接杀死掉进程呢?

通过windbg找到DbgView的EPROCESS信息。

kd> !process 0 0
kd> !process 81b00878

image-20220207185335929.png

可以看到有两个线程。

断链的时候需要把两条链都断下来,直接让KPROCESS+0x50和EPROCESS+0x190两个位置都指向自身

kd> ed 81b00878+50 81b00878+50
kd> ed 81b00878+54 81b00878+54
kd> ed 81b00878+190 81b00878+190
kd> ed 81b00878+194 81b00878+194

再次通过命令查看线程信息:

kd> !process 81b00878

image-20220207190056351.png

发现一个线程也没有了,回到主机后查看,发现DbgView并没有结束,而是正常运行着。

通过任务管理器查看DbgView的线程数为0。

image-20220207190407530.png

这时通过调试程序也不能够attach DbgView

image-20220207190649888.png

也很容易能理解,我们仅仅是隐藏了线程,但并不影响线程的正常运行,也就是说操作系统能够通过其他方式找到线程。

KPCR

进程在内核中对应结构体:EPROCESS

线程在内核中对应结构体:ETHREAD

CPU在内核中也有一个对应的结构体:KPCR

KPCR介绍

1) 当线程进入0环时,FS:[0]指向KPCR(3环时FS:[0] -> TEB)
2) 每个CPU都有一个KPCR结构体(一个核一个)
3) KPCR中存储了CPU本身要用的一些重要数据:GDT、IDT以及线程相关的一些信息
4) KPCR绝大部分的数据都是从当前正在执行线程的ETHREAD结构体中拷贝过来的

kd> dt _KPCR
nt!_KPCR
   +0x000 NtTib            : _NT_TIB
   +0x01c SelfPcr          : Ptr32 _KPCR
   +0x020 Prcb             : Ptr32 _KPRCB
   +0x024 Irql             : UChar
   +0x028 IRR              : Uint4B
   +0x02c IrrActive        : Uint4B
   +0x030 IDR              : Uint4B
   +0x034 KdVersionBlock   : Ptr32 Void
   +0x038 IDT              : Ptr32 _KIDTENTRY
   +0x03c GDT              : Ptr32 _KGDTENTRY
   +0x040 TSS              : Ptr32 _KTSS
   +0x044 MajorVersion     : Uint2B
   +0x046 MinorVersion     : Uint2B
   +0x048 SetMember        : Uint4B
   +0x04c StallScaleFactor : Uint4B
   +0x050 DebugActive      : UChar
   +0x051 Number           : UChar
   +0x052 Spare0           : UChar
   +0x053 SecondLevelCacheAssociativity : UChar
   +0x054 VdmAlert         : Uint4B
   +0x058 KernelReserved   : [14] Uint4B
   +0x090 SecondLevelCacheSize : Uint4B
   +0x094 HalReserved      : [16] Uint4B
   +0x0d4 InterruptMode    : Uint4B
   +0x0d8 Spare1           : UChar
   +0x0dc KernelReserved2  : [17] Uint4B
   +0x120 PrcbData         : _KPRCB

_NT_TIB主要成员

+0x000 ExceptionList : Ptr32_EXCEPTION_REGISTRATION_RECORD
当前线程内核异常链表(SEH)

+0x004 StackBase: Ptr32 Void
+0x008 StackLimit: Ptr32 Void

当前线程内核栈的基址和大小

+0x018 Self : Ptr32 _NT_TIB
指向自己(也就是指向KPCR结构) 这样设计的目的是为了查找方便.

KPCR其他成员

+0x01c SelfPcr: Ptr32 _KPCR

指向自己,方便寻址

+0x020 Prcb : Ptr32 _KPRCB

指向拓展结构体KPRCB

+0x038 IDT: Ptr32 _KIDTENTRY

IDT表基址

+0x03c GDT : Ptr32 _KGDTENTRY

GDT表基址

+0x040 TSS : Ptr32 _KTSS

指针,指向TSS,每个CPU都有一个TSS.

+0x051 Number : UChar

CPU编号:0 1 2 3 4 5。。。

+0x120 PrcbData : _KPRCB

拓展结构体

KPRCB

+0x004 CurrentThread : Ptr32 _KTHREAD(当前线程)

+0x008 NextThread : Ptr32 _KTHREAD(即将切换的下一个线程)

+0x00c IdleThread : Ptr32 _KTHREAD (空闲线程)

空闲线程是指该cpu目前没有。调度线程,线程要么挂起要么就绪,cpu就执行空闲线程(cpu是不会停下来的)

等待链表/调度链表

进程结构体EPROCESS(0x50和0x190)是2个链表,里面圈着当前进程所有的线程。

对进程断链,程序可以正常运行,原因是CPU执行与调度是基于线程的,进程断链只是影响一些遍历系统进程的API,并不会影响程序执行。

对线程断链也是一样的,断链后在Windbg或者OD中无法看到被断掉的线程,但并不影响其执行(仍然再跑)。

33个链表

线程有3种状态:就绪、等待、运行

正在运行中的线程存储在KPCR中,就绪和等待的线程全在另外的33个链表中。

一个等待链表,32个就绪链表:

这些链表都使用了_KTHREAD(+0x060)这个位置,也就是说,线程在某一时刻,只能属于其中一个圈。

image-20220209155528828.png

等待链表

dd KiWaitListHead

image-20220209160310131.png

比如:线程调用了Sleep() 或者 WaitForSingleObject()等函数时,就挂到这个链表

(查看等待线程)

调度链表

有32个圈,就是优先级:0 - 31 0最低 31最高 默认优先级一般是8

改变优先级就是从一个圈里面卸下来挂到另外一个圈上

这32个圈是正在调度中的线程:包括正在运行的和准备运行的

比如:只有一个CPU但有10个线程在运行,那么某一时刻,正在运行的线程在KPCR中,其他9个在这32个圈中。

查看调度链表

既然有32个链表,就要有32个链表头

dd KiDispatcherReadyListHead L70

image-20220209160753440.png

XP只有一个33个圈,也就是说上面这个数组只有一个,多核也只有一个.

Win7也是一样的只有一个圈,如果是64位的,那就有64个圈.

服务器版本:

KiWaitListHead整个系统只有一个,但KiDispatcherReadyListHead这个数组有几个CPU就有几组.

总结:

  1. 正在运行的线程在KPCR中.
  2. 准备运行的线程在32个调度链表中(0 - 31级),KiDispatcherReadyListHead 是个数组存储了这32个链表头.
  3. 等待状态的线程存储在等待链表中,KiWaitListHead存储链表头.
  4. 这些圈都挂一个相同的位置:_KTHREAD(+0x060).

模拟线程切换

先看下效果:

有四个线程不断跑着,但是从任务管理器只看到一个线程,因为这里是我们模拟的,并不是真正的操作系统在切换线程。

image-20220212165733993.png

首先定义了一个线程结构体,来模拟线程的一些基础信息(仿ETHREAD)

//线程结构体
typedef struct
{
    char *name;         //线程名 相当于线程TID
    int Flags;          //线程状态
    int SleepMillisecondDot;        //休眠时间

    void *InitialStack;         //线程堆栈起始位置
    void *StackLimit;           //线程堆栈界限
    void *KernelStack;      //线程堆栈当前位置,也就是ESP

    void *lpParameter;      //线程函数的参数
    void (*func)(void *lpParameter);    //线程函数

} GMThread_t;

模拟调度链表,创建一个数组。

所谓创建了一个线程,就是创建了一个结构体,然后挂到这个数组上去。

此时的线程状态为:创建

//线程结构体数组
extern GMThread_t GMThreadList[MAXGMTHREAD];

初始化线程堆栈

通过VirtualAlloc创建一块空间,模拟堆栈,然后按顺序push堆栈,填充对应的值

//初始化线程的信息
void initGMThread(GMThread_t* GMThreadp, char* name, void (*func)(void* lpParameter), void* lpParameter)
{
    unsigned char* StackPages;
    unsigned int* StackDWordParam;
    GMThreadp->Flags = GMTHREAD_CREATE;
    GMThreadp->name = name;
    GMThreadp->func = func;
    GMThreadp->lpParameter = lpParameter;
    StackPages = (unsigned char*)VirtualAlloc(NULL, GMTHREADSTACKSIZE, MEM_COMMIT, PAGE_READWRITE);
    ZeroMemory(StackPages, GMTHREADSTACKSIZE);
    GMThreadp->initialStack = StackPages + GMTHREADSTACKSIZE;
    StackDWordParam = (unsigned int*)GMThreadp->initialStack;
    //入栈
    PushStack(&StackDWordParam, (unsigned int)GMThreadp);//startup 函数所需要的参数
    PushStack(&StackDWordParam, (unsigned int)9);//平衡堆栈用的,随便填
    PushStack(&StackDWordParam, (unsigned int)GMThreadStartup);
    PushStack(&StackDWordParam, (unsigned int)5);
    PushStack(&StackDWordParam, (unsigned int)7);
    PushStack(&StackDWordParam, (unsigned int)6);
    PushStack(&StackDWordParam, (unsigned int)3);
    PushStack(&StackDWordParam, (unsigned int)2);
    PushStack(&StackDWordParam, (unsigned int)1);
    PushStack(&StackDWordParam, (unsigned int)0);
    //当前线程的栈顶
    GMThreadp->KernelStack = StackDWordParam;
    GMThreadp->Flags = GMTHREAD_READY;
    return;
}

image-20220212171404992.png

填充9的原因

image-20220212174303692.png

线程切换的核心函数

__declspec(naked) void SwitchContext(GMThread_t* SrcGMThreadp, GMThread_t* DstGMThreadp)
{
    __asm {
        push ebp
        mov ebp, esp
        push edi
        push esi
        push ebx
        push ecx
        push edx
        push eax

        mov esi, SrcGMThreadp
        mov edi, DstGMThreadp
        mov [esi+GMThread_t.KernelStack], esp
        //线程切换核心就是堆栈的切换
        mov esp, [edi+GMThread_t.KernelStack]

        pop eax  
        pop edx
        pop ecx
        pop ebx
        pop esi
        pop edi
        pop ebp
        ret   //把栈顶的值弹到eip中,如果切换的线程是首次执行,那么在这里弹出的就是startup的地址到eip中。如果不是首次执行,就是上一次线程执行的位置
    }
}

image-20220212182347409.png

模拟线程切换总结:

1) 线程不是被动切换的,而是主动让出CPU.
2) 线程切换并没有使用TSS来保存寄存器,而是使用堆栈.
3) 线程切换的过程就是堆栈切换的过程.

线程挂起恢复实现

//这个函数会让出cpu,从队列里重新选择一个线程执行
void Scheduling(void)
{
    int i;
    int TickCount;
    GMThread_t* SrcGMThreadp;
    GMThread_t* DstGMThreadp;
    TickCount = GetTickCount();
    SrcGMThreadp = &GMThreadList[CurrentThreadIndex];
    DstGMThreadp = &GMThreadList[0];

    for (i = 1; GMThreadList[i].name; i++) {
        if (GMThreadList[i].Flags & GMTHREAD_SLEEP) {
            if (TickCount > GMThreadList[i].SleepMillsecondDot) {
                GMThreadList[i].Flags = GMTHREAD_READY;
            }
        }
        if (GMThreadList[i].Flags & GMTHREAD_READY) {
            DstGMThreadp = &GMThreadList[i];
            break;
        }
    }

    CurrentThreadIndex = DstGMThreadp - GMThreadList;
    SwitchContext(SrcGMThreadp, DstGMThreadp);
    return;
}

通过Scheduling函数可以看到,当线程处于GMTHREAD_READY状态时,该线程就随时会被调用。

将线程状态改为GMTHREAD_EXIT即可让该线程不再被切换执行,即做到挂起。将线程状态GMTHREAD_READY将会再次被调用。

挂起:

BOOL SuspendThread(char* name)
{
    for(int i = 0;i<MAXGMTHREAD;i++)
    {
        if(!strcmp(name,GMThreadList[i].name))
        {
            GMThreadList[i].Flags = GMTHREAD_EXIT;
            return true;
        }
    }
    printf("SusFailed\n");

    return false;
}

恢复:

BOOL ResumeThread(char* name)
{
    for(int i = 0;i<MAXGMTHREAD;i++)
    {
        if(!strcmp(name,GMThreadList[i].name))
        {
            GMThreadList[i].Flags = GMTHREAD_READY;
            return true;
        }
    }
    printf("ReSFailed\n");
    return false;
}

挂起Thread4,此时只有三个线程在跑。

image-20220213104114883.png

回复Thread4,又变成了四个线程在跑。

image-20220213104154316.png

线程切换条件

1) 主动调用API函数
2) 时钟中断
3) 异常处理

如果一个线程不调用API,在代码中屏蔽中断(CLI指令),并且
不会出现异常,那么当前线程将永久占有CPU,单核占有率
100% 2核就是50%

Windows线程切换_线程优先级

在KiSwapThread与KiQuantumEnd函数中都是通过KiFindReadyThread来找下一个要切换的线程,KiFindReadyThread是根据什么条件来选择下一个要执行的线程呢?

kd> dd KiDispatcherReadyListHead
80554820  80554820 80554820 80554828 80554828
80554830  80554830 80554830 80554838 80554838
80554840  80554840 80554840 80554848 80554848
80554850  80554850 80554850 80554858 80554858
80554860  80554860 80554860 80554868 80554868
80554870  80554870 80554870 80554878 80554878
80554880  80554880 80554880 80554888 80554888
80554890  80554890 80554890 80554898 80554898

KiFindReadyThread查找方式:按照优先级别进行查找:31..30..29..28.....

也就是说,在本次查找中,如果级别31的链表里面有线程,那么就不会查找级别为30的链表!

并不是高级别线程执行完了才执行低级别线程!!

调度链表有32个,每次都从头开始查找效率太低,所以Windows通过一个DWORD类型变量的变量来记录:

当向调度链表(32个)中挂入或者摘除某个线程时,会判断当前级别的链表是否为空,为空将DWORD变量对应位置0,否则置1。

如下图:

image-20220217113728416.png

这个变量:_kiReadySummary

​ 多cpu会随机寻找KiDispatcherReadyListHead指向的数组中的线程。线程可以绑定某个cpu(使用api:setThreadAffinityMask)

如果没有就绪线程cpu就会跑IdleThread线程。

PrcbData:
+0x004 CurrentThread    : Ptr32 _KTHREAD
+0x008 NextThread       : Ptr32 _KTHREAD
+0x00c IdleThread       : Ptr32 _KTHREAD 

image-20220216110808019.png

逆向SwapContext

SwapContext是windows实现线程切换的API。

KiSwapThread调用了KiSwapContext,而KiSwapContext调用了SwapContext

通过查看交叉引用,发现有这些内核函数又调用了KiSwapContext.

image-20220216135857205.png

然后在看一个关于KeWaitForSingleObject的交叉引用

image-20220216141116560.png

这个交叉引用是比较夸张了,Windows中绝大部分API都调用了SwapContext函数,也就是说,当线程只要调用了API,就是导致线程切换。

SwapContextKiSwapContext调用,先看KiSwapContext的代码

image-20220216110808019.png

进入SwapContext后才是真正线程切换的地方(esp的切换,也就是堆栈的切换,这是本质)

image-20220216111023706.png

image-20220216111058776.png

image-20220216111142841.png

通过逆向SwapContext能了解到很多细节,比如:

  1. 在线程切换(堆栈)前,TSS中esp0的值保证了已经是下一线程的堆栈。
  2. 在三环fs:[0]总是指向对应线程的Teb,但fs的值是固定的3b,是怎么做到的呢,实际上是更换了段选择子3b对应段描述符的base,改为当前线程的Teb的地址。
  3. 0环异常链表是直接push到当前线程的堆栈中的。
  4. windows并没有使用TSS作为任务切换,而是直接将寄存器保存在当前线程的堆栈中,等到下次线程切回来的时候重新将这些寄存器取出来。
  5. 线程切换时会比较是否属于同一个进程,如果不是,切换Cr3,Cr3换了,进程也就切换了。

Windows线程切换_时间片

时钟中断会导致线程进行切换,但并不是说只要有时钟中断就一定会切换线程,时钟中断时,两种情况会导致线程切换:

  1. 当前的线程CPU时间片到期
  2. 有备用线程(KPCR.PrcbData.NextThread)

一.CPU时间片到期

当一个新的线程开始执行时,初始化程序会在_KTHREAD.Quantum赋初始值,该值的大小由_KPROCESS.ThreadQuantum决定(这里随便找了个进程,观察ThreadQuantum大小)

image-20220216170425554.png

每次时钟中断会调用KeUpdateRunTime函数,该函数每次将当前线程
Quantum减少3个单位,随即有个比较,如果减到0,则将KPCR.PrcbData.QuantumEnd的值设置为非0。

image-20220216171043262.png

KiDispatchInterrupt判断时间片到期:

image-20220216173239299.png

调用KiQuantumEnd(重新设置时间片、找到要运行的线程)

image-20220216173503562.png

二.有备用线程(KPCR.PrcbData.NextThread)

这个值被设置时,即使当前线程的CPU时间片没有到期,仍然会被切换.

image-20220216210802417.png

总结线程切换的三种情况

  1. 当前线程主动调用API:API函数:KiSwapThread-->KiSwapContext-->SwapContext
  2. 当前线程时间片到期:KiDispatchInterrupt-->KiQuantumEnd-->SwapContext
  3. 有备用线程(KPCR.PrcbData.NextThread) KiDispatchInterrupt-->SwapContext

进程挂靠

进程与线程的关系

一个进程可以包含多个线程

一个进程至少要有一个线程

进程为线程提供资源,也就是提供Cr3的值,Cr3中存储的是页目录表基址,Cr3确定了,线程能访问的内存也就确定了。

线程代码:

mov eax,dword ptr ds:[0x12345678]

CPU如何解析0x12345678这个地址呢?

1) CPU解析线性地址时要通过页目录表来找对应的物理页,页目录表基址存在Cr3寄存器中。
2) 当前的Cr3的值来源于当前的进程(_KPROCESS.DirectoryTableBase(+0x018))。

线程与进程如何关联

在线程结构体EThread中有两处地方关联到对应的进程

一处是+44的位置,一处是+220的位置

+0x034 ApcState
        +0x000 ApcListHead 
        +0x010 Process 
        +0x014 KernelApcInProgress
        +0x015 KernelApcPending
        +0x016 UserApcPending
+0x220 ThreadsProcess

"养父母"负责提供Cr3

为什么会有养父母的概念呢?比如远程创建一个线程,创造线程的是A进程,但是线程是在B进程跑的,也就是实际资源是B进程提供的,此时B进程就形象的成为线程的养父母。

线程切换的时候,会比较_KTHREAD结构体0x044处指定的EPROCESS是否为同一个,如果不是同一个,会将0x044处指定的EPROCESS的DirectoryTableBase的值取出,赋值给Cr3。

所以,线程需要的Cr3的值来源于0x044处偏移指定的EPROCESS.

image-20220216222702890.png

总结:

0x220 亲生父母:这个线程谁创建的
0x044 养父母:谁在为这个线程提供资源(也就是提供Cr3)
一般情况下,0x220与0x44指向的是同一个进程

正常情况下,Cr3的值是由养父母提供的,但Cr3的值也可以改成和当前线程毫不相干的其他进程的DirectoryTableBase。

mov cr3,A.DirectoryTableBase
mov eax,dword ptr ds:[0x12345678]       //A进程的0x12345678内存
mov cr3,B.DirectoryTableBase
mov eax,dword ptr ds:[0x12345678]       //B进程的0x12345678内存
mov cr3,C.DirectoryTableBase
mov eax,dword ptr ds:[0x12345678]       //C进程的0x12345678内存

将当前Cr3的值改为其他进程,称为“进程挂靠”。

分析NtReadVirtualMemory函数
NtReadVirtualMemory-->KiAttachProcess-->修改养父母-->修改Cr3
image-20220216230340469.png

image-20220216225906954.png

可不可以只修改Cr3而不修改养父母?不可以,如果不修改养父母的值,一旦产生线程切换,就会变成自己读自己!

如果我们自己来写这个代码,在切换Cr3后关闭中断,并且不调用会导致线程切换的API,就可以不用修改养父母的值。

总结
正常情况下,当前线程使用的Cr3是由其所属进程提供的(ETHREAD 0x44偏移处指定的EPROCESS),正是因为如此,A进程中的线程只能访问A的内存。
如果要让A进程中的线程能够访问B进程的内存,就必须要修改Cr3的值为B进程的页目录表基址(B.DirectoryTableBase),这就是所谓的“进程挂靠”。

评论

S

SD

这个人很懒,没有留下任何介绍

twitter weibo github wechat

随机分类

密码学 文章:13 篇
后门 文章:39 篇
SQL注入 文章:39 篇
APT 文章:6 篇
Windows安全 文章:88 篇

扫码关注公众号

WeChat Offical Account QRCode

最新评论

K

kinding

给大佬点赞

K

kinding

给大佬点赞

路人甲@@

https://github.com/ice-doom/CodeQLRule 代

S

shungli923

师傅,催更啊!!!

1

1ue1uekin8

6

目录