内存休眠时混淆技术相知
前言
又是许久未更的一篇文章,不出意外的话,这应该是我农历年之前的最后一更了,感谢默默关注我的小伙伴们,在这里提前祝大家新年快乐,希望在新的一年里带给大家更多更好的文章,话不多说开始今天的内容!
从上篇文章中我们了解了基本的内存休眠时混淆技术,通过利用shellcode实现每隔固定时间对模块各个节加解密操作。本节将继续深入这一话题,让我们看看“现代”技术对此进行了哪些升级。
【友情提示】 :代码可在此获得 https://github.com/jseclab/wechat_public/blob/main/sleepcrypt/p2/main.c
正文
概述
所谓更为现代的内存休眠加密技术大概是由从 foliage [1]开始的,而foliage则是对石像鬼技术的升级完善,在之后,foliage作者对号称下一代恶意软件的nighthawk中使用的内存休眠加密技术进行了分析(通过VT上的样本,Nighthawk是闭源软件)后整理成文并发表,随后5pider针对文中所述对此技术进行了复现,也就是可能很多人正在用的ekko(ex),同时 Bokuloader[2] 也集成了此技术。
石像鬼回顾
在开始之前先让我们简单回顾一下 来自2017年的石像鬼技术 ,给没有看过的朋友简单提一下,这项技术在当时主要是为了规避杀毒引擎扫描器扫描可执行内存,石像鬼(gargoyle) 技术则利用一段巧妙构思的shellcode实现定时变换内存节属性来规避扫描,下图基本展示了这一技术的全过程。
-
• 主程序申请内存存放shellcode
-
• shellcode初始化计时器对象和构造堆栈,最终调用VirtualProtec修改内存属性,随后等待计时器对象触发(红色)
-
• 等待期间shellcode内存变为只读状态(灰色)
-
• 计时器触发并执行回调,通过ROP返回到VirtualProtect修改内存属性为可读可执行
-
• 最后再次跳转到shellcode执行,周而复始(当然非首次执行将不再对计时器对象进行初始化操作)。
-
关于石像鬼我们只需要了解两点
1:其shellcode实现了内存属性自修改(我改我自己)
2:没有实现内存加解密
”传统“技术缺陷
1:需要用到shellcode,就得为其分配一段可执行内存(当然也可以用RWX自注入等,非重点不讨论)
2:整个过程shellcode内存本身没有得到加密
3:自己实现加密算法,简单的异或甚至可能会被解密
4:随着依据调用栈检测逐渐流行,shellcode调用来自一段非可执行的内存区域可能会变得可疑
”现代“技术的诞生
前面提到,石像鬼的巧妙构思居然可以让一段shellcode实现定时修改自身的内存保护属性,而且不会导致崩溃,但是不方便的点在于,如果我们想添加要保护的代码到其中,就必须在shellcode中编写,这对开发者来讲及其不友好,当然他只是一个POC,并不需要过多的苛责。
试想一下,如果我们能将其改写为模块本身的代码,而非一段独立运行的代码,同时想想办法在加上加解密的特性,那么我们就可以几乎解决传统内存休眠加密技术中的所有缺点,而这就是”现代“内存休眠加密技术,不使用shellcode,而是利用原生API巧妙实现休眠,不使用自定义的算法,而使用windows自带的加解密接口实现加解密,怎么越听越像LOLBin(Living off the land Binaries)了呢
Ekko(ex)的QueueUserAPC实现
实际上ekko对此技术的实现代码量并不是非常多,但是初看会感到难以理解,不过没关系,我们先从其目的入手,其根本还是在于实现自身内存的加解密,只不过在当中要休眠一段时间在解密然后执行,如上一节所述,我们依然需要一个外协小帮手(其他线程),而自身是实现不了的,不同于使用shellcode,这次我们要用到线程回调。
但是实现这一过程,我们需要调用好几个API,比如修改属性的,加密内存的,还有休眠的,我们的外协小帮手自然是不知道的,那我们主线程如何告知小帮手怎么做呢?答案是通过线程队列,主线程在进入等待状态之前就需要把这些事情给安排好。
关于线程队列,每个线程都有一个自身APC队列,一般线程处于警戒态时候才会执行队列中的回调(Win 11才支持的QueueUserAPC2则可以让线程在非警戒态下直接调用)
既然如此,ekko通过调用CreateTimerQueueTimer告诉小助手要干什么,如下所示,第三个参数callback代表小助手要执行的函数,第四个参数Parameter是回调函数的参数,那么问题就来了,如果我要调用的函数参数有好几个怎么办 :?
BOOL CreateTimerQueueTimer(
[out] PHANDLE phNewTimer,
[in, optional] HANDLE TimerQueue,
[in] WAITORTIMERCALLBACK Callback,
[in, optional] PVOID Parameter,
[in] DWORD DueTime,
[in] DWORD Period,
[in] ULONG Flags
);
这~,就得感谢NtContinue了,在64位下我们通过寄存器传递前四个参数,而这都可以通过修改NtContinue的参数一的Context中的寄存器来实现。
NTSYSAPI
NTSTATUS
NTAPI
NtContinue(
IN PCONTEXT ThreadContext,
IN BOOLEAN RaiseAlert );
而这其中比较难的是Context.Rsp该如何设置?
通过调试Ekko可以发现通过NtContinue调用的函数最终总是会返回到ntdll.RtlpTpTimerCallback某个偏移处。
那么EKKO是如何设置ESP的值,同时这个ESP中存储的刚好是返回地址呢?那下面就需要用到 RtlCaptureContext了,我们先向小助手请求调用RtlCaptureContext,随后我们就能从ContexRecord中获取其Rsp的值,而*(Rsp-0x8)存放的就是返回地址。
NTSYSAPI
VOID
NTAPI
RtlCaptureContext(
_Out_ PCONTEXT ContextRecord
);
如果理解了这些点,那再看EKKO的代码就能理解个七七八八了。
既然如此,那我是否可以利用我们更为常见的QueueUserAPC API来达到同样目的?
当然可以!
-
• 创建线程,使其处于警戒状态
DWORD WINAPI QueueApcThread( LPVOID EvtHandle )
{
WaitForSingleObjectEx(GetCurrentProcess(), INFINITE, TRUE);
return 0;
}
-
• 通过RtlCaptureContext先行获取RSP
QueueUserAPC((PAPCFUNC)RtlCaptureContext, ThreadHd, (ULONG_PTR)&Ctx);
QueueUserAPC(WaitEventApc, ThreadHd, (ULONG_PTR)StartEvtHd); //防止竞争
-
• 依次将其他调用放入队列
QueueUserAPC((PAPCFUNC)NtContinue, ThreadHd, (ULONG_PTR)&Vp);
QueueUserAPC((PAPCFUNC)NtContinue, ThreadHd, (ULONG_PTR)&Enc);
QueueUserAPC((PAPCFUNC)NtContinue, ThreadHd, (ULONG_PTR)&Slp);
QueueUserAPC((PAPCFUNC)NtContinue, ThreadHd, (ULONG_PTR)&Dec);
QueueUserAPC((PAPCFUNC)NtContinue, ThreadHd, (ULONG_PTR)&Vp1);
QueueUserAPC((PAPCFUNC)NtContinue, ThreadHd, (ULONG_PTR)&EndEvt);
-
• 激活对象通知小助手开始干活,并等待其结束
SignalObjectAndWait(StartEvtHd, EndEvtHd, INFINITE, FALSE);
成果展示
引用
[1]:https://github.com/realoriginal/foliage
[2]:https://github.com/boku7/BokuLoader
ekko:https://github.com/Cracked5pider/Ekko/tree/main
原文始发于微信公众号(无名之):内存休眠时混淆技术二:相知
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论