制作完美的注射器:滥用Windows地址清理和CoW

制作完美的注射器:滥用Windows地址清理和CoW

Gat1ta 1,456 2021-01-22

在这篇文章的结尾,我的目标是使注入器与众不同:设计使您的DLL无法从UM调试,使您的页面对NtQueryVirtualMemory和NtReadVirtualMemory不可见,并允许您在目标进程中执行代码,而无需有效的句柄;在执行此操作时,我希望它与Patchguard兼容,在目标运行时不加载内核驱动程序,并且完全不需要任何处理。

现在,这似乎是一个愚蠢的目标,但是实际上非常简单,因为Windows将帮助我们。

(源代码可以在底部找到)

# 0x1:滥用Windows地址清理

在IDA中打开ntoskrnl.exe的任何人都可能注意到以下检查:

__int64 __usercall MiReadWriteVirtualMemory@(ULONG_PTR BugCheckParameter1@, unsigned __int64 a2@, unsigned __int64 a3@, __int64 a4@, __int64 a5, int a6)
{
  ...
    if ( v10 < a3 || v9 > 0x7FFFFFFEFFFFi64 || v10 > 0x7FFFFFFEFFFFi64 )
      return 0xC0000005i64;
  ...
}
__int64 __fastcall MmQueryVirtualMemory(__int64 a1, unsigned __int64 a2, __int64 a3, unsigned __int64 a4, unsigned __int64 a5, unsigned __int64 *a6)
{
  ...
  if ( v12 > 0x7FFFFFFEFFFFi64 )
    return 0xC000000Di64;
  ...
}

好吧,那么这些有趣的是您可能现在要问的是,0x7FFFFFFEFFFF标记了用户模式内存的结束,因此它们很显然可以确保不会将内核内存泄漏到用户模式。

这就是让它们如此有趣的原因:这些常量由操作系统进行硬编码,而不是处理器实际上用来决定是否可以从cpl3访问页面的内容。

如果您不熟悉页表,则以下是虚拟内存的工作方式:

虚拟地址的前12位(&0xFFF)指示与解析页面的偏移量,后四个9位组合(&0x1FF000,&0x3FE00000,&0x7FC0000000,&0xFF8000000000)指示页面表,页面目录,页面目录中条目的索引指针和页面映射级别4。这些条目除了链接到较低级别外,还包含某些标志,例如禁止写入,禁止执行等。从下面的定义中可以看到。

#pragma pack(push, 1)
typedef union CR3_
{
  uint64_t value;
  struct
  {
    uint64_t ignored_1 : 3;
    uint64_t write_through : 1;
    uint64_t cache_disable : 1;
    uint64_t ignored_2 : 7;
    uint64_t pml4_p : 40;
    uint64_t reserved : 12;
  };
} PTE_CR3;
typedef union VIRT_ADDR_
{
  uint64_t value;
  void *pointer;
  struct
  {
    uint64_t offset : 12;
    uint64_t pt_index : 9;
    uint64_t pd_index : 9;
    uint64_t pdpt_index : 9;
    uint64_t pml4_index : 9;
    uint64_t reserved : 16;
  };
} VIRT_ADDR;
typedef uint64_t PHYS_ADDR;
typedef union PML4E_
{
  uint64_t value;
  struct
  {
    uint64_t present : 1;
    uint64_t rw : 1;
    uint64_t user : 1;
    uint64_t write_through : 1;
    uint64_t cache_disable : 1;
    uint64_t accessed : 1;
    uint64_t ignored_1 : 1;
    uint64_t reserved_1 : 1;
    uint64_t ignored_2 : 4;
    uint64_t pdpt_p : 40;
    uint64_t ignored_3 : 11;
    uint64_t xd : 1;
  };
} PML4E;
typedef union PDPTE_
{
  uint64_t value;
  struct
  {
    uint64_t present : 1;
    uint64_t rw : 1;
    uint64_t user : 1;
    uint64_t write_through : 1;
    uint64_t cache_disable : 1;
    uint64_t accessed : 1;
    uint64_t dirty : 1;
    uint64_t page_size : 1;
    uint64_t ignored_2 : 4;
    uint64_t pd_p : 40;
    uint64_t ignored_3 : 11;
    uint64_t xd : 1;
  };
} PDPTE;
typedef union PDE_
{
  uint64_t value;
  struct
  {
    uint64_t present : 1;
    uint64_t rw : 1;
    uint64_t user : 1;
    uint64_t write_through : 1;
    uint64_t cache_disable : 1;
    uint64_t accessed : 1;
    uint64_t dirty : 1;
    uint64_t page_size : 1;
    uint64_t ignored_2 : 4;
    uint64_t pt_p : 40;
    uint64_t ignored_3 : 11;
    uint64_t xd : 1;
  };
} PDE;
typedef union PTE_
{
  uint64_t value;
  VIRT_ADDR vaddr;
  struct
  {
    uint64_t present : 1;
    uint64_t rw : 1;
    uint64_t user : 1;
    uint64_t write_through : 1;
    uint64_t cache_disable : 1;
    uint64_t accessed : 1;
    uint64_t dirty : 1;
    uint64_t pat : 1;
    uint64_t global : 1;
    uint64_t ignored_1 : 3;
    uint64_t page_frame : 40;
    uint64_t ignored_3 : 11;
    uint64_t xd : 1;
  };
} PTE;
#pragma pack(pop)

我们感兴趣的标志是.user,用户/主管标志可以决定是否可以从用户模式访问存储区域。因此,与人们的想法相反,这些检查的微代码将如下所示:

Pte->user & Pde->user & Pdpte->user & Pml4e->user

代替

Va > = 0xFFFFFFFF80000000

听起来对您来说不是吗?因为绝对是。在我们的情况下,我们将使用它来创建一个页面,该页面对于所有用户模式API都是不可见的,其操作非常简单:

BOOL ExposeKernelMemoryToProcess( MemoryController& Mc, PVOID Memory, SIZE_T Size, uint64_t EProcess )
{
  Mc.AttachTo( EProcess );
  BOOL Success = TRUE;
  Mc.IterPhysRegion( Memory, Size, [ & ] ( PVOID Va, uint64_t Pa, SIZE_T Sz )
  {
    auto Info = Mc.QueryPageTableInfo( Va );
    Info.Pml4e->user = TRUE;
    Info.Pdpte->user = TRUE;
    Info.Pde->user = TRUE;
    if ( !Info.Pde || ( Info.Pte && ( !Info.Pte->present ) ) )
    {
      Success= TRUE;
    }
    else
    {
      if ( Info.Pte )
        Info.Pte->user = TRUE;
    }
  } );
  Mc.Detach();
  return Success;
}
PVOID Memory = AllocateKernelMemory( CpCtx, KrCtx, Size );
ExposeKernelMemoryToProcess( Controller, Memory, Size, Controller.CurrentEProcess );
ZeroMemory( Memory, Size );

瞧,现在我们有了我们的超级机密页面。
(我之前使用的是用于物理内存访问的包装器,因此,如果您想了解线性转换或页表条目的解析是如何实现的,可以进行检查。)

0x2:滥用写时复制

既然我们已经完成了隐藏内存的工作,剩下要做的就是实际执行它,而这次我们将滥用写时复制。

CoW是操作系统使用的一种技术,它通过使进程共享某些物理内存区域直到实际对其进行编辑来节省内存。

我们知道ntdll.dll会为每个进程加载,而很少修改其代码(.text)区域,那么为什么要一次又一次为数百个进程分配物理内存呢?这就是为什么现代操作系统使用称为CoW的技术的原因。

实现非常简单:

当PE文件被映射时,如果它也被映射到其他进程,并且其VA在当前进程中也是可用的,则只需复制PFN并将标志设置为只读即可。
当由于指令试图在页面上写入而发生PageFault时,分配新的物理内存,设置PTE的PFN并删除只读标志。
这意味着,当我们使用物理内存挂接DLL时,实际上实际上是创建了系统范围的挂接。

我们如何以此劫持线程?

好吧,让我们选择一个常用的函数并将其挂钩:TlsGetValue。

现在,PML4E随进程的不同而变化,因此不能从所有进程访问我们公开的内核内存,因此我们需要在可爱的内核页面中跳转到存根之前,在KERNEL32.dll中找到一个填充来检查pid。 。

pid检查将非常简单:

std::vector<BYTE> PidBasedHook =
{
  0x65, 0x48, 0x8B, 0x04, 0x25, 0x30, 0x00, 0x00, 0x00,        // mov rax, gs:[0x30]
  0x8B, 0x40, 0x40,                                            // mov eax,[rax+0x40] ; pid
  0x3D, 0xDD, 0xCC, 0xAB, 0x0A,                                // cmp eax, TargetPid
  0x0F, 0x85, 0x00, 0x00, 0x00, 0x00,                          // jne 0xAABBCC
  0x48, 0xB8, 0xAA, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0x00, 0x00,  // mov rax, KernelMemory
  0xFF, 0xE0                                                   // jmp rax
};

由于PE区域始终是0x1000对齐的,因此只要我们寻找0x00(页面填充)而不是0xCC / 0x90(功能内填充),找到35字节的填充就很容易了。

在执行存根中,我们还必须做一些技巧。我们只希望一个线程执行我们的代码,我们想在继续执行之前取消TlsGetValue的钩子,我注意到有时物理内存中的更改不会立即对所执行的指令产生影响,因此我们希望确保将其应用,因此我们将在存根的开头执行三个检查。

std::vector<BYTE> Prologue =
{ 
  0x00, 0x00, // data
  0xF0, 0xFE, 0x05, 0xF8, 0xFF, 0xFF, 0xFF,                     // lock inc byte ptr [rip-n]
                                                                // wait_lock:
  0x80, 0x3D, 0xF0, 0xFF, 0xFF, 0xFF, 0x00,                     // cmp byte ptr [rip-m], 0x0
  0xF3, 0x90,                                                   // pause
  0x74, 0xF5,                                                   // je wait_lock
  0x48, 0xB8, 0xAA, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0x00, 0x00,   // mov rax, 0xAABBCCDDEEAA
                                                                // data_sync_lock:
  0x0F, 0x0D, 0x08,                                             // prefetchw [rax]
  0x81, 0x38, 0xDD, 0xCC, 0xBB, 0xAA,                           // cmp dword ptr[rax], 0xAABBCCDD
  0xF3, 0x90,                                                   // pause
  0x75, 0xF3,                                                   // jne data_sync_lock
  0xF0, 0xFE, 0x0D, 0xCF, 0xFF, 0xFF, 0xFF,                     // lock dec byte ptr [rip-n]
  0x75, 0x41,                                                   // jnz continue_exec                         
  0x53,                                                         // --- start executing DllMain ---

第一个自旋锁wait_lock是确保进入此存根的线程停止执行,直到让它从我们的注射器继续执行为止。第二个自旋锁data_sync_lock用于确保在继续执行之前回写旧的TlsGetValue数据。最后的原子指令lock dec是存根开头的lock inc的补充部分。lock inc存储了自旋锁中等待的线程数,锁自动使该计数递减;这样做是因为如果该值达到零,则将置零标志,并且由于此操作是原子操作,因此仅执行一次,因此我们检查零标志以确定是执行DllMain还是继续执行。

现在我们已经完成了所有技巧,实际喷油器的实现非常简单:

加载易受攻击的驱动程序
将物理内存映射到用户模式
搜索某些偏移量(UniqueProcessId,DirectoryTableBase,ActiveProcessLinks)
保存当前的EProcess和CR3值以供用户模式使用
为我们的注入器存根和映像分配足够的内核池内存
卸载易受攻击的驱动程序
将我们的映像映射到内核内存(修复.relocs并创建一个存根,为我们获取导入,因为我无法打扰阅读EProcess-> Peb)
等待目标进程
将内核页面公开给目标进程
在系统范围内钩住TlsGetValue并使其检查pid,然后再跳转到内核内存中的存根
等待Stub-> SpinningThreadCount不为零
取消挂钩TlsGetValue,将Stub-> Free设置为TRUE
利润
image.png