一种高级内存驻留规避技术的概念验证实现,用于欺骗线程调用栈。该技术可绕过基于线程的内存检查规则,在进程内存中更好地隐藏 shellcode。
本示例实现了线程栈欺骗技术,旨在规避恶意软件分析师、AV 和 EDR 通过检查线程调用栈中 shellcode 帧的引用。其核心思路是隐藏线程调用栈中指向 shellcode 的引用,从而伪装包含恶意代码的内存分配。
本实现与我的 ShellcodeFluctuation 一起,为红队工具提供了与商业 C2 产品相媲美的示例,让我们在红队工具中不落下风。💪
当前实现与最初发布的版本有较大差异。原因是我发现有一种更简单的方法可以终止线程调用栈的处理过程,并隐藏 shellcode 相关的帧——只需将我们控制的第一个帧的返回地址写入 0 即可:
void WINAPI MySleep(DWORD _dwMilliseconds)
{
[...]
auto overwrite = (PULONG_PTR)_AddressOfReturnAddress();
const auto origReturnAddress = *overwrite;
*overwrite = 0;
[...]
*overwrite = origReturnAddress;
}
之前使用 StackWalk64 的实现可在此 commit c250724 中查看。
当前实现更加稳定,在 Debug 和 Release 模式下,以及 和 两种架构上均能正常工作。
x64x86这是未欺骗时的调用栈示例:

这是启用线程栈欺骗后的调用栈:

如上所示,调用栈中的最后一帧是我们的 MySleep 回调函数。人们可能会想,这是否立即引入了新的危害指标(IOC)?检测规则可能会寻找线程调用栈没有展开到系统库中预期的线程入口点的情况:
kernel32!BaseThreadInitThunk+0x14
ntdll!RtlUserThreadStart+0x21
然而,被欺骗线程的调用栈乍看之下可能有些奇怪,但我在系统上简要检查后发现,其他线程的调用栈也未展开到上述入口点:

上图为未修改的 Total Commander x64 的一个线程。如图所示,其初始调用栈帧与我们的非常相似。
当存在可以简单模仿的行为进程时,我们何必还要费心伪造调用栈呢?
大致算法如下:
dbghelp.dll 获取所有必要的函数指针,调用 SymInitialize。kernel32!Sleep,指向我们自己的回调函数。VirtualAlloc + memcpy + CreateThread 注入并启动 shellcode。线程应从 runShellcode 函数开始,以避免线程的 StartAddress 指向异常位置(如 ntdll!RtlUserThreadStart+0x21)。MySleep 回调函数被调用。0,从而终止调用栈。::SleepEx,让 Beacon 在等待后续通信时休眠。函数返回地址分布在线程栈内存区域中,由 RBP/EBP 寄存器指向。要在栈上找到它们,首先需要收集帧指针,然后对其解引用以进行覆盖:

(上图摘自 Eli Bendersky 的文章 x86-64 上的栈帧布局)
*(PULONG_PTR)(frameAddr + sizeof(void*)) = Fake_Return_Address;
ThreadStackSpoofer 的初始实现在 walkCallStack 和 spoofCallStack 函数中完成了这些操作,但当前实现表明,这些努力并非维护隐匿调用栈所必需。
用法:
C:\> ThreadStackSpoofer.exe <shellcode> <spoof>
其中:
<shellcode> 是 shellcode 文件的路径<spoof> 当为 1 或 true 时启用线程栈欺骗,其他值禁用。启用 Beacon 线程调用栈欺骗的示例运行:
PS D:\dev2\ThreadStackSpoofer> .\x64\Release\ThreadStackSpoofer.exe .\tests\beacon64.bin 1
[.] 读取 shellcode 字节...
[.] 钩住 kernel32!Sleep...
[.] 注入 shellcode...
[+] Shellcode 正在运行。
[>] 原始返回地址: 0x1926747bd51。终止调用栈...
===> MySleep(5000)
[<] 恢复原始返回地址...
[>] 原始返回地址: 0x1926747bd51。终止调用栈...
===> MySleep(5000)
[<] 恢复原始返回地址...
[>] 原始返回地址: 0x1926747bd51。终止调用栈...
查看代码及其实现,理解概念后,将这个概念重新实现到你用于红队任务的 Shellcode 加载器中。这是高级内存驻留规避的又一种技术,可提高团队不被反病毒软件、EDR 和恶意软件分析师发现的机会。
在开发高级 shellcode 加载器时,你可能还需要实现:
BeaconEyeRW(从 RX/RWX)并加密其内容 - 使用 Shellcode Fluctuation 技术 —— 在休眠前完成(可规避诸如 Moneta 或 pe-sieve 等扫描器)正如有人指出的,这里的技术并未真正名副其实地成为栈欺骗器。因为我们仅仅覆盖了线程栈上的返回地址,并没有欺骗栈本身的其他区域。此外,我们使调用栈变得不可展开,这看起来很奇怪,因为系统无法正确遍历整个调用栈帧链。
不过,我意识到这些缺点,目前保持现状,因为我主要关心的是规避自动扫描器——它们可能遍历进程、枚举其线程、遍历线程栈并捕捉任何指向非镜像内存(如 SEC_PRIVATE——由 VirtualAlloc 等动态分配的内存)的返回地址。专注的恶意软件分析师会立即发现异常并认为该线程可疑,进而追踪我们的植入物。我对此非常确定。然而,我不认为目前诸如 AV/EDR 等自动扫描器会实现能够实际遍历每个线程栈以验证其是否可展开的启发式逻辑 ¯\_(ツ)_/¯ 。
当然,这个项目(以及 C2 框架中的商业实现)给了 AV 和 EDR 供应商考虑实现覆盖这种新颖规避技术的适当启发式逻辑的理由。
要改进此技术,可以尝试实现真正的线程栈欺骗器,通过插入精心伪造的栈帧(通过逆向展开过程构建)来达成。阅读以下更多想法。
与 namazso 数小时的讨论让我明白,要实现一个合适的线程栈欺骗器,我们需要逆向 x64 调用栈展开过程。 首先,需要仔细理解下方链接(a)中解释的栈展开过程。系统在遍历 x64 架构上的线程调用栈时,不会仅仅依赖线程栈上散布的返回地址,而是:
RUNTIME_FUNCTION、UNWIND_INFO 和 UNWIND_CODE 结构。这些结构描述了函数的起始地址、结束地址,以及所有修改 RBP 或 RSP 的代码序列。UNWIND_CODE,以精确计算该帧的返回地址和栈指针值的位置。为了干扰这个过程,我们需要逆转它——即拥有我们自己的 RtlVirtualUnwind 逆向形式。我们需要遍历模块(比如 kernel32)中定义的函数,扫描每个函数的 UNWIND_CODE 代码,并仔细地逆向模拟它们(与 RtlVirtualUnwind 和具体来说 RtlpUnwindPrologue 相反),以找到栈上应该放置我们虚假返回地址的位置。
namazso 提到,需要引入 3 个虚假栈帧来很好地缝合调用栈:
MySleep 调用者不同(具有不同的 UWOP - 展开操作代码)。我们通过查看模块中的所有函数,检查它们的 UWOP,计算虚假帧应该有多大。该帧的 UWOP 必须不同于我们的 MySleep 调用者。RBP 来展开的函数——基本通过 UWOP_PUSH_NONVOL 代码实现。UWOP_SET_FPREG 从 RBP 恢复 RSP 的函数。恢复后的 RSP 必须设置为从控制流进入 MySleep 时的 RSP 值,这样所有我们的帧都会被隐藏,作为第三个小工具展开的结果。
要开始这个过程,可以通过解引用 IMAGE_DIRECTORY_ENTRY_EXCEPTION 数据目录项来遍历可执行文件的 .pdata。考虑下面的示例:
ULONG_PTR imageBase = (ULONG_PTR)GetModuleHandleA("kernel32");
PIMAGE_NT_HEADERS64 pNthdrs = PIMAGE_NT_HEADERS64(imageBase + PIMAGE_DOS_HEADER(imageBase)->e_lfanew);
auto excdir = pNthdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION];
if (excdir.Size == 0 || excdir.VirtualAddress == 0)
return;
auto begin = PRUNTIME_FUNCTION(excdir.VirtualAddress + imageBase);
auto end = PRUNTIME_FUNCTION(excdir.VirtualAddress + imageBase + excdir.Size);
UNWIND_HISTORY_TABLE mshist = { 0 };
DWORD64 imageBase2 = 0;
PRUNTIME_FUNCTION currFrame = RtlLookupFunctionEntry(
(DWORD64)caller,
&imageBase2,
&mshist
);
UNWIND_INFO *mySleep = (UNWIND_INFO*)(currFrame->UnwindData + imageBase);
UNWIND_CODE myFrameUwop = (UNWIND_CODE)(mySleep->UnwindCodes[0]);
log("1. MySleep RIP UWOP: ", myFrameUwop.UnwindOpcode);
for (PRUNTIME_FUNCTION it = begin; it < end; ++it)
{
UNWIND_INFO* unwindData = (UNWIND_INFO*)(it->UnwindData + imageBase);
UNWIND_CODE frameUwop = (UNWIND_CODE)(unwindData->UnwindCodes[0]);
if (frameUwop.UnwindOpcode != myFrameUwop.UnwindOpcode)
{
// 找到用于去同步小工具帧的候选函数
}
}
这个过程有点复杂,但归根结底是逆向线程调用栈展开过程,用精心选择的其它帧替换任意栈帧,类似于 ROP 的方法。
本 PoC 未遵循此算法进行复制,因为根据我目前的理解,我接受调用栈终止于基于 EXE 的栈帧,并且我不想使我的 shellcode 加载器或本 PoC 过于复杂。把这个实现并公开分享的练习留给有兴趣的读者。或者,如果我有更多空闲时间,也许会亲自尝试实现。
更多信息:
如果你计划将这一功能添加到自己的 shellcode 加载器/工具中,务必避免取消挂钩 kernel32.dll。尝试解除 kernel32 的钩子会恢复原始的 Sleep 功能,从而阻止我们的回调被调用。如果回调不被调用,线程将无法自行欺骗自己的调用栈。
如果你需要那样做,则可能需要运行另一个监控线程,确保 Beacon 线程在休眠时被欺骗。
如果你使用 Cobalt Strike 和 Raphael Mudge 的 BOF unhook-bof,请查看我的 Pull Request,它为 BOF 添加了一个可选参数,用于指定不应取消挂钩的库。
这样,你可以在 kernel32 中保留钩子:
beacon> unhook kernel32
[*] 运行 unhook。
将跳过以下模块: wmp.dll, kernel32.dll
[+] 主机回连,发送 9475 字节
[+] 收到输出:
ntdll.dll <.text>
Unhook 完成。
此 PoC 旨在与 Cobalt Strike 的 Beacon shellcode 配合使用。Beacon 会调用 kernel32!Sleep 以等待来自 C2 的进一步指令。本加载器利用此事实,钩住 Sleep 来执行其内部维护。
如果其他 shellcode(如 Meterpreter)不使用 Sleep 来冷却,则此实现可能不适用。由于这仅是展示技术的概念验证,我不打算添加对其他 C2 框架的支持。
当你理解了概念后,肯定能够将其转化为你的 shellcode 需求,并为你所用。
请不要打开与“此代码不适用于 XYZ shellcode”相关的 Github 问题,它们将被立即关闭。
这个项目和其他项目都是无数个不眠之夜和大量辛勤工作的成果。如果你喜欢我所做的事情,并欣赏我始终回馈社区的做法, 请考虑请我喝杯咖啡(或者更好,请我喝杯啤酒)来表达感谢!💪
Mariusz Banach / mgeeky, 21
<mb [at] binary-offensive.com>
(https://github.com/mgeeky)