⭐ 欢迎来到虫虫下载站! | 📦 资源下载 📁 资源专辑 ℹ️ 关于我们
⭐ 虫虫下载站

📄

📁 兼容内核漫谈 适合想将Windows上的程序移植到其它平台上的朋友研究查看
💻
📖 第 1 页 / 共 4 页
字号:
    ● SystemDllApcDispatcher,指向KiUserApcDispatcher()。
    ● SystemDllExceptionDispatcher,指向KiUserExceptionDispatcher()。
    ● SystemDllCallbackDispatcher,指向KiUserCallbackDispatcher()。
    ● SystemDllRaiseExceptionDispatche r,指向KiRaiseUserExceptionDispatcher()。
    这些指针都是在LdrpMapSystemDll()中得到设置的。给定一个函数名的字符串,就可以通过一个函数LdrGetProcedureAddress()从(已经映射的)DLL映像中获取这个函数的地址(如果这个函数被引出的话)。
    于是,CPU从KiDeliverApc()回到_KiServiceExit以后会继续完成其返回用户空间的行程,只是一到用户空间就栽进了圈套,那就是KiUserApcDispatcher(),而不是回到原先的断点上。关于原先断点的现场信息保存在用户空间堆栈上、并形成一个CONTEXT数据结构,但是“深埋”在6个32位整数的后面。而这6个32位整数的作用则为:
    ● Esp[0]的值为0xdeadbeef,用来模拟KiUserApcDispatcher()的返回地址。当然,这个地址是无效的,所以KiUserApcDispatcher()实际上是不会返回的。
    ● Esp[1]的值为NormalRoutine,在我们这个情景中指向“门户”函数IntCallUserApc()。
    ● Esp[2]的值为NormalContext,在我们这个情景中是指向实际APC函数的指针。
    ● 余类推。其中Esp[5]指向(用户)堆栈上的CONTEXT数据结构。
    总之,用户堆栈上的这6个32位整数模拟了一次CPU在进入KiUserApcDispatcher()还没有来得及执行其第一条指令之前就发生了中断的假象,使得CPU在结束了KiDeliverApc()的执行、回到_KiServiceExit中继续前行、并最终回到用户空间时就进入KiUserApcDispatcher()执行其第一条指令。
    另一方面,对于该线程原来的上下文而言,则又好像是刚回到用户空间就发生了中断,而KiUserApcDispatcher()则相当于中断相应程序。

[code]VOID STDCALL
KiUserApcDispatcher(PIO_APC_ROUTINE ApcRoutine, PVOID ApcContext,
                PIO_STATUS_BLOCK Iosb, ULONG Reserved, PCONTEXT Context)
{
   /* Call the APC */
   ApcRoutine(ApcContext, Iosb, Reserved);
   /* Switch back to the interrupted context */
   NtContinue(Context, 1);
}[/code]

    这里的第一个参数ApcRoutine指向IntCallUserApc(),第二个参数ApcContext指向真正的(目标)APC函数。

[code][KiUserApcDispatcher() > IntCallUserApc()]

static void CALLBACK
IntCallUserApc(PVOID Function, PVOID dwData, PVOID Argument3)
{
   PAPCFUNC pfnAPC = (PAPCFUNC)Function;
   pfnAPC((ULONG_PTR)dwData);
}[/code]

    可见,IntCallUserApc()其实并无必要,在KiUserApcDispatcher()中直接调用目标APC函数也无不可,这样做只是为将来可能的修改扩充提供一些方便和灵活性。从IntCallUserApc()回到KiUserApcDispatcher(),下面紧接着是系统调用NtContinue()。

    KiUserApcDispatcher()是不返回的。它之所以不返回,是因为对NtContinue()的调用不返回。正如代码中的注释所述,NtContinue()的作用是切换回被中断了的上下文,不过其实还不止于此,下面读者就会看到它还起着循环执行整个用户APC请求队列的作用。

[code][KiUserApcDispatcher() > NtContinue()]

NTSTATUS STDCALL
NtContinue (IN PCONTEXT Context,  IN BOOLEAN TestAlert)
{
    PKTHREAD Thread = KeGetCurrentThread();
    PKTRAP_FRAME TrapFrame = Thread->TrapFrame;
    PKTRAP_FRAME PrevTrapFrame = (PKTRAP_FRAME)TrapFrame->Edx;
    PFX_SAVE_AREA FxSaveArea;
    KIRQL oldIrql;

    DPRINT("NtContinue: Context: Eip=0x%x, Esp=0x%x\n", Context->Eip, Context->Esp );
    PULONG Frame = 0;
    __asm__("mov %%ebp, %%ebx" : "=b" (Frame) : );
    . . . . . .

    /*
    * Copy the supplied context over the register information that was saved
    * on entry to kernel mode, it will then be restored on exit
    * FIXME: Validate the context
    */
    KeContextToTrapFrame ( Context, TrapFrame );

    /* Put the floating point context into the thread's FX_SAVE_AREA
    * and make sure it is reloaded when needed.
    */
    FxSaveArea = (PFX_SAVE_AREA)((ULONG_PTR)Thread->InitialStack –
                                                sizeof(FX_SAVE_AREA));
    if (KiContextToFxSaveArea(FxSaveArea, Context))
    {
        Thread->NpxState = NPX_STATE_VALID;
        KeRaiseIrql(DISPATCH_LEVEL, &oldIrql);
        if (KeGetCurrentPrcb()->NpxThread == Thread)
        {
            KeGetCurrentPrcb()->NpxThread = NULL;
            Ke386SetCr0(Ke386GetCr0() | X86_CR0_TS);
        }
        else
        {
            ASSERT((Ke386GetCr0() & X86_CR0_TS) == X86_CR0_TS);
        }
        KeLowerIrql(oldIrql);
    }

    /* Restore the user context */
    Thread->TrapFrame = PrevTrapFrame;
__asm__("mov %%ebx, %%esp;\n" "jmp _KiServiceExit": : "b" (TrapFrame));

    return STATUS_SUCCESS; /* this doesn't actually happen */
}[/code]

    注意从KiUserApcDispatcher()到NtContinue()并不是普通的函数调用,而是系统调用,这中间经历了空间的切换,也从用户空间堆栈切换到了系统空间堆栈。CPU进入系统调用空间后,在_KiSystemServicex下面的代码中把指向中断现场的框架指针保存在当前线程的KTHREAD数据结构的TrapFrame字段中。这样,很容易就可以找到系统空间堆栈上的调用框架。当然,现在的框架是因为系统调用而产生的框架;而要想回到当初、即在执行用户空间APC函数之前的断点,就得先恢复当初的框架。那么当初的框架在哪里呢?它保存在用户空间的堆栈上,就是前面KiInitializeUserApc()保存的CONTEXT数据结构中。所以,这里通过KeContextToTrapFrame()把当初保存的信息拷贝回来,从而恢复了当初的框架。
    下面的KiContextToFxSaveArea()等语句与浮点处理器有关,我们在这里并不关心。
    最后,汇编指令“jmp _KiServiceExit”使CPU跳转到了返回用户空间途中的_KiServiceExit处(见前面的代码)。在这里,CPU又会检查APC请求队列中是否有APC请求等着要执行,如果有的话又会进入KiDeliverApc()。前面讲过,每次进入KiDeliverApc()只会执行一个用户APC请求,所以如果用户APC队列的长度大于1的话就得循环着多次走过上述的路线,即:
    1. 从系统调用、中断、或异常返回途径_KiServiceExit,如果APC队列中有等待执行的APC请求,就调用KiDeliverApc()。
    2. KiDeliverApc(),从用户APC队列中摘下一个APC请求。
    3. 在KiInitializeUserApc()中保存当前框架,并伪造新的框架。
    4. 回到用户空间。
    5. 在KiUserApcDispatcher()中调用目标APC函数。
    6. 通过系统调用NtContinue()进入系统空间。
    7. 在NtContinue()中恢复当初保存的框架。
    8. 从NtContinue()返回、途径_KiServiceExit时,如果APC队列中还有等待执行的APC请求,就调用KiDeliverApc()。于是转回上面的第二步。
    这个过程一直要循环到APC队列中不再有需要执行的请求。注意这里每一次循环中保存和恢复的都是同一个框架,就是原始的、开始处理APC队列之前的那个框架,代表着原始的用户空间程序断点。一旦APC队列中不再有等待执行的APC请求,在_KiServiceExit下面就不再调用KiDeliverApc(),于是就直接返回用户空间,这次是返回到原始的程序断点了。所以,系统调用neContinue()的作用不仅仅是切换回到被中断了的上下文,还包括执行用户APC队列中的下一个APC请求。
对于KiUserApcDispatcher()而言,它对NtContinue()的调用是不返回的。因为在NtContinue()中CPU不是“返回”到对于KiUserApcDispatcher()的另一次调用、从而对另一个APC函数的调用;就是返回到原始的用户空间程序断点,这个断点既可能是因为中断或异常而形成的,也可能是因为系统调用而形成的。

    理解了常规的APC请求和执行机制,我们不妨再看看启动执行PE目标映像时函数的动态连接。以前讲过,PE格式EXE映像与(除ntdll.dll外的)DLL的动态连接、包括这些DLL的装入,是由ntdll.dll中的一个函数LdrInitializeThunk()作为APC函数执行而完成的,所以这也是对APC机制的一种变通使用。
    要启动一个EXE映像运行时,首先要创建进程,再把目标EXE映像和ntdll.dll的映像都映射到新进程的用户空间,然后通过系统调用NtCreateThread()创建这个进程的第一个线程、或称“主线程”。而LdrInitializeThunk()作为APC函数的执行,就是在NtCreateThread()中安排好的。

[code]NtCreateThread(OUT PHANDLE ThreadHandle, IN ACCESS_MASK DesiredAccess,
               IN POBJECT_ATTRIBUTES ObjectAttributes  OPTIONAL,
               IN HANDLE ProcessHandle, OUT PCLIENT_ID ClientId,
               IN PCONTEXT ThreadContext, IN PINITIAL_TEB InitialTeb,
               IN BOOLEAN CreateSuspended)
{
  HANDLE hThread;

  . . . . . .
  . . . . . .
  /*
   * Queue an APC to the thread that will execute the ntdll startup
   * routine.
   */
  LdrInitApc = ExAllocatePool(NonPagedPool, sizeof(KAPC));
  KeInitializeApc(LdrInitApc, &Thread->Tcb, OriginalApcEnvironment,
                            LdrInitApcKernelRoutine,
                            LdrInitApcRundownRoutine,
                            LdrpGetSystemDllEntryPoint(), UserMode, NULL);
  KeInsertQueueApc(LdrInitApc, NULL, NULL, IO_NO_INCREMENT);

  /*
   * The thread is non-alertable, so the APC we added did not set UserApcPending to TRUE.
   * We must do this manually. Do NOT attempt to set the Thread to Alertable before the call,
   * doing so is a blatant and erronous hack.
   */
  Thread->Tcb.ApcState.UserApcPending = TRUE;
  Thread->Tcb.Alerted[KernelMode] = TRUE;
  . . . . . .
  . . . . . .
}[/code]

    NeCreateThread()要做的事当然很多,但是其中很重要的一项就是安排好APC函数的执行。这里的KeInitializeApc()和KeInsertQueueApc读者都已经熟悉了,所以我们只关心调用参数中的三个函数指针,特别是其中的KernelRoutine和NormalRoutine。前者十分简单:

[code]VOID STDCALL
LdrInitApcKernelRoutine(PKAPC Apc, PKNORMAL_ROUTINE* NormalRoutine,
          PVOID* NormalContext, PVOID* SystemArgument1, PVOID* SystemArgument2)
{
  ExFreePool(Apc);
}[/code]

    而NormalRoutine,这里是通过LdrpGetSystemDllEntryPoint()获取的,它只是返回全局量SystemDllEntryPoint的值:

[code]PVOID LdrpGetSystemDllEntryPoint(VOID)
{
   return(SystemDllEntryPoint);
}[/code]

    前面已经讲到,全局量SystemDllEntryPoint是在LdrpMapSystemDll()时得到设置的,指向已经映射到用户空间的ntdll.dll映像中的LdrInitializeThunk()。注意这APC请求是挂在新线程的队列中,而不是当前进程的队列中。事实上,新线程和当前进程处于不同的进程,因而不在同一个用户空间中。还要注意,这里的NormalRoutine直接就是LdrInitializeThunk(),而不像前面通过QueueUserAPC()发出的APC请求那样中间还有一层IntCallUserApc()。至于KiUserApcDispatcher(),那是由KeInitializeApc()强制加上的,正是这个函数保证了对NtContinue()的调用。
    此后的流程本来无需细说了,但是由于情景的特殊性还是需要加一些简要的说明。由NtCreateProcess()创建的进程并非一个可以调度运行的实体,而NtCreateThread()创建的线程却是。所以,在NtCreateProcess()返回的前夕,系统中已经多了一个线程。这个新增线程的“框架”是伪造的,目的在于让这个线程一开始在用户空间运行就进入预定的程序入口。从NtCreateProcess()返回是回到当前线程、而不是新增线程,而刚才的APC请求是挂在新增线程的队列中,所以在从NtCreateThread()返回的途中不会去执行这个APC请求。可是,当新增线程受调度运行时,首先就是按伪造的框架和堆栈模拟一个从系统调用返回的过程,所以也要途径_KiServiceExit。这时候,这个APC请求就要得到执行了(由KiUserApcDispatcher()调用LdrInitializeThunk())。然后,在用户空间执行完APC函数LdrInitializeThunk()以后,同样也是通过NtContinue()回到内核中,然后又按原先的伪造框架“返回”到用户空间,这才真正开始了新线程在用户空间的执行。

    最后,我们不妨比较一下APC机制和Unix/Linux的Signal机制。
    Unix/Linux的Signal机制基本上是对硬件中断机制的软件模拟,具体表现在以下几个方面:
    1) 现代的硬件中断机制一般都是“向量中断”机制,而Signal机制中的Signal序号(例如SIG_KILL)就是对中断向量序号的模拟。
    2) 作为操作系统对硬件中断机制的支持,一般都会提供“设置中断向量”一类的内核函数,使特定序号的中断向量指向某个中断服务程序。而系统调用signal()就相当于是这一类的函数。只不过前者在内核中、一般只是供其它内核函数调用,而后者是系统调用、供用户空间的程序调用。
    3) 在硬件中断机制中,“中断向量”的设置只是为某类异步事件、及中断的发生做好了准备,但是并不意味着某个特定时间的发生。如果一直没有中断请求,那么所设置的中断向量就一直得不到执行,而中断的发生只是触发了中断服务程序的执行。在Signal机制中,向某个进程发出“信号”、即Signal、就相当于中断请求。
    相比之下,APC机制就不能说是对于硬件中断机制的模拟了。首先,通过NtQueueApcThread()设置一个APC函数跟通过signal()设置一个“中断向量”有所不同。将一个APC函数挂入APC队列中时,对于这个函数的得到执行、以及大约在什么时候得到执行,实际上是预知的,只是这得到执行的条件要过一回儿才会成熟。而“中断”则不同,中断向量的设置只是说如果发生某种中断则如何如何,但是对于其究竟是否会发生、何时发生则常常是无法预测的。所以,从这个意义上说,APC函数只是一种推迟执行、异步执行的函数调用,因此称之为“异步过程调用”确实更为贴切。
    还有,signal机制的signal()所设置的“中断服务程序”都是用户空间的程序,而APC机制中挂入APC队列的函数却可以是内核函数。
    但是,尽管如此,它们的(某些方面的)实质还是一样的。“中断”本来就是一种异步执行的机制。再说,(用户)APC与Signal的执行流程几乎完全一样,都是在从内核返回用户空间的前夕检查是否有这样的函数需要加以执行,如果是就临时修改堆栈,偏离原来的执行路线,使得返回用户空间后进入APC函数,并且在执行完了这个函数以后仍进入内核,然后恢复原来的堆栈,再次返回用户空间原来的断点。这样,对于原来的流程而言,就相当于受到了中断。

⌨️ 快捷键说明

复制代码 Ctrl + C
搜索代码 Ctrl + F
全屏模式 F11
切换主题 Ctrl + Shift + D
显示快捷键 ?
增大字号 Ctrl + =
减小字号 Ctrl + -