APC 篇—— APC 掛入

寂靜的羽夏發表於2022-01-28

寫在前面

  此係列是本人一個字一個字碼出來的,包括示例和實驗截圖。由於系統核心的複雜性,故可能有錯誤或者不全面的地方,如有錯誤,歡迎批評指正,本教程將會長期更新。 如有好的建議,歡迎反饋。碼字不易,如果本篇文章有幫助你的,如有閒錢,可以打賞支援我的創作。如想轉載,請把我的轉載資訊附在文章後面,並宣告我的個人資訊和本人部落格地址即可,但必須事先通知我

你如果是從中間插過來看的,請仔細閱讀 羽夏看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 篇—— APC 掛入

  APC正常被執行,接下來我們去掉註釋掉主函式的Sleep,繼續執行看看:

APC 篇—— APC 掛入

  這次竟然發現APC沒有執行,到底是為什麼呢?我們改回原答案,就可以正常執行APC了,也就是我在參考中給的效果圖:

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 執行

相關文章