x64 Call Stack Spoofing
前言
在我之前的博客中,我讨论了 x64 返回地址欺骗的实现。虽然这种技术可以欺骗返回地址,但它有一个重大缺陷:欺骗返回地址会破坏调用栈链,从而导致容易被检测。在本博客中,我们将在返回地址欺骗的基础上,探讨如何欺骗线程的调用栈。
这项技术并不新颖,namazso
、KlezVirus
、waldoirc
、trickster012
等人已经进行了广泛的研究。本博客的目的是将这项技术分解为更简单的部分,并讨论如何在调用任何 WinAPI 时实现调用栈欺骗。
本项目的代码可以在我的GitHub上找到。
简介
本文将深入探讨如何通过创建合成栈帧来掩盖 API 调用的来源。通过这样做,我们可以欺骗那些监控调用栈以检测返回地址篡改的安全解决方案。首先,让我们观察"返回地址欺骗"导致的破损调用栈。
上图显示的线程调用栈是不完整栈展开的示例。"0x4"值是泄露的内存值,表明栈展开被终止。相比之下,正常展开的线程应该如下所示:
通过在调用 API 时欺骗调用栈,我们将创建具有正确栈展开的合成栈帧,然后欺骗返回地址。
在深入实现之前,理解 x64 栈的工作原理至关重要。
x64 栈帧
栈是进程内的一个内存区域,用于为函数分配存储其依赖项的空间。这些依赖项包括为局部变量分配空间和保存非易失性寄存器。如果函数修改了非易失性寄存器,它将从栈上保存的值中恢复。
每个函数都有自己的栈帧,当函数执行完成时,这个帧会被释放。下面是一个简单的演示,展示了"Func"函数的栈帧是如何分配和释放的。
以下是一个简单函数的反汇编代码,可以分为三部分。第一部分是函数的序言,第二部分是函数的主体,最后是函数的尾声。函数的序言负责保存非易失性寄存器并在栈上分配空间。而函数的尾声将反转这些指令以释放栈空间并恢复非易失性寄存器的值。
调用栈
调用栈代表线程到达其当前执行状态所调用的所有函数。在下图中,执行当前在"NtUserWaitMessage+x014"处等待;该函数由"DialogBoxIndirectParamAorW"调用。往下看,我们可以看到"MessageBoxA"是由我们的代码中的"main"函数调用的。最后两个帧是作为线程初始化帧调用的。
在当前执行状态下,线程的栈是这样的
实现栈欺骗的一个快速但不完善的方法是使用这些值创建合成调用栈。然而,这不是一个健壮的实现,在不同的 Windows 版本/构建中会失败。为了避免这些问题,我们需要动态识别每个栈帧的大小,即"RtlUserThreadStart"帧、"BaseThreadInitThunk"帧等的大小。
要动态计算栈大小,我们需要理解 Windows 中的异常处理和".PDATA"节。
异常处理与.PDATA
当异常被引发时,"异常调度器"检查当前函数中是否定义了任何异常处理程序。如果没有处理程序,则展开函数的栈以恢复调用者帧的栈,并在调用者函数中检查异常处理程序。这个过程会重复进行,直到找到异常处理程序或整个调用栈被展开。展开函数栈所需的信息定义在".PDATA"节中。这个节包含一个"RUNTIME_FUNCTION"结构的数组。
这个结构包含函数开始和结束指令地址的偏移量。此外,它还包含"UNWIND_INFO"结构的偏移量。
"UNWIND_INFO"结构包含"UnwindCodes"数组及其计数。展开代码表示在函数序言中执行的指令。通过遍历这些,我们可以计算栈的大小。我们将只考虑以下四个修改函数栈大小的展开代码:
-
UWOP_ALLOC_SMALL
-
UWOP_PUSH_NONVOL
-
UWOP_ALLOC_LARGE
-
UWOP_PUSH_MACHFRAME
计算函数栈的大小
第一步是获取".PDATA"节的地址。这将使用以下代码完成:
typedefstruct _EXCEPTION_INFO {
UINT64 hModule;
UINT64 pExceptionDirectory;
DWORD dwRuntimeFunctionCount;
}EXCEPTION_INFO, *PEXCEPTION_INFO;
PVOID RetExceptionAddress(PEXCEPTION_INFO pExceptionInfo) {
UINT64 pImgNtHdr, hModule;
PIMAGE_OPTIONAL_HEADER64 pImgOptHdr;
hModule = pExceptionInfo->hModule;
pImgNtHdr = hModule + ((PIMAGE_DOS_HEADER)hModule)->e_lfanew;
pImgOptHdr = &((PIMAGE_NT_HEADERS64)pImgNtHdr)->OptionalHeader;
pExceptionInfo->pExceptionDirectory = hModule + pImgOptHdr->DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION].VirtualAddress;
pExceptionInfo->dwRuntimeFunctionCount = pImgOptHdr->DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION].Size / sizeof(RUNTIME_FUNCTION);
}
使用".PDATA"节,我们可以计算函数的栈大小
DWORD RetStackSize(UINT64 hModule, UINT64 pFuncAddr) {
EXCEPTION_INFO sExceptionInfo = { 0 };
sExceptionInfo.hModule = hModule;
RetExceptionAddress(&sExceptionInfo);
PRUNTIME_FUNCTION pRuntimeFunction = (PRUNTIME_FUNCTION)sExceptionInfo.pExceptionDirectory;
DWORD dwStackSize = 0, dwFuncOffset = pFuncAddr - hModule;
PUNWIND_INFO pUnwindInfo;
PUNWIND_CODE pUnwindCode;
// Loop Through RunTimeFunction structures until we find the structure for our target function
for (int i = 0; i < sExceptionInfo.dwRuntimeFunctionCount; i++) {
if (dwFuncOffset >= pRuntimeFunction->BeginAddress && dwFuncOffset <= pRuntimeFunction->EndAddress) {
break;
}
pRuntimeFunction++;
}
// From the RunTimeFunction structure we need the offset to UnwindInfo structure
pUnwindInfo = ((PUNWIND_INFO)(hModule + pRuntimeFunction->UnwindInfoAddress));
pUnwindCode = pUnwindInfo->UnwindCode; // UnwindCode Array
// Loop Through the UnwindCodesArray and calculate Stack Size
for (int i = 0; i < pUnwindInfo->CountOfUnwindCodes; i++) {
UBYTE bUnwindCode = pUnwindCode[i].OpInfo;
switch (bUnwindCode)
{
case UWOP_ALLOC_SMALL:
dwStackSize += (pUnwindCode[i].OpInfo + 1) * 8;
break;
case UWOP_PUSH_NONVOL:
if (pUnwindCode[i].OpInfo == 4)
return 0;
dwStackSize += 8;
break;
case UWOP_ALLOC_LARGE:
if (pUnwindCode[i].OpInfo == 0) {
dwStackSize += pUnwindCode[i + 1].FrameOffset * 8;
i++;
}
else {
dwStackSize += *(ULONG*)(&pUnwindCode[i + 1]);
i += 2;
}
break;
case UWOP_PUSH_MACHFRAME:
if (pUnwindCode[i].OpInfo == 0)
dwStackSize += 40;
else
dwStackSize += 48;
case UWOP_SAVE_NONVOL:
i++;
break;
case UWOP_SAVE_NONVOL_FAR:
i += 2;
break;
default:
break;
}
}
}
使用上述代码片段,我们可以在运行时动态计算出任何函数的栈大小。
Gadget
为了隐藏我们代码的返回地址,我们将使用 JOP 跳转小工具作为返回地址,这些小工具会将执行流程重新定向回我们的代码。JOP 跳转小工具的一个例子是jmp QWORD PTR [rbx]
。当这条指令被执行时,控制流会被转移到 rbx 寄存器中值所指向的地址。此外,我们可以使用任何非易失性寄存器来实现这一点。
使用以下代码片段,我们可以获取任何模块中跳转小工具的地址。
PVOID RetGadget(UINT64 hModule) {
PVOIDpGadget= NULL;
intr= rand() % 2, count = 0;
DWORDdwSize= ((PIMAGE_NT_HEADERS64)(hModule + ((PIMAGE_DOS_HEADER)hModule)->e_lfanew))->OptionalHeader.SizeOfImage;
for (inti=0; i < dwSize - 1; i++) {
if (((PBYTE)hModule)[i] == 0xff && ((PBYTE)hModule)[i+1] == 0x23) {
pGadget = hModule + i;
if (count >= r) {
break;
}
count ++;
}
}
return pGadget;
}
欺骗调用栈
在本节中,我们将介绍创建合成栈帧的步骤。
创建合成栈帧将使用我们的"Spoof"函数来完成,该函数将用汇编语言编写。这个函数执行以下步骤:
-
在栈上压入"0",这将终止栈展开。
-
在栈上为"RtlUserThreadStart"帧预留空间。
-
在栈上压入返回地址"RtlUserThreadStart+0x21"。
-
在栈上为"BaseThreadInitThunk"帧预留空间。
-
在栈上压入返回地址"BaseThreadInitThunk+0x14"。
-
在栈上为我们的小工具帧预留空间。
-
在栈上压入指向我们小工具的返回地址。
上图展示了栈的欺骗部分。在执行 WinAPI 之前,我们需要配置所需的参数。Windows x64 使用 fastcall 调用约定。前四个参数存储在寄存器rcx
、rdx
、r8
和r9
中。任何额外的参数都从右到左压入栈中。然后,在栈上分配 4 字节的空间,这被称为影子空间。之后,调用 WinAPI。
由于我们已经创建了欺骗的栈帧,我们不能压入或弹出任何值,因为这会破坏链条。相反,我们需要在现有栈上配置额外的参数,如上图所示。
汇编
当使用"C"等高级语言编写代码时,所有步骤,包括寄存器管理和函数调用或返回时的栈修改,都被抽象化了。然而,我们需要控制栈的行为;因此,我们将完全用汇编语言编写这一部分。
通过使用结构体向我们的"Spoof"函数传递参数,访问所有参数的过程变得更简单。
以下一系列指令创建我们的合成栈帧。
现在,我们需要配置目标函数所需的参数。
一半的工作已经完成。从技术上讲,到目前为止我们所做的工作将欺骗栈并成功执行我们的目标 API。然而,当 API 调用返回时,程序将崩溃。这是因为我们还没有配置我们的小工具。
为了避免崩溃,我们必须将栈恢复到其原始状态。因此,我们将在rbx
中存储恢复栈的指针。当小工具执行时,控制流会返回给我们。
现在,是时候执行我们的目标 API 了,这将通过使用jmp
指令跳转到目标 API 的地址来完成。注意,我们使用的是jmp
指令而不是call
指令。如果使用call
,它会将当前函数的地址压入栈中。相反,通过使用jmp
,我们的小工具的地址将作为返回地址,表明小工具的函数负责该调用。
我们现在已经执行了目标 API 并获得了控制流的返回。剩下的就是将栈恢复到其原始状态。
整合所有内容
我们已经准备好了所有的技巧部件。现在,我们将使用一个函数来编排我们的马戏团。
PVOID CallStackSpoof(UINT64 pTargetFunction, DWORD dwNumberOfArgs, ...) {
srand((time(0)));
va_list va_args;
STACK_INFO sStackInfo = { 0 };
UINT64 pGadget, pRtlUserThreadStart, pBaseThreadInitThunk;
UINT64 pNtdll, pKernel32;
pNtdll = GetModuleHandleA("ntdll");
pKernel32 = GetModuleHandleA("kernel32");
pGadget = RetGadget(pKernel32);
pRtlUserThreadStart = GetProcAddress(pNtdll, "RtlUserThreadStart");
pBaseThreadInitThunk = GetProcAddress(pKernel32, "BaseThreadInitThunk");
sStackInfo.pGadgetAddress = pGadget;
sStackInfo.dwGadgetSize = RetStackSize(pKernel32, pGadget);
sStackInfo.pRtlUserThreadStart = pRtlUserThreadStart + 0x21;
sStackInfo.dwRtlUserThreadStartSize = RetStackSize(pNtdll, pRtlUserThreadStart);
sStackInfo.pBaseThreadInitThunk = pBaseThreadInitThunk + 0x14;
sStackInfo.dwBaseThreadInitThunk = RetStackSize(pKernel32, pBaseThreadInitThunk);
sStackInfo.pTargetFunction = pTargetFunction;
if (dwNumberOfArgs <= 4)
sStackInfo.dwNumberOfArguments = 4;
elseif (dwNumberOfArgs % 2 != 0)
sStackInfo.dwNumberOfArguments = dwNumberOfArgs + 1;
else
sStackInfo.dwNumberOfArguments = dwNumberOfArgs;
sStackInfo.pArgs = malloc(8 * sStackInfo.dwNumberOfArguments);
va_start(va_args, dwNumberOfArgs);
for (int i = 0; i < dwNumberOfArgs; i++) {
(&sStackInfo.pArgs)[i] = va_arg(va_args, UINT64);
}
va_end(va_args);
return Spoof(&sStackInfo);
}
结果
#include"spoofer.h"
int main() {
HMODULE pUser32 = LoadLibraryA("User32");
UINT64 pMessageBoxA = GetProcAddress(pUser32, "MessageBoxA");
CallStackSpoof(pMessageBoxA, 4, NULL, "Text", "Caption", MB_YESNO);
}
让我们观察调用"MessageBoxA"时的调用栈。
危害指标
与所有技术一样,调用栈伪造也有特定的危害指标。
所有返回地址出现在调用栈上的唯一原因是存在 call 指令。然而,在我们的 gadget 情况下,会缺少一个 call 指令。
RtlUserThreadStart
BaseThreadInitThunk
Gadget 的地址
从上图可以看出,缺少"call"指令表明没有 call 指令将 gadget 的地址压入栈中。
参考资料
-
SilentMoonWalk 作者:KlezVirus、Waldo-IRC 和 Trickster0
-
Nigerald 的栈伪造介绍
-
CodeMachine 的 x64 深入解析
-
ReactOS
原文始发于微信公众号(securitainment):x64 调用栈欺骗
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论