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
备份恢复的都是在进程挂靠相关函数上:KeStackAttachProcess
和KeUnstackDetachProcess
。我们先看看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。
挂入流程
为了方便理解,我们先撸一下函数大体调用流程:
其中QueueUserAPC
这个函数位于kernel32.dll
,它会调用内核模块的NtQueueApcThread
进行实现,经历过重重调用,使用KeInitializeApc
为APC
结构体分配内存并进行初始化,调用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]
指向ApcState
,ApcStatePointer[1]
指向SavedApcState
;而在挂靠情况下ApcStatePointer[0]
指向SavedApcState
,ApcStatePointer[1]
指向ApcState
。当值为2的时候,插入的是当前进程的队列。什么是当前队列,是我不管你环境是挂靠还是不挂靠,我就插入当前进程的APC
队列里面,以初始化APC
的时候为基准。还剩下最玄学的一个值,当值为3时,插入的是当前进程的APC
队列,此时有修复ApcStateIndex
的操作,以插入APC
的时候为基准。
KiInsertQueueApc 调用流程
为了降低本篇思考题难度,我把该函数的调用流程说一下:
- 根据
KAPC
结构中的ApcStateIndex
找到对应的APC
队列 - 再根据
KAPC
结构中的ApcMode
确定是用户队列还是内核队列 - 将
KAPC
挂到对应的队列中(挂到KAPC
的ApcListEntry
处) - 再根据
KAPC
结构中的Inserted
置1,标识当前的KAPC
为已插入状态 - 修改
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 执行
本文来自博客园,作者:寂静的羽夏 ,一个热爱计算机技术的菜鸟
转载请注明原文链接:https://www.cnblogs.com/wingsummer/p/15853079.html