简介
根据老外介绍,这个项目是一个内存规避的项目,这个项目会循环的加密和解密shellcode的内容,然后在可读可写(RW)和可执行(RX)之间来回波动。这样可以规避其Moneta或pe-sieve等扫描工具。
下载地址如下:
https://github.com/mgeeky/ShellcodeFluctuation?tab=readme-ov-file
经过官网介绍这个项目的工作流程是这样的。
波动-PAGE_READWRITE
首先从文件中去读取Shellcode,当然这里我们也可以更改为远程去拉我们的Shellcode,比如上一个项目解析,通过Socket的方式。
挂钩Kernel32.dll中的Sleep函数,让他指向我们的回调函数,比如说我们的回调函数是MySleep函数,那么每次当调用Sleep函数的时候就会调用我们的MySleep函数。
通过VirtualAlloc申请一块内存,将shellcode写入到该内存中,然后通过CreateThread创建线程的方式去执行我们的shellcode。
当CobaltStrike Beacon尝试进入休眠时,我们的MySleep回调函数会被调用。
在MySleep回调函数中,首先将Beacon的内存分配进行解密,并将该内存区域的保护属性从RX更改为RW。
为了避免在内存中留下简单的IOC,比如Sleep函数被替换为跳转指令的痕迹,在执行Sleep之前,取消对原始Sleep函数的挂钩。
通过调用原始的
Sleep
函数,让Beacon进入休眠状态,等待进一步的通信。当
Sleep
函数执行完毕后,解密Shellcode的数据,并将其内存保护重新设置为可执行(RX),以便再次执行。最后,重新挂钩
Sleep
函数,以确保可以拦截并处理后续的Sleep
调用,从而继续控制Shellcode的执行流。
波动-PAGE_NOACCESS
上面是将内存保护设置为可读可写的方式,这个是将内存保护权限设置为PAGE_NOACCESS
权限,这个权限不能读不能写更不能执行,那么如果说你去访问一个不能读不能写更不能执行的内存区域,肯定是会触发异常的,那么既然触发异常,就需要去捕获异常,那么就用到了我们当时去讲硬件断点讲过的VEH,通过异常处理器来捕获异常。
流程如下:
首先从文件中去读取Shellcode,当然这里我们也可以更改为远程去拉我们的Shellcode,比如上一个项目解析,通过Socket的方式。
挂钩Kernel32.dll中的Sleep函数,让他指向我们的回调函数,比如说我们的回调函数是MySleep函数,那么每次当调用Sleep函数的时候就会调用我们的MySleep函数。
通过VirtualAlloc申请一块内存,将shellcode写入到该内存中,然后通过CreateThread创建线程的方式去执行我们的shellcode。
初始化矢量异常处理器来捕获异常,也就是我们的VEH,设置完成之后就可以捕获由PAGE_NOACCESS引发的访问冲突异常了。
当CobaltStrike Beacon尝试调用Sleep函数进入休眠状态时,由于我们挂钩了Sleep函数,所以会执行到MySleep函数。
在MySleep函数中加密Beacon的内存数据,并将其更改为不可访问的权限(PAGE_NOACCESS)。
取消对原始Sleep函数的挂钩。
通过调用原始的
Sleep
函数,让Beacon进入休眠状态,等待进一步的通信。当
Sleep
函数执行完毕后,重新挂钩Sleep
函数,以确保可以拦截并处理后续的Sleep
调用。在Shellcode恢复执行时,由于内存页面被标记为
PAGE_NOACCESS
,导致触发访问冲突异常(Access Violation)。矢量异常处理程序捕获访问冲突异常,解密被加密的内存数据,并将内存保护属性恢复为可执行(RX),最后继续执行Shellcode。
代码解释
接下来我们去Debug一下代码,带着大家一步一步去跟一遍。
首先我们来到main函数这里,根据项目介绍,我们可以得知,这个项目需要传递一个Shellcode和你要修改内存保护权限的方式,我们可以传递比如: shellcode.bin 1 这里的1表示将内存保护权限设置为PAGE_READWRITE权限,而2表示将内存保护权限设置为PAGE_NOACCESS权限。
ThreadStackSpoofer.exe payload.bin 1
当我们将Sleep设置为10秒的时候,随之这里也会变成10秒。
首先读取从外部传递进来的Shellcode,并存储在shellcode变量中。
这里的g_fluctuate是一个全局变量,类型为TypeOfFluctuation,是一个枚举类型。
这个枚举类型中有三个值。
这里会判断是否不等于NoFluctuation这个值。
此时g_fluctuate的值为:FluctuateToNA。
进入IF之后,会去挂钩Sleep函数,我们跟进去。
在hookSleep函数这里定义了一个HookTrampolineBuffers
类型的结构体,并将其内容初始化为0,这个结构会保存钩子操作的相关信息,比如保存原始函数的一些字节或保存跳板函数的相关数据。
结构如下:
紧接着将其16字节的数组保存至HookTrampolineBuffers结构的previousBytes成员中,这里的sleepStub成员定义了16个字节数组,用于后续保存Sleep函数的前16个字节。一般我们去设置钩子的时候这些字节会被替换为跳转指令或其他钩子相关的代码,所以这里的话需要保存原始的字节以便恢复。
buffers.previousBytes = g_hookedSleep.sleepStub;
g_hookedSleep是HookSleep结构。
HookSleep结构如下:
然后将其大小也存储在HookTrampolineBuffers结构中的previousBytesSize成员中。
buffers.previousBytesSize = sizeof(g_hookedSleep.sleepStub);
紧接着将Sleep函数的地址转换为TypeSleep类型的函数指针,然后赋值给HookedSleep结构中的origSleep成员。
然后调用了fastTrampoline
函数,这个函数用于去设置跳床代码,其实就是当我们去调用Sleep函数的时候会跳转到我们自定义的Sleep函数执行。
这个函数第一个传递进去TRUE表示需要Hook Sleep函数,第二个参数传递进去Sleep函数的地址,第三个参数传递进去自定义Sleep函数的地址,最后一个参数传递进去一个buffer,也就是我们上面将16字节的数组和大小放到了buffer这个结构中。
来到fastTrampoline
函数这里,这里定义了一个跳床汇编代码,这段汇编用来跳转到指定的内存地址,首先将一个地址放到了r10寄存器中,然后通过jmp指令去进行跳转。
我们可以看到后面的8个0x00是让我们来进行填充的,我们需要填充为我们自定义函数的地址。
然后将其我们自定义函数的地址赋值给addr变量。
然后将其地址填充到跳床指令中。这里填充的就是我们自定义函数的地址。
这段代码的意思就是将addr中的64位地址值复制到trampoline数组中从索引2开始的位置。
这样当trampoline代码执行的时候,mov r10,addr指令就会将地址加载到r10寄存器中,最后使用jmp指令进行跳转。
至于这里为什么只改变了6个字节,这是因为虽然指针类型为8个字节,但是实际使用的有效地址范围一般只需要48位,所以高16位一般都是0.
紧接着获取到跳床代码的大小,判断是否需要去安装Hook,因为我们当时传递进来的第一个参数是true,所以这里是需要hook的。
紧接着判断Buffer结构中的previousBytes和previousBytesSize是否为nullptr或0。
然后将其Sleep函数地址的前16个字节复制到buffers结构中的previousBytes成员中。
这一步是为了保留挂钩函数的原始字节。
更改之后用跳床代码覆盖Sleep函数地址处的原始代码,从而实现挂钩hook。
那么复制过去之后,当我们去调用Sleep函数的时候就会调用我们自定义函数的地址。
紧接着初始化了一个静态变量,用于指向NtFlushInstructionCache
函数的地址。
NtFlushInstructionCache
函数是一个内核模式的函数,用于刷新指定进程的指令缓存。
然后通过GetProcAddress函数来获取到NtFlushInstructionCache函数的地址。
然后调用NtFlushInstructionCache函数刷新Sleep函数的内存地址,刷新的大小其实就是跳床代码的大小。
刷新之后将其Sleep函数的地址设置为可读可写可执行的权限,这里设置了0x40 对应的其实就是PAGE_EXECUTE_READWRITE
。
最后返回TRUE。
往下走这里会调用AddVectoredExceptionHandler函数来初始化一个矢量异常处理器,其实就是VEH。这里的参数1表示优先于其他异常处理程序被调用,如果设置为0的话,那么他会被添加到链的末尾,优先级是较低的。
这里的第二个参数是我们自定义的异常处理函数,比如当我们尝试去访问一块不可访问的内存区域,就会发生异常,就会来到异常处理程序这里进行处理。
紧接着就是注入Shelllcode了,这里调用InjectShellcode函数来注入shellcode。
这里传递进去两个参数,第一个就是我们的shellcode,第二个参数是通过初始化一个智能指针对象来管理线程句柄。
在注入Shellcode这个函数中,首先会申请一块内存,将其Shellcode写入到内存中。需要注意的是这里分配的权限是PAGE_READWRITE权限,并不是可执行的权限。
紧接着再将其权限更改为可读可写可执行的权限。
然后创建一个线程去指向我们shellcode的内存区域。
最后等待执行。
那么当我们去调用Sleep函数的时候,就会来到MySleep函数这里。
首先获取调用Mysleep函数的返回地址,并将其存储在caller变量中。这里的返回地址我们可以理解为调用Mysleep函数地址之后的下一条地址。
紧接着调用initializeShellcodeFluctuation函数将调用MySleep函数的下一条地址传递进去。
这里的g_fluctuate是一个全局变量,我们上面也说到过,它表示波动的状态,这里判断是否存在波动,如果不存在并且shellcodeAddr成员等于nullptr以及调用了isshellcodeThread函数。
在isshellcodeThread函数中会调用VirtualQuery函数来获取指定内存地址的内存状态信息,然后将其这些信息存储到MEMORY_BASIC_INFORMATION
结构体中。
紧接着使用MEMORY_BASIC_INFORMATION
结构体中的Type字段来判断这块内存区域的类型,这里判断是否是MEM_PRIVATE
类型,这个类型表示这块内存是由VirtualAlloc函数直接分配的私有内存。
跟进IF判断,根据g_fluctuate
的值来动态设置内存保护属性。
最后通过MEMORY_BASIC_INFORMATION
结构体中的Protect字段来判断当前的内存区域保护属性是否满足以下的任意一个条件。
这里的Proetct值为0x40 表示PAGE_EXECUTE_READWRITE,所以肯定是满足的。
最后返回TRUE。
回到initializeShellcodeFluctuation
函数这里,这里调用了一个collectMemoryMap函数,并将当前进程传递进去,这个函数是用于收集当前进程中内存映射的信息。这会返回包含所有内存块信息的列表。
然后进行遍历每一个内存块,其实就是遍历MEMORY_BASIC_INFORMATION
对象,判断caller的地址是否位于某个内存块的范围,如果是的话,那么这个内存块中可能包含了shellcode。
针对于循环这里的判断,首先将caller变量转换为uintptr_t类型的变量,这里的BaseAddr是内存块起始的地址。
mbi.RegionSize
:这是内存块的大小(以字节为单位)。将 mbi.BaseAddress
加上 mbi.RegionSize
得到内存块的结束地址(不包括)。
首先会判断caller
地址是否大于内存块的起始地址,也就是说caller必须在内存块的起始地址。
然后判断caller的地址是否小于内存块的结束地址,也就是说caller必须在内存块结束地址之前。
那么我们在想BaseAddress从哪来的,肯定是通过collectMemoryMap函数来获取的内存映射信息啊。
如下图这些都是内存块,也就是说有多少内存块就会循环多少次。
如果找到了包含 caller
的内存块,函数会将这个块的基地址和大小存储到全局变量 g_fluctuationData
中。
然后生成一个随机的 32 位密钥 encodeKey
,用于后续的 XOR 加密操作。
这段我的理解是caller
是通过 _ReturnAddress()
获取的,该函数返回调用当前函数的返回地址。这通常是调用 MySleep
函数的位置的下一条指令的地址。例如,如果你调用了 Sleep()
函数,然后 Sleep()
被挂钩到 MySleep()
函数,那么 caller
就是 Sleep()
调用的下一条指令的地址。
在 initializeShellcodeFluctuation
函数中,caller
地址被用于检查是否属于 shellcode 的内存区域。这个函数的逻辑是:
检查是否存在内存波动:g_fluctuate
变量决定是否需要对 shellcode 进行加密或解密。
判断是否在 shellcode 内存区域:使用 isShellcodeThread(caller)
函数来判断 caller
地址是否在 shellcode 的内存区域内。如果 caller
地址在 shellcode 的内存区域,则函数会设置 g_fluctuationData
中的相关数据。
isShellcodeThread
函数用于判断 caller
地址是否在 shellcode 的内存区域内。它会:
查询内存区域:通过
VirtualQuery
获取caller
地址所在的内存区域信息。检查内存保护和类型:确认内存区域是否为动态分配(
MEM_PRIVATE
),并且其保护属性是否允许执行(PAGE_EXECUTE_READ
或PAGE_EXECUTE_READWRITE
)。
然后调用shellcodeEncryptDecrypt函数来加密内存块。
来到shellcodeEncryptDecrypt函数这里,首先判断是否处于波动的状态,这里的NoFluctuation表示没有波动,紧接着判断shellcodeAddr是否等于空指针,并且调用isShellcodeThread函数来判断caller地址是否在shellcode内存区域内。
紧接着判断当前的shellcode是否被加密,如果shellcode没有被加密,则执行加密的操作。
或者shellcode已经被加密,并且意味着此时的操作要求将内存保护设置为 PAGE_NOACCESS
(不可访问)。
修改shellcode中内存地址为PAGE_READWRITE权限。
然后对其使用xor进行加密。
然后当shellcode已经被加密之后,我们将其内存保护恢复到执行的状态状态,这里就是判断如果条件为真的话,那么就表示shellcode已经被加密了,恢复内存保护权限。因为是首次进入,所以shellcode未加密。所以对其进行取反也就是从false变为了true。
出来之后将16字节的数组赋值到buffers结构中的originalBytes中,并且将其大小赋值到originalBytesSize成员中
紧接着这里调用了一个fastTrampoline函数,传递进去一个false,然后将Sleep函数的地址,以及自定义函数的地址和Bufferss传递了进去。
这段代码其实就是暂时取消对Sleep函数的挂钩,然后再MySleep函数执行之后恢复钩子。
紧接着既然已经取消了挂钩,那么紧接着就会调用正常的Sleep函数来进行延时执行,最后进行解密。
总结
我们来总结一下这个挂钩流程。
首先是第一次挂钩Sleep函数去执行的时候,他会对其内存块进行解密操作。默认的时间就是60秒的时间
最主要的是shellcodeEncryptDecrypt函数中,在这个函数中会去判断内存块是否被加密,因为是第一次所以需要对其进行加密。
第一次会进入到这个IF判断中。
if (!g_fluctuationData.currentlyEncrypted
|| (g_fluctuationData.currentlyEncrypted && g_fluctuate == FluctuateToNA))
加密之后会将currentlyEncrypted的值取反,因为原本是false,表示未加密,现在取反之后变成了true,所以表示是加密的。
然后取消挂钩Sleep函数,最后调用正常的Sleep函数,此时调用正常的Sleep函数传递进去的值是60,所以会延时60秒。
紧接着就会对其内存块进行解密。因为解密也会调用shellcodeEncryptDecrypt函数,所以解密之后会将currentlyEncrypted的值设置为false,以便下次调用Sleep函数的时候进行内存块加密。
然后挂钩Sleep函数。
挂钩之后,我们去cs客户端中去执行Sleep 15命令的时候,本质上调用了Sleep(15),所以会来到MySleep函数这里。
执行之后,再次进去到Mysleep挂钩函数这里,因为我们已经将currentlyEncrypted设置为了false,所以需要对其加密内存块。
加密之后,会再次将currentlyEncrypted值设置为true。
那么紧接着会取消挂钩Sleep函数,然后调用正常的Sleep函数,我们可以看到他的值转换为10进制其实就是15.
紧接着就是等待Sleep函数执行完毕,再次去解密内存块执行即可。
最后在挂钩Sleep函数即可。
那么我们在想第一次Sleep是什么时候调用的,其实是在我们上线CobaltStrike Beacon之后,C2 服务器可能会下发配置给 Beacon,包括睡眠时间(例如 60 秒),以及其他执行策略。Beacon 会根据这些配置,进入一个循环:休眠、唤醒、检查是否有新任务、执行任务、再次休眠。