You can Run, but you can't Hide.

You can Run, but you can’t Hide

blackhat asia 2023 May 11-12由Elastic的EDR安全工程师john uhlmann分享的一篇议题,内容是关于如何在内存中检测shellcode。

Why do security products scan memory?

内存扫描技术主要还是运用在Windows系统中,这是因为Windows系统做的一些局限,比如在Windows x64系统中,微软做的一些限制:

  1. 加固了内核;
  2. 实现了一些虚拟化技术,比如Hiper-V;
  3. 私有化的允许内存对安全产品来说是无法防御的;

综上考虑,只剩下内存扫描这样一个不是办法的办法了,尽管内存扫描不是很完美(效率低、开销大),但仍然是一个有价值的防御面。

Overview of memory scanners

传统扫描器:

  1. YARA:内存扫描特征码,Elastic也开源了自己的yara库:https://github.com/elastic/protections-artifacts。
  2. PE-sieve:扫描程序是否被注入、hook,当然PE-sieve并不是一个判断程序是否恶意的最终方案,毕竟它无法判断注入、hook是恶意还是合法的。
  3. Moneta:基于一些非法的内存(比如在“private commit”的内存运行“image commit”的代码)以及IOC进行内存扫描。

Evasion recap

针对传统的内存扫描,红队也有了一些绕过的方案:

  1. Gargoyle:使用ROP以及APC修改内存的权限来执行代码,这样可以绕过一些仅扫描可执行代码页的内存扫描。
  2. obfuscate-and-sleep:cobalt-strike-3-12后引入的策略,在休眠时混淆shellcode,直到agent需要回连在短暂的解混淆执行shellcode,这样可以绕过一些非实时监控的内存扫描。

Beacon spends most of its time sleeping. When obfuscate-and-sleep is enabled, Beacon will obfuscate itself, in memory, before it goes to sleep. When the agent wakes up, it will restore itself to its original state.

  1. FOLIAGE:通过APC以及计时器进行修改代码所在的空间然后绕过,比如:使用NtContinue恢复代码上下文。
  2. Shellcode Fluctuation:使用sleepStub函数使得加密代码区的读写权限变动以绕过Moneta以及PE-sieve。
  3. DeepSleep:使用类似Gargoyle的技术在x64中绕过权限检测的内存扫描。
  4. Ekko:使用CreateTimerQueueTimer函数来让加密的代码权限更改。

Kyle Avery - Avoiding Memory Scanners: Customizing Malware to Evade YARA, PE-sieve, and More

https://forum.defcon.org/node/241824

Gargoyle

其实用的就是一些exp里ROP关闭DEP的方式来对内存页的权限修改的方法,来绕过一些只扫可执行内存页的内存扫描。

一些过DEP的方法:

(1)通过跳转到ZwSetInformationProcess函数将DEP关闭后再转入shellcode执行。
(2)通过跳转到VirtualProtect函数来将shellcode所在内存页设置为可执行状态,然后再转入shellcode 执行。
(3)通过跳转到VirtualAlloc函数开辟一段具有执行权限的内存空间,然后将shellcode复制到这段内存中执行。

Gargoyle用的是VirtualProtect这个方法。主要的策略是使用CreateWaitableTimer来创建一个计时器,然后使用SetWaitableTimer来queue APC。其中,SetWaitableTimer声明如下:

1
2
3
4
5
6
7
8
BOOL WINAPI SetWaitableTimer(
_In_ HANDLE hTimer, //定时器对象的句柄。可以使用CreateWaitableTimer函数创建一个定时器对象,并将其句柄传递给该参数。
_In_ const LARGE_INTEGER *pDueTime, //指向一个LARGE_INTEGER结构的指针,表示定时器的初始到期时间。可以使用相对时间或绝对时间来指定。
_In_ LONG lPeriod, //定时器的周期,以毫秒为单位。如果设置为0,则表示定时器只在初始到期时触发一次。
_In_opt_ PTIMERAPCROUTINE pfnCompletionRoutine, //一个可选的回调函数,当定时器到期时会被调用。可以为NULL,表示不需要回调函数。
_In_opt_ LPVOID lpArgToCompletionRoutine, //传递给回调函数的参数。
_In_ BOOL fResume //指定定时器是否在被取消后重新启动。如果设置为TRUE,则在定时器到期后,会自动重新启动定时器。
);

这里的主要在于回调函数pfnCompletionRoutine以及其参数lpArgToCompletionRoutine使用,Gargoyle使用ROP将堆栈指向lpArgToCompletionRoutine的地址,然后在调用shellcode之前将其内存地址通过VirtualProtectEx函数更改为可执行状态。

其中主要用的一个ROP链位于mshtml.dll的如下一段:

1
2
3
pop ecx
pop esp
ret

控制pfnCompletionRoutine指向VirtualProtectEx后关键代码如下:

1
VirtualProtect(pShellcode, sizeof(shellcode), PAGE_READWRITE, &OldProtect);

Niche memory scanners

基于上述的一些绕过,也有一些扫描器专门扫这些绕过的函数:

  1. Patriot:专门扫修改内存权限的那些函数的扫描器,在FindSuspiciousContext中设置了以下这些可疑函数:
1
2
3
4
5
functions[0] = GetProcAddress(GetModuleHandle(L"ntdll.dll"), "NtProtectVirtualMemory");
functions[1] = GetProcAddress(GetModuleHandle(L"kernel32.dll"), "VirtualProtect");
functions[2] = GetProcAddress(GetModuleHandle(L"kernel32.dll"), "VirtualProtectEx");
functions[3] = GetProcAddress(GetModuleHandle(L"kernelbase.dll"), "VirtualProtect");
functions[4] = GetProcAddress(GetModuleHandle(L"kernelbase.dll"), "VirtualProtectEx");
  1. Hunt-Sleeping-Beacons:检测关于Timer的Wait那些函数的内存扫描器,必然对一些函数以及模块的检测:
1
2
3
if (strstr(toLowerA(cStompedModule), "ntdll.dll") != NULL || strstr(toLowerA(cStompedModule), "kernelbase.dll") != NULL)
if (strstr(cCalltrace, "Sleep") != NULL)
if (strstr(cCalltrace, "Microsoft.NET") != NULL)
  1. TickTock:检测基于timer-queue timers的一些绕过策略(比如Ekko)的扫描器。

Immutable code page principle violations

也就是基于代码页权限变更的检测,只要检测到代码所在的内存页发现了变化,就认为这个进程出现了异常。

github:https://github.com/jdu2600/EtwTi-FluctuationMonitor

An interesting discovery

这里Gabriel Landau有一个有趣的发现,即当使用VirtualAlloc创建了一个RWX的内存页,然后再使用VirtualAlloc再分配RW权限的内存,那么CFG保护是不会变化的。这意味着CFG记录了进程生命周期内可执行或以前可执行的所有私有内存地址的位置,这样可以根据CFG机制来检测内存页是否发生过变化,将现在不在可执行内存但是以前是的隐藏shellcode找出来

作者也以此搞了个demo出来:https://github.com/jdu2600/CFG-FindHiddenShellcode

Control Flow Guard bitmap recap

CFG的一些策略:

  1. 记录所有的间接调用(如:虚函数、函数指针)等,当程序执行间接调用时,控制流保护会使用这个数据结构来快速查找目标是否是有效的。
  2. CFG中每个进程都有一个独立的位图,这样每个进程可以根据自身的代码结构和执行路径来维护控制流信息,而不会受到其他进程的影响。
  3. CFG将每个2位编码映射到16个虚拟地址。在控制流保护位图中,每个位都用于表示一个基本块(basic block)的有效性。为了节省内存空间,可以使用2位来编码每个基本块的状态。每个2位编码可以表示4种状态,例如:
  • 00:表示基本块无效(invalid)
  • 01:表示基本块有效(valid)
  • 10:保留状态(reserved)
  • 11:保留状态(reserved)
  1. CFG中,x64位图的大小为2TB,并且其中大部分是用于共享或保留的。
  2. PE文件会携带自己的位图,这是由编译时产生的。
  3. 位图会在加载时加载到各个函数对应的地方。
  4. 有向后兼容性。
  5. CFG的内存管理器会将所有可执行的私有地址标记为有效目标,这意味着所有可执行的私有地址都被认为是有效的控制流目标,无需进一步的验证。

CFG bitmap anomalies

CFG记录了进程生命周期内可执行或以前可执行的所有私有内存地址的位置,这样可以根据CFG机制来检测内存页是否发生过变化,将现在不在可执行内存但是以前是的隐藏shellcode找出来。

Evasion opportunities

一些绕过的策略:

  1. 在特征被提取出来之前混淆代码。
  2. 使用CS那样的obfuscate-and-sleep策略。
  3. 将代码藏在显而易见的地方(我猜是不会扫的一些地方)。
  4. 定时任务。
  5. 每次都开一个新进程加载shellcode。