【高级睡眠混淆】|堆栈不在,何必欺骗?

admin 2025-12-14 00:59:41 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文介绍了高级睡眠混淆技术,重点分析了如何避免EDR系统通过堆栈扫描检测恶意行为。文章对比了Ekko和DeathSleep两种技术,指出Ekko使用计时器队列已被检测工具发现,而DeathSleep利用新线程池API更难被检测。作者提出了改进方法,包括修改计时器参数、使用循环替代等待函数、保存恢复堆栈等,并建议结合两种技术的优点。这些技术对红队攻击中的隐蔽执行有重要价值。 综合评分: 87 文章分类: 红队,免杀,二进制安全,渗透测试,内网渗透


cover_image

【高级睡眠混淆】| 堆栈不在,何必欺骗?

半只红队

半只红队

2025年5月21日 23:21 广东

            (改进的代码、POC也打包一并发布在了圈子内部)

进程中的每个线程都有自己的调用栈。当一个函数被调用时,会为其创建一个frame;这个frame是一个用于存储该函数的一些数据(例如局部变量)的空间。frame基址的正下方包含一个返回地址;即frame调用者的位置。这样,当函数执行完毕后,它就可以返回到调用者。

EDR除了Hook之外还有其他很多功能,它可以利用函数调用的调用堆栈来判断函数是否是恶意的。Elastic已经实现了这个功能,大致猜测这些数据是通过为进程和线程创建事件注册的内核回调、以及ETWTI获取的,甚至可能还有其他方式。

内存查杀、堆栈扫描是一个需要大量关注的点,Hunt-Beacon-Sleep开源工具的发布(它可以主动检查休眠的线程),使得这些未备份区域的调用堆栈有了可疑活动的信息。

所以,产生了堆栈欺骗,可是呢?这个Trampoline Gadget特征、前一个指令是否为call特征也是很明显,下面两张图分别是①正常构造两帧+Trampoline Gadget。②DEFCON会议上提到的StackMoonWalk。这两种欺骗方式使用Hunt-Sleep-Beacon的扫描:

对线程堆栈的扫描不仅仅是在Beacon“睡眠”时期,任何敏感操作如Beacon回连等等都很有可能触发堆栈扫描,无论是主动还是被动。下面提出的思路以Beacon“睡眠时期”为例,根据Ekko、DeathSleep的代码、思路进行了优化、改造。

计时器对象

项目地址:https://github.com/Cracked5pider/Ekko,它的思路是使用计时器队列,计时器队列是一种轻量级对象,用于在指定时间内调用回调函数,通过使用CreateTimerQueue、CreateTimerQueueTimer函数,可以创建并管理计时器队列以及计时器。

代码就不全部贴出来了,这里的xxx.Rsp  -= 8和RtlCaptureContext的细节在于,NtContinue切换执行流之后还需要正常返回,所以使用 RtlCaptureContext() 在工作线程中复制上下文,并将获取的上下文的堆栈指针增加 8,这样它就会指向通过调用 RtlCaptureContext() 在堆栈中引入的地址,也就是最后一个函数的返回地址,我们可以将它用作所有函数的返回地址。

使用这个方法确实是一种不太一样的思路,但是这种思路也已经存在有检测,Hunt-Sleep-Beacon、TickTock对于计时器也已经有了检测:

并且这种方式还是使用到了WaitForSingleObject等一些等待的函数,正所谓计时器就计时器,直接使用计时器代替计时器对象队列中要执行的WaitForSingleObject,所以直接修改CreateTimerQueueTimer中的DueTime参数就行。另外,Beacon自己的线程也有一个WaitForSingleObject,这是为了等待计时器的队列(修改内存属性、加密、解密…)全部执行完成,看到Beacon会产生线程等待就有点不爽,替换方法是使用while循环即可:

虽然扫描出计时器进程,但是并不会仅仅因为这个就会把进程直接列为恶意进程。

堆栈不在,何必欺骗?

接下来进入这一篇文章的主题,《堆栈不在,何必欺骗?》,这个思路一开始是DeathSleep项目提出的,缺点就是不同机器需要不同处理,它使用了新的线程池API函数,并且对他们进行了逆向,与CreateTimerQueueTimer做了对比,解决了参数位置处理上的问题。另外,在我对他使用Hunt-Beacon-Sleep进行扫描时候,发现它并不会触发计时器对象的一些行为。

在睡眠时期非常短,且你疯狂Hunt-Sleep-Beacon扫描就会有一个报“使用NtContinue”,无伤大雅。

大致思路就是:

  • 保存DeathSleep之前的堆栈,并且使用RtlCaptureContext获取一个CONTEXT(threadCtxBackup),将它RIP设为Call DeathSleep的下一条地址。如何获取RIP呢?看下面,其中initialRsp是调用DeathSleep的函数的Rsp,保存多少堆栈呢?翻翻正常的beacon.exe就知道保存大小了,断点一下Sleep函数就行了。
threadCtxBackup.Rip = *(PDWORD64)(initialRsp - 0x8);
stackBackup = malloc(0x150);
memcpy(stackBackup, (PVOID)initialRsp, 0x150);
  • 关于新的线程池API也就是InitializeThreadpoolEnvironment、CreateThreadpool、CreateThreadpoolCleanupGroup、SetThreadpoolTimer哪一些。我们beacon当前线程退出之后,剩下的就交给线程池去做了,我的使用方式是一个ROP链执行修改RW属性、加密操作,之后再调用rebirth函数去恢复。(注意Loader中对于WaitForSingleObject的写法就行,主线程不能退出。)
VirtualProtect -> ROP Gadgets -> NtContinue For encrytionPermCtx -> encrytionPermCtx.Rsp -= 8 (helperCtx) to exit
memcpy(&encrytionPermCtx, &helperCtx, sizeof(CONTEXT));

encrytionPermCtx.Rsp -= 8;
encrytionPermCtx.Rip = (ULONG_PTR)winApi.pfnSystemFunction032;
encrytionPermCtx.Rcx = (ULONG_PTR)(&pData);
encrytionPermCtx.Rdx = (ULONG_PTR)(&pKey);

ropMemBlock = malloc(0x1000);
// 【+】 ROP: VirtualProtect -> ROP Gadgets -> NtContinue For encrytionPermCtx -> encrytionPermCtx.Rsp -= 8 (helperCtx) to exit
PVOID ropStackPtr = InitilizeRopStack(ropMemBlock, 0x1000, winApi.pfnNtContinue, &encrytionPermCtx, rcxGadgetAddr, shadowFixerGadgetAddr);

RtlCaptureContext(&changePermRwCtx);
changePermRwCtx.Rsp = ropStackPtr;
changePermRwCtx.Rip = (DWORD_PTR)VirtualProtect;
changePermRwCtx.Rcx = (DWORD_PTR)pRdiAddress;
changePermRwCtx.Rdx = dwRdiSize;
changePermRwCtx.R8 = PAGE_READWRITE;
changePermRwCtx.R9 = (DWORD_PTR)&OldProtect;
  • 在恢复beacon的函数中,原作者的创新点是将当前的栈挪一个位置(具体挪多少,作者有一个py脚本,用来定位标记以及使用Unwind Info执行替换),如图。当然你不挪Awake也可以,你可以把要恢复的beacon栈区离Awake Rsp远远的,确保不要覆盖到就行。之后调用NtContinue对先前保存的线程上下文threadCtxBackup恢复就好。

项目地址:https://github.com/janoglezcampos/DeathSleep,网上可以搜得到。我也将这个项目的代码改了一下,放在了内部圈子之中,更容易懂代码没有这么冗余。对于嵌入UDRL中还是比较容易的。

结合一下?

看到这里,已经知道Ekko的计时器会被扫描出来,DeathSleep的并不会,所以,结合一下?

圈子介绍

圈子内部致力于红蓝对抗,武器免杀与二开,不定期分享前沿技术文章,经验总结,学习笔记以及自研工具与插件,进圈联系~

圈子已满200余人,目前价格199,学生优惠30

后续升价


评论:0   参与:  8