📄 漫谈兼容内核之十九windows线程间的强相互作用.txt
字号:
{
*PreviousSuspendCount = Count;
}
ObDereferenceObject ((PVOID)Thread);
return STATUS_SUCCESS;
}
注意在ObReferenceObjectByHandle()时使用的参数THREAD_SUSPEND_RESUME,当初打开这个线程对象时必须说明允许此类操作。
线程的KTHREAD数据结构中有个字段SuspendCount,正常情况下其数值为0,如果大于0就说明该线程已被挂起;要是大于1就说明已经有不止一次的挂起尚未恢复。参数PreviousSuspendCount就是用来返回本次操作之前的SuspendCount。
显然,具体的操作是由PsSuspendThread()实现的:
[NtSuspendThread() > PsSuspendThread()]
NTSTATUS PsSuspendThread(PETHREAD Thread, PULONG PreviousSuspendCount)
{
ULONG OldValue;
ExAcquireFastMutex(&SuspendMutex);
OldValue = Thread->Tcb.SuspendCount;
Thread->Tcb.SuspendCount++;
if (!Thread->Tcb.SuspendApc.Inserted)
{
if (!KeInsertQueueApc(&Thread->Tcb.SuspendApc,
NULL, NULL, IO_NO_INCREMENT))
{
Thread->Tcb.SuspendCount--;
ExReleaseFastMutex(&SuspendMutex);
return(STATUS_THREAD_IS_TERMINATING);
}
}
ExReleaseFastMutex(&SuspendMutex);
if (PreviousSuspendCount != NULL)
{
*PreviousSuspendCount = OldValue;
}
return(STATUS_SUCCESS);
}
所谓“挂起”一个线程实际包括两方面的操作。一是递增KTHREAD结构中的SuspendCount字段,这个字段的值表示目标线程已经有了几次尚未恢复的“挂起”操作。二是为此线程挂入一个APC请求。这两项操作都应该在临界区中排它地进行,所以内核中提供了一个互斥门SuspendMutex,用它来构成用于挂起/恢复操作的临界区。注意这里的Thread代表着目标线程、而不是当前进程(除非当前进程就是目标线程)。
这里的Thread->Tcb.SuspendApc是创建线程时设置好的,我们不妨回顾一下线程对象的初始化:
KeInitializeThread(PKPROCESS Process, PKTHREAD Thread, BOOLEAN First)
{
. . . . . .
/* Initialize the Suspend APC */
KeInitializeApc(&Thread->SuspendApc, Thread, OriginalApcEnvironment,
PiSuspendThreadKernelRoutine,
PiSuspendThreadRundownRoutine,
PiSuspendThreadNormalRoutine,
KernelMode, NULL);
/* Initialize the Suspend Semaphore */
KeInitializeSemaphore(&Thread->SuspendSemaphore, 0, 128);
. . . . . .
}
其中PiSuspendThreadKernelRoutine()和PiSuspendThreadRundownRoutine()都是空函数,但PiSuspendThreadNormalRoutine()不是:
VOID STDCALL
PiSuspendThreadNormalRoutine(PVOID NormalContext,
PVOID SystemArgument1, PVOID SystemArgument2)
{
PETHREAD CurrentThread = PsGetCurrentThread();
while (CurrentThread->Tcb.SuspendCount > 0)
{
KeWaitForSingleObject(&CurrentThread->Tcb.SuspendSemaphore,
0, UserMode, TRUE, NULL);
}
}
就是说,当目标进程下一次被调度运行时,就会先在内核中执行这个APC函数,从而在其自身的“信号量”SuspendSemaphore上执行一次P操作。但是这个信号量的初值是0,所以这个线程势必会进入睡眠,这就是被“挂起”了。那么什么时候才能恢复运行呢?首先得要从KeWaitForSingleObject()中被唤醒,就是必须有谁对这个“信号量”执行一次V操作。同时,其KTHREAD结构中的SuspendCount又必须为0,否则即使唤醒了还会调头又睡。实际上,如前所述,Windows提供的信号量是变型的,因为它不取负值。而计数值SuspendCount和这里的“信号量”合在一起就相当于“原型”的信号量。注意这里的当前线程CurrentThread实际上就是目标线程,因为是目标线程在执行这个APC函数。
这里还有个问题,所谓挂起“正在运行的”目标线程是什么意思呢?显然,真正“正在运行”的线程就是当前线程、即调用并执行NtSuspendThread()的线程,而不是目标进程。但是目标进程之所以并不真的在运行是因为被线程调度暂时剥夺了运行权,例如因为当前这个进程的优先级更高,或者用完了本次运行的时间配额,但是它的状态仍是“就绪”状态,宏观地看这就算是正在运行了。那么要是目标线程已经睡眠会怎样呢?如果已经睡眠,那就不会被调度运行,因而就不会执行这个APC函数。但是,一旦原来的睡眠被唤醒,就早晚会被调度运行,从而就会因执行APC函数而又进入睡眠,这一次就是被挂起了。
与NtSuspendThread()相对应的系统调用是NtResumeThread(),其作用是使被挂起的目标线程恢复运行。
NTSTATUS STDCALL
NtResumeThread(IN HANDLE ThreadHandle, IN PULONG SuspendCount OPTIONAL)
{
. . . . . .
Status = ObReferenceObjectByHandle (ThreadHandle, THREAD_SUSPEND_RESUME,
PsThreadType, UserMode, (PVOID*)&Thread, NULL);
. . . . . .
Status = PsResumeThread (Thread, &Count);
. . . . . .
ObDereferenceObject ((PVOID)Thread);
return STATUS_SUCCESS;
}
同样,具体的操作是由PsResumeThread()完成的:
[NtResumeThread() > PsResumeThread()]
NTSTATUS PsResumeThread (PETHREAD Thread, PULONG SuspendCount)
{
ExAcquireFastMutex (&SuspendMutex);
if (SuspendCount != NULL)
{
*SuspendCount = Thread->Tcb.SuspendCount;
}
if (Thread->Tcb.SuspendCount > 0)
{
Thread->Tcb.SuspendCount--;
if (Thread->Tcb.SuspendCount == 0)
{
KeReleaseSemaphore (&Thread->Tcb.SuspendSemaphore,
IO_NO_INCREMENT, 1, FALSE);
}
}
ExReleaseFastMutex (&SuspendMutex);
return STATUS_SUCCESS;
}
首先是递减目标线程的SuspendCount。当递减到0的时候,目标进程的所有“挂起”都已被撤销,可以恢复运行了。此时对其信号量SuspendSemaphore执行一次V操作,从而将其唤醒。注意在前面PiSuspendThreadNormalRoutine()代码中的KeWaitForSingleObject()是放在一个while循环中,目标线程醒来以后又要检查SuspendCount是否大于0,如果大于0则又要睡眠等待。这是因为虽然此刻的SuspendCount已经是0,但是目标线程被唤醒以后未必立刻就被调度运行,此前可能会有别的线程先有机会运行,而这些线程有可能又对目标线程实行“挂起”操作。
读者应该十分明确一个概念,那就是:在一个系统中,除当前线程以外的所有线程都不在运行,而不在运行的线程肯定都进了内核。为什么呢?因为线程的“运行权”都是在内核中被放弃或被剥夺的。因系统调用进入内核而被阻塞就不必说了;即使是在用户空间好好运行的线程,既然被暂时剥夺了运行权,那就说明发生了线程调度,而强制的(剥夺性的)线程调度只发生于CPU从内核返回用户空间的途中。那是怎么进的内核呢?除系统调用之外就只有两种可能,即异常和中断。所以,在用户空间好好运行的线程突然被剥夺了运行,最大的可能就是发生了中断、例如时钟中断,从而导致了线程调度。所不同的是有的线程进入了睡眠,有的则没有、而只是在就绪队列中等待再次被调度运行。这一点,在Linux上是这样,在Windows上也是一样。
既然除当前进程之外所有的线程都在内核中,这些线程的内核堆栈中就留有它们进入内核时所保留的“现场”,又称“上下文(Context)”,实际上就是它们进入内核时各个寄存器的内容,所以这是在用户空间的上下文。Windows提供了读/写这些上下文的系统调用,这就是NtGetContextThread()和NtSetContextThread(),前者用于获取一个线程的上下文,后者用于改变一个线程的上下文。当然,这里的目标线程必须事先已经打开。
如果说获取一个线程的上下文只是窥探隐私,那么改变一个线程的上下文可就不简单了。比方说,改变目标线程上下文中的Eip映像、即寄存器EIP的内容,就可以使目标线程受调度运行而返回用户空间时脱离原来的轨道,“返回”到别的地方去。在“跨进程操作”那篇漫谈中我们看到一个进程可以在另一个进程的用户空间分配存储区间、把一段程序拷贝过去,再在目标进程中创建一个线程并令其执行。其实,连创建线程都没有必要,只要改变其中一个线程的上下文,使其返回到“栽赃”进去的程序入口就行了。显然,线程间如此这般的作用是强作用而不是弱作用。事实上,这两个系统调用是供程序调试工具做Debug用的,调试程序的时候就得使被调试的线程指哪去哪,这就是通过改变目标线程的上下文实现的。但是,调试工具也好,恶意代码也罢,一旦打开了目标线程,就可以对其为所欲为了。由此也可以看出,Windows系统中对于打开进程对象和线程对象的安全保护措施是何等重要。
NtGetContextThread()和NtSetContextThread()两个系统调用的代码基本上是对称的,所以我们在这里只看后者的代码。当然,目标线程必须已被打开,并且已经准备好了一个伪造的上下文,一般是先通过NtGetContextThread()获取目标线程的上下文,再加以篡改而成。
NTSTATUS STDCALL
NtSetContextThread(IN HANDLE ThreadHandle, IN PCONTEXT ThreadContext)
{
. . . . . .
. . . . . .
PreviousMode = ExGetPreviousMode();
if(PreviousMode != KernelMode)
{
_SEH_TRY . . . _SEH_END;
. . . . . .
}
Status = ObReferenceObjectByHandle(ThreadHandle, THREAD_SET_CONTEXT,
PsThreadType, PreviousMode, (PVOID*)&Thread, NULL);
if(NT_SUCCESS(Status))
{
if (Thread == PsGetCurrentThread())
{
/* I don't know if trying to set your own context makes much
sense but we can handle it more efficently. */
KeContextToTrapFrame(&Context, Thread->Tcb.TrapFrame);
}
else
{
KeInitializeEvent(&Event, NotificationEvent, FALSE);
KeInitializeApc(&Apc, &Thread->Tcb, OriginalApcEnvironment,
KeSetContextKernelRoutine,
KeGetSetContextRundownRoutine,
NULL, KernelMode, (PVOID)&Context);
if (!KeInsertQueueApc(&Apc,
(PVOID)&Event, (PVOID)&Status, IO_NO_INCREMENT))
{
Status = STATUS_THREAD_IS_TERMINATING;
}
else
{
Status = KeWaitForSingleObject(&Event, 0, KernelMode, FALSE, NULL);
}
}
ObDereferenceObject(Thread);
}
return Status;
}
首先要根据Handle取得目标线程数据结构的指针,而ObReferenceObjectByHandle()在这里设置了一道防线。参数THREAD_SET_CONTEXT是个标志位,表示访问目标对象的目的是设置上下文,这必须符合打开目标线程时所申明的操作范围。而在打开目标对象时的安全保护机制则把所要求的操作范围作为判断是否允许打开的依据之一。
具体的操作是由内核APC函数实现的。这里只是为APC函数的执行作了些准备,并提出APC请求,然后就睡眠等待具体操作的完成,而事件Event的作用就是作为等待/唤醒机制的载体。此外,由于参数ThreadContext所指的上下文数据结构是在用户空间,所以先要把它复制到内核中,就是这里的Context。注意调用KeInitializeApc()时的第二个参数&Thread->Tcb,这个参数是个KTHREAD指针。但是这里的Thread指向目标线程、而不是当前线程的ETHREAD数据结构。所以,这里的APC请求是对目标线程的APC请求,而不是对当前进程的APC请求。
于是,当前进程就在KeWaitForSingleObject()中进入了睡眠,内核将调度别的线程运行。
当目标线程被调度运行,准备返回用户空间的前夕,就到了它检查APC请求和执行APC函数的时候。由于已经有APC请求在队列中等候,目标线程就会执行APC函数KeSetContextKernelRoutine()。
VOID STDCALL
KeSetContextKernelRoutine(PKAPC Apc, PKNORMAL_ROUTINE* NormalRoutine,
PVOID* NormalContext, PVOID* SystemArgument1, PVOID* SystemArgument2)
{
PKEVENT Event;
PCONTEXT Context;
PNTSTATUS Status;
Context = (PCONTEXT)(*NormalContext);
Event = (PKEVENT)(*SystemArgument1);
Status = (PNTSTATUS)(*SystemArgument2);
KeContextToTrapFrame(Context, KeGetCurrentThread()->TrapFrame);
⌨️ 快捷键说明
复制代码
Ctrl + C
搜索代码
Ctrl + F
全屏模式
F11
切换主题
Ctrl + Shift + D
显示快捷键
?
增大字号
Ctrl + =
减小字号
Ctrl + -