APC 篇—— APC 挂入

写在前面

  此系列是本人一个字一个字码出来的,包括示例和实验截图。由于系统内核的复杂性,故可能有错误或者不全面的地方,如有错误,欢迎批评指正,本教程将会长期更新。 如有好的建议,欢迎反馈。码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作。如想转载,请把我的转载信息附在文章后面,并声明我的个人信息和本人博客地址即可,但必须事先通知我

你如果是从中间插过来看的,请仔细阅读 羽夏看Win系统内核——简述 ,方便学习本教程。

  看此教程之前,问几个问题,基础知识储备好了吗?保护模式篇学会了吗?练习做完了吗?没有的话就不要继续了。


🔒 华丽的分割线 🔒


NtReadVirtualMemory 分析

  由于是仅仅分析挂靠时该函数是如何备份和恢复APC队列的,为了缩短篇幅增加可读性,我会尽可能使用IDA翻译的伪代码,你的伪代码结果应该和我的不一样,因为我进行了一些重命名操作。我们先定位到NtReadVirtualMemory这个伪代码:

NTSTATUS __stdcall NtReadVirtualMemory(HANDLE ProcessHandle, PVOID BaseAddress, PVOID Buffer, SIZE_T NumberOfBytesToRead, PSIZE_T NumberOfBytesRead)
{
  _KTHREAD *v5; // edi
  PSIZE_T v6; // ebx
  int v8; // [esp+10h] [ebp-28h] BYREF
  PVOID Object; // [esp+14h] [ebp-24h] BYREF
  KPROCESSOR_MODE AccessMode[4]; // [esp+18h] [ebp-20h]
  NTSTATUS v11; // [esp+1Ch] [ebp-1Ch]
  CPPEH_RECORD ms_exc; // [esp+20h] [ebp-18h]

  v5 = KeGetCurrentThread();
  AccessMode[0] = v5->PreviousMode;
  if ( AccessMode[0] )
  {
    if ( BaseAddress + NumberOfBytesToRead < BaseAddress
      || Buffer + NumberOfBytesToRead < Buffer
      || BaseAddress + NumberOfBytesToRead > MmHighestUserAddress
      || Buffer + NumberOfBytesToRead > MmHighestUserAddress )
    {
      return 0xC0000005;
    }
    v6 = NumberOfBytesRead;
    if ( NumberOfBytesRead )
    {
      ms_exc.registration.TryLevel = 0;
      if ( NumberOfBytesRead >= MmUserProbeAddress )
        *MmUserProbeAddress = 0;
      *NumberOfBytesRead = *NumberOfBytesRead;
      ms_exc.registration.TryLevel = -1;
    }
  }
  else
  {
    v6 = NumberOfBytesRead;
  }
  v8 = 0;
  v11 = 0;
  if ( NumberOfBytesToRead )
  {
    v11 = ObReferenceObjectByHandle(ProcessHandle, 0x10u, PsProcessType, AccessMode[0], &Object, 0);
    if ( !v11 )
    {
      v11 = MmCopyVirtualMemory(
              Object,
              BaseAddress,
              v5->ApcState.Process,
              Buffer,
              NumberOfBytesToRead,
              AccessMode[0],
              &v8);
      ObfDereferenceObject(Object);
    }
  }
  if ( v6 )
  {
    *v6 = v8;
    ms_exc.registration.TryLevel = -1;
  }
  return v11;
}

  我们可以看到,该函数实现内存拷贝是通过MmCopyVirtualMemory这个函数实现的,我们点击去看看:

NTSTATUS __stdcall MmCopyVirtualMemory(PEX_RUNDOWN_REF RunRef, int a2, PRKPROCESS KPROCESS, volatile void *Address, SIZE_T Length, KPROCESSOR_MODE AccessMode, int a7)
{
  struct _KPROCESS *v8; // ebx
  PRKPROCESS kprocess; // ecx
  NTSTATUS res; // esi
  struct _EX_RUNDOWN_REF *RunRefa; // [esp+8h] [ebp+8h]

  if ( !Length )
    return 0;
  v8 = RunRef;
  kprocess = RunRef;
  if ( RunRef == KeGetCurrentThread()->ApcState.Process )
    kprocess = KPROCESS;
  RunRefa = &kprocess[1].ProfileListHead.Blink;
  if ( !ExAcquireRundownProtection(&kprocess[1].ProfileListHead.Blink) )
    return STATUS_PROCESS_IS_TERMINATING;
  if ( Length <= 0x1FF )
    goto LABEL_10;
  res = MiDoMappedCopy(v8, a2, KPROCESS, Address, Length, AccessMode, a7);
  if ( res == STATUS_WORKING_SET_QUOTA )
  {
    *a7 = 0;
LABEL_10:
    res = MiDoPoolCopy(v8, a2, KPROCESS, Address, Length, AccessMode, a7);
  }
  ExReleaseRundownProtection(RunRefa);
  return res;
}

  你可能看到一个新奇的函数ExAcquireRundownProtection,这个函数是申请一个锁,从网上查阅翻译过来是停运保护(RundownProtection)锁,名字怪怪的听起来怪怪的。
  这个不涉及我们的核心,我们继续分析,发现它内部又是通过MiDoMappedCopy实现进程内存读取的:

NTSTATUS __stdcall MiDoMappedCopy(PRKPROCESS PROCESS, int a2, PRKPROCESS a3, volatile void *Address, SIZE_T Length, KPROCESSOR_MODE AccessMode, int a7)
{
  // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]

  v13 = 0;
  v22 = a2;
  v17 = Address;
  v7 = 0xE000;
  if ( Length <= 0xE000 )
    v7 = Length;
  v16 = &MemoryDescriptorList;
  Length_1 = Length;
  v19 = v7;
  v20 = 0;
  v14 = 0;
  v15 = 0;
  while ( Length_1 )
  {
    if ( Length_1 < v19 )
      v19 = Length_1;
    KeStackAttachProcess(PROCESS, &ApcState);
    BaseAddress = 0;
    v12 = 0;
    v11 = 0;
    ms_exc.registration.TryLevel = 0;
    if ( v22 == a2 && AccessMode )
    {
      v20 = 1;
      if ( Length && (a2 + Length < a2 || a2 + Length > MmUserProbeAddress) )
        ExRaiseAccessViolation();
      v20 = 0;
    }
    MemoryDescriptorList.Next = 0;
    MemoryDescriptorList.Size = 4 * (((v22 & 0xFFF) + v19 + 0xFFF) >> 12) + 28;
    MemoryDescriptorList.MdlFlags = 0;
    MemoryDescriptorList.StartVa = (v22 & 0xFFFFF000);
    MemoryDescriptorList.ByteOffset = v22 & 0xFFF;
    MemoryDescriptorList.ByteCount = v19;
    MmProbeAndLockPages(&MemoryDescriptorList, AccessMode, IoReadAccess);
    v12 = 1;
    BaseAddress = MmMapLockedPagesSpecifyCache(&MemoryDescriptorList, 0, MmCached, 0, 0, 0x20u);
    if ( !BaseAddress )
    {
      v13 = 1;
      ExRaiseStatus(STATUS_INSUFFICIENT_RESOURCES);
    }
    KeUnstackDetachProcess(&ApcState);
    KeStackAttachProcess(a3, &ApcState);
    if ( v22 == a2 )
    {
      if ( AccessMode )
      {
        v20 = 1;
        ProbeForWrite(Address, Length, 1u);
        v20 = 0;
      }
    }
    v11 = 1;
    qmemcpy(v17, BaseAddress, v19);
    ms_exc.registration.TryLevel = -1;
    KeUnstackDetachProcess(&ApcState);
    MmUnmapLockedPages(BaseAddress, &MemoryDescriptorList);
    MmUnlockPages(&MemoryDescriptorList);
    Length_1 -= v19;
    v22 += v19;
    v17 += v19;
  }
  *a7 = Length;
  return STATUS_SUCCESS;
}

  经过分析,发现与APC备份恢复的都是在进程挂靠相关函数上:KeStackAttachProcessKeUnstackDetachProcess。我们先看看KeStackAttachProcess

void __stdcall KeStackAttachProcess(PRKPROCESS PROCESS, PRKAPC_STATE ApcState)
{
  _KTHREAD *CurrentThread; // esi
  char PROCESSa; // [esp+10h] [ebp+8h]

  CurrentThread = KeGetCurrentThread();
  if ( KeGetPcr()->PrcbData.DpcRoutineActive )
    KeBugCheckEx(
      5u,
      PROCESS,
      CurrentThread->ApcState.Process,
      CurrentThread->ApcStateIndex,
      KeGetPcr()->PrcbData.DpcRoutineActive);
  if ( CurrentThread->ApcState.Process == PROCESS )
  {
    ApcState->Process = 1;
  }
  else
  {
    PROCESSa = KeRaiseIrqlToDpcLevel();
    if ( CurrentThread->ApcStateIndex )
    {
      KiAttachProcess(CurrentThread, PROCESS, PROCESSa, ApcState);
    }
    else
    {
      KiAttachProcess(CurrentThread, PROCESS, PROCESSa, &CurrentThread->SavedApcState);
      ApcState->Process = 0;
    }
  }
}

  重点我们来看看ApcStateIndex,上一篇我们讲过,当正常状态为0,挂靠状态为1.也就是说,他将会走如下代码:

KiAttachProcess(CurrentThread, PROCESS, PROCESSa, ApcState);

  点击去看看里面有啥代码:

void __stdcall KiAttachProcess(_KTHREAD *thread, PRKPROCESS Process, KIRQL irql, PRKAPC_STATE ApcState)
{
  // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]

  ++Process->StackCount;
  KiMoveApcState(&thread->ApcState, ApcState);
  InitializeListHead(thread->ApcState.ApcListHead);
  InitializeListHead(&thread->ApcState.ApcListHead[1]);
  thread->ApcState.Process = Process;
  thread->ApcState.KernelApcInProgress = 0;
  thread->ApcState.KernelApcPending = 0;
  thread->ApcState.UserApcPending = 0;
  if ( ApcState == &thread->SavedApcState )
  {
    thread->ApcStatePointer[0] = &thread->SavedApcState;
    thread->ApcStatePointer[1] = &thread->ApcState;
    thread->ApcStateIndex = 1;
  }
  if ( Process->State )
  {
    thread->State = 1;
    thread->ProcessReadyQueue = 1;
    v9 = Process->ReadyListHead.Blink;
    thread->WaitListEntry.Flink = &Process->ReadyListHead;
    thread->WaitListEntry.Blink = v9;
    v9->Flink = &thread->WaitListEntry;
    Process->ReadyListHead.Blink = &thread->WaitListEntry;
    if ( Process->State == 1 )
    {
      Process->State = 2;
      v10 = KiProcessInSwapListHead;
      v11 = &Process->SwapListEntry;
      Processa = &Process->SwapListEntry;
      ApcStatea = KiProcessInSwapListHead;
      do
      {
        v11->Next = v10;
        v12 = v10;
        v10 = ApcStatea;
        _ECX = &KiProcessInSwapListHead;
        _EDX = Processa;
        __asm { cmpxchg [ecx], edx }
      }
      while ( ApcStatea != v12 );
      KiSetSwapEvent();
    }
    thread->WaitIrql = irql;
    KiSwapThread();
  }
  else
  {
    v4 = &Process->ReadyListHead;
    while ( 1 )
    {
      v8 = v4->Flink;
      if ( v4->Flink == v4 )
        break;
      v5 = v8->Flink;
      v6 = v8 - 12;
      v7 = v8->Blink;
      v7->Flink = v5;
      v5->Blink = v7;
      BYTE1(v6[37].Flink) = 0;
      KiReadyThread(v6);
    }
    KiSwapProcess(Process, ApcState->Process);
    KiUnlockDispatcherDatabase(irql);
  }
}

  我们就可以看到里面与APC备份相关操作了:

if ( ApcState == &thread->SavedApcState )
  {
    thread->ApcStatePointer[0] = &thread->SavedApcState;
    thread->ApcStatePointer[1] = &thread->ApcState;
    thread->ApcStateIndex = 1;
  }

  我们再来看看KeUnstackDetachProcess这个函数:

void __stdcall KeUnstackDetachProcess(PRKAPC_STATE ApcState)
{
  PRKAPC_STATE v1; // ebx
  _KTHREAD *CurrentThread; // esi
  _KPROCESS *CurrentProcess; // edi
  int v4; // eax
  int v7; // ecx
  _KAPC_STATE *v8; // [esp-Ch] [ebp-20h]
  int v9; // [esp+4h] [ebp-10h]
  int v10; // [esp+Ch] [ebp-8h]
  signed __int8 v11; // [esp+13h] [ebp-1h]

  v1 = ApcState;
  if ( ApcState->Process != 1 )
  {
    CurrentThread = KeGetCurrentThread();
    v11 = KeRaiseIrqlToDpcLevel();
    if ( !CurrentThread->ApcStateIndex
      || CurrentThread->ApcState.KernelApcInProgress
      || CurrentThread->ApcState.ApcListHead[0].Flink != &CurrentThread->ApcState
      || CurrentThread->ApcState.ApcListHead[1].Flink != &CurrentThread->ApcState.ApcListHead[1] )
    {
      KeBugCheck(6u);
    }
    CurrentProcess = CurrentThread->ApcState.Process;
    if ( !--CurrentProcess->StackCount && CurrentProcess->ThreadListHead.Flink != &CurrentProcess->ThreadListHead )
    {
      CurrentProcess->State = 3;
      v4 = KiProcessOutSwapListHead;
      v10 = KiProcessOutSwapListHead;
      do
      {
        CurrentProcess->SwapListEntry.Next = v4;
        v9 = v4;
        v4 = v10;
        _ECX = &KiProcessOutSwapListHead;
        _EDX = &CurrentProcess->SwapListEntry;
        __asm { cmpxchg [ecx], edx }
      }
      while ( v10 != v9 );
      KiSetSwapEvent();
      v1 = ApcState;
    }
    v8 = &CurrentThread->ApcState;
    if ( v1->Process )
    {
      KiMoveApcState(v1, v8);
    }
    else
    {
      KiMoveApcState(&CurrentThread->SavedApcState, v8);
      CurrentThread->SavedApcState.Process = 0;
      CurrentThread->ApcStatePointer[0] = &CurrentThread->ApcState;
      CurrentThread->ApcStatePointer[1] = &CurrentThread->SavedApcState;
      CurrentThread->ApcStateIndex = 0;
    }
    if ( CurrentThread->ApcState.ApcListHead[0].Flink != &CurrentThread->ApcState )
    {
      LOBYTE(v7) = 1;
      CurrentThread->ApcState.KernelApcPending = 1;
      HalRequestSoftwareInterrupt(v7);
    }
    KiSwapProcess(CurrentThread->ApcState.Process, CurrentProcess);
    KiUnlockDispatcherDatabase(v11);
  }
}

  我们很快找到了与APC恢复相关的代码:

if ( v1->Process )
   {
     KiMoveApcState(v1, v8);
   }
   else
   {
     KiMoveApcState(&CurrentThread->SavedApcState, v8);
     CurrentThread->SavedApcState.Process = 0;
     CurrentThread->ApcStatePointer[0] = &CurrentThread->ApcState;
     CurrentThread->ApcStatePointer[1] = &CurrentThread->SavedApcState;
     CurrentThread->ApcStateIndex = 0;
   }

  分析至此,本题就结束了。

QueueUserAPC 引发的血案

  还记着 APC 篇——备用 APC 队列 提供的第一题的参考代码中的一行注释了吗?

DWORD WINAPI ThreadProc(VOID* Param)
{
    for (int i =0 ;i<100;i++)
    {
        SleepEx(1000,TRUE); //思考为什么?
        //Sleep(1000);
        printf("Running\n");
    }
    return 0;
}

  为什么我用SleepEx函数而不是用Sleep吗?你思考这个问题了吗?我们来看看下面几个图:
  我们将SleepEx函数用Sleep替换,并注释掉主函数的Sleep看看效果:

  APC正常被执行,接下来我们去掉注释掉主函数的Sleep,继续运行看看:

  这次竟然发现APC没有执行,到底是为什么呢?我们改回原答案,就可以正常执行APC了,也就是我在参考中给的效果图:

  原因将会在本篇后部分进行揭晓。

KAPC

  无论是正常状态还是挂靠状态,都有两个APC队列,一个内核队列,一个用户队列。每当要挂入一个APC函数时,不管是内核APC还是用户APC,内核都要准备一个KAPC的数据结构,并且将这个KAPC结构挂到相应的APC队列中。现在我们看看KAPC的结构:

kd> dt _KAPC
ntdll!_KAPC
   +0x000 Type             : Int2B
   +0x002 Size             : Int2B
   +0x004 Spare0           : Uint4B
   +0x008 Thread           : Ptr32 _KTHREAD
   +0x00c ApcListEntry     : _LIST_ENTRY
   +0x014 KernelRoutine    : Ptr32     void 
   +0x018 RundownRoutine   : Ptr32     void 
   +0x01c NormalRoutine    : Ptr32     void 
   +0x020 NormalContext    : Ptr32 Void
   +0x024 SystemArgument1  : Ptr32 Void
   +0x028 SystemArgument2  : Ptr32 Void
   +0x02c ApcStateIndex    : Char
   +0x02d ApcMode          : Char
   +0x02e Inserted         : UChar

Type

  指明结构体的类型,APC类型为0x12

Size

  该结构体的大小,值为0x30

Thread

  指向目标线程的线程结构体的指针,因为任何一个APC都是让目标线程进行完成。

ApcListEntry

  APC队列挂的位置。

KernelRoutine

  指向一个函数,调用ExFreePoolWithTag释放APC

NormalRoutine

  存储着用户APC总入口或真正的内核APC函数地址,里面具体的细节将会在后面的文章进行介绍。

NormalContext

  当为内核APC,该成员存储着NULL;如果为用户APC,则为真正的APC函数。

SystemArgument1

  APC函数的参数。

SystemArgument2

  APC函数的参数。

ApcStateIndex

  挂哪个队列,有四个值:0、1、2、3,里面的细节将在后面进行介绍。

ApcMode

  指示该APC是内核APC还是用户APC

Inserted

  表示本APC是否已挂入队列。挂入前值为0,挂入后值为1。

挂入流程

  为了方便理解,我们先撸一下函数大体调用流程:

graph TD QueueUserAPC--用户层调用--> NtQueueApcThread -.->KeInitializeApc -.->KeInsertQueueApc-.->KiInsertQueueApc

  其中QueueUserAPC这个函数位于kernel32.dll,它会调用内核模块的NtQueueApcThread进行实现,经历过重重调用,使用KeInitializeApcAPC结构体分配内存并进行初始化,调用KeInsertQueueApc进行插入到指定队列,而插入最终由KiInsertQueueApc实现。

KeInitializeApc 函数说明

  为了做好本篇练习,我们先过一下KeInitializeApc的相关说明:

VOID KeInitializeApc
(
    IN PKAPC Apc,   //KAPC 指针
    IN PKTHREAD Thread, //目标线程
    IN KAPC_ENVIRONMENT TargetEnvironment,  //四种状态
    IN PKKERNEL_ROUTINE KernelRoutine,  //销毁 KAPC 的函数地址
    IN PKRUNDOWN_ROUTINE RundownRoutine OPTIONAL,
    IN PKNORMAL_ROUTINE NormalRoutine,  //用户 APC 总入口或者内核 APC 函数
    IN KPROCESSOR_MODE Mode,//要插入用户 APC 队列还是内核 APC 队列
    IN PVOID Context//内核APC:NULL,用户APC:真正的APC函数
) 

ApcStateIndex 详解

  该成员与KTHREAD + 0x165偏移处的属性同名,但含义不一样。该ApcStateIndex有四个值,如下面表格所示:

含义
0 原始环境
1 挂靠环境
2 当前环境
3 插入APC时的当前环境

  前两个值挺好理解,当值为0时,就是指线程的“亲生父母”;如果值为1时,就是指自己的“养父母”。后面的两个值比较绕,下面将会详细解释一下:

  上一篇我们说过,线程在正常情况下ApcStatePointer[0]指向ApcStateApcStatePointer[1]指向SavedApcState;而在挂靠情况下ApcStatePointer[0]指向SavedApcStateApcStatePointer[1]指向ApcState。当值为2的时候,插入的是当前进程的队列。什么是当前队列,是我不管你环境是挂靠还是不挂靠,我就插入当前进程的APC队列里面,以初始化APC的时候为基准。还剩下最玄学的一个值,当值为3时,插入的是当前进程的APC队列,此时有修复ApcStateIndex的操作,以插入APC的时候为基准。

KiInsertQueueApc 调用流程

  为了降低本篇思考题难度,我把该函数的调用流程说一下:

  1. 根据KAPC结构中的ApcStateIndex找到对应的APC队列
  2. 再根据KAPC结构中的ApcMode确定是用户队列还是内核队列
  3. KAPC挂到对应的队列中(挂到KAPCApcListEntry处)
  4. 再根据KAPC结构中的Inserted置1,标识当前的KAPC为已插入状态
  5. 修改KAPC_STATE结构中的KernelApcPending/UserApcPending

Alertable 详解

  Alertable属性位于KTHREAD当中,如下所示:

kd> dt _KTHREAD
ntdll!_KTHREAD
   ...
   +0x164 Alertable        : UChar
   ...

  我们可以发现很多与线程相关的结尾带Ex的函数的参数都会有一个bAlertable,举例如下:

DWORD SleepEx(
  DWORD dwMilliseconds, // time-out interval
  BOOL bAlertable        // early completion option
);
DWORD WaitForSingleObjectEx(
  HANDLE hHandle,       // handle to object
  DWORD dwMilliseconds, // time-out interval
  BOOL bAlertable       // alertable option
);

  该值指示线程是否运行被APC吵醒,我们开头说QueueUserAPC 引发的血案解决办法就是由该属性捣的鬼。当该属性为0时,当前插入的用户APC函数未必有机会执当UserApcPending = 0时就会无法执行插入的APC,如果Alertable = 1 ,就会使UserApcPending = 1,从而将目标线程唤醒,从等待链表中被摘出来,并挂到调度链表当中执行。

本节练习

本节的答案将会在下一节进行讲解,务必把本节练习做完后看下一个讲解内容。不要偷懒,实验是学习本教程的捷径。

  俗话说得好,光说不练假把式,如下是本节相关的练习。如果练习没做好,就不要看下一节教程了,越到后面,不做练习的话容易夹生了,开始还明白,后来就真的一点都不明白了。本节练习不多,请保质保量的完成,本篇参考将会在正文给出。

1️⃣ 逆向分析QueueUserAPC完整的调用流程。
2️⃣ 如果在一个无法被唤醒的线程插入一个APC,然后紧接又插入一个,如果设置线程可被唤醒,那么它会执行几个APC呢?请用代码论证。

下一篇

  APC 篇—— APC 执行

posted @ 2022-01-28 16:28  寂静的羽夏  阅读(792)  评论(0编辑  收藏  举报