原文由作者授权转载,首发于:奇安信攻防社区
https://forum.butian.net/share/2017
0x01 本文主要内容
本文主要对cobaltstrike4.0中的shellcode的运作原理的分析。
Cobaltstrike启动服务端,然后打开aggressor 端,如下图生成payload:
打开生成的payload:
长度是1600个byte
创建一个加载这个段机器码的加载器,在cobaltstrike中这段机器码我们一般叫stager,所以我们简单写一个stagerloader:
这里实现的方法很多,可以直接通过c++内联汇编,获取shellcode的存储地址,然后直接跳转过去;也可以分配内存空间,将对应payload当成一个返回类型为void的函数来执行。如下图,也是比较常见的c实现的loader的形式:
#include <windows.h>int main(void) {
unsigned char buf[] = 上面那串payload;;
//创建一个堆(这里看个人习惯,不建堆也可以直接分配)
HANDLE myHeap = HeapCreate(HEAP_CREATE_ENABLE_EXECUTE, 0, 0);
//从堆上分配一块内存
void* exec = HeapAlloc(myHeap, HEAP_ZERO_MEMORY, sizeof(buf));
//payload复制过去
memcpy(exec, buf, sizeof(buf));
//将exec强制转换成返回类型为void的函数指针,并调用这个函数
((void(*)())exec)();
return 0;
}
编译链接生成exe,(使用vs编译链接的时候建议把什么优化,安全检查,随机基址,以及相关清单信息啥的都关掉,方便后面我们对exe进行调试)
通过x86 的release生成的exe如下:
完成之后我们先简单测试下,丢到虚拟机运行下,看下上线啥的是否正常:
如下图,没啥问题,成功上线:
接下来我们使用ollydbg来调试下这个exe,来看看这个payload在执行什么内容:
od运行之后,一个call和一个jmp:使用这个编译器生成好像都是这样,不重要,我们要找的代码在jmp里面,跟过去就行了
过来之后又是一堆操作,调用啥的,代码还挺长:
一个个过肯定是能找到执行我们payload的调用的,过的方法呢,就看过完对应call之后,aggressor端是否新增上线设备就行。但是这里有简单方法,比如给heapAlloc上调试断点,f9,就直接跳过去了。
最后是在这个call里面调用了对应的payoad:
f7进去看看,如下图,就是我们自己写的代码了:其中的四步内容如下:
直接跟进去调用的payload:如下图,就是我们生成的payload:(从这里开始往下,因为笔者调试的是多是多次进行运行,所以每次分配的堆的内存空间不一样,比如下图是9805c8开始,下下下下张图(从下开始第四张)是9c05c8开始,所以这里我们主要看代码逻辑即可,地址可能对不上,笔者尽量在完整的一段逻辑里面分析的时候一次过)
第一个call:
和上图call配合,这里pop ebp,获取到eip,如下图然后压入特征码和参数返回eip:
上图中,其中参数有,“wininet”,和一个特征码 726774c,然后通过call ebp 返回。
如下图,返回之后通过fs寄存器,找打TEB——>PEB——>PEB_LDR_DATA——>内存模块加载List(InMemoryOrderLinks)——_LDR_DATA_TABLE_ENTRY
——>获取模块的名称和基址,为后续遍历模块和函数名做准备。
然后对获取的模块名,对其进行特定的一个hash算法,如下图:第一个获取到的模块名是,exe本身这个模块,所以模块名是:“shellcodeloader.exe”
对其进行特定的求特征算法,(算法逻辑就是:对每个字符判断,如果大于0x60,就减0x20(其实就是小写转大写),然后累加,在累加前将上一次的累加结果循环右移14位)
最后得出来的值是在edi中,将其压入栈中,后面会使用到。
然后找到edx+10 对应位置:如下图
这个位置其实就是,_LDR_DATA_TABLE_ENTRY中的0x18偏移的内容(edx是头指针,在里面的0x08偏移的位置),下表是该结构体的内容,所以这里其实就是拿到对应的模块的基址
struct _LDR_DATA_TABLE_ENTRY
{
struct _LIST_ENTRY InLoadOrderLinks; //0x0
struct _LIST_ENTRY InMemoryOrderLinks; //0x8
struct _LIST_ENTRY InInitializationOrderLinks; //0x10
VOID* DllBase; //0x18
VOID* EntryPoint; //0x1c
ULONG SizeOfImage; //0x20
struct _UNICODE_STRING FullDllName; //0x24
struct _UNICODE_STRING BaseDllName; //0x2c
....
};
拿到基址之后,如下,寻找3c偏移,这里是在DOS头3c偏移找PE起始位置,然后找pe头中的78偏移。
PE头中的0x78偏移:如下,可以看到这个位置其实就是可选头里面的导出表
找到导出表之后,接下来的test命令,判断是否为0,也就是判断这里是否有导出表:
等于0的话,来到如下,这里我们第一次运行的时候也的确等于0,因为我们的exe里面没有导出表,下面的对导出表为空的处理其实就是接着遍历下一个模块,然后跳回到获取模块名的位置:
接着我们去看看当某个模块的导入表不为空的时候,怎么处理的,其实第二个模块就是ntdll.dll (一般程序第三个是kernel32.dll),此时就满足导出表不为空,
然后其操作是:找到导出表里面的0x18偏移和0x20偏移的地方,这个表的结构如下:
然后如下图,遍历名称,计算特征值(这里的计算算法和之前对模块的特征算法有点不一样,区别就是没有判断是否是大于0x60,然后小写转大写),和之前栈中的特征码比较:
如下图:拿计算出来模块的特征值加上函数的特征值,最后和我们在开头压入栈中的特征值做比较:
如果不相等:就遍历下一个函数,一直到这个模块的全部以名称形式导出的函数被遍历完,然后重复遍历下一个模块:
这里我们来看下,当找到对应的函数,其通过这种hash算法计算出来的值和压入栈中的相等时的情况,
1、这个函数是什么函数
2、对这个函数干什么
如下图,可以看到,当我们遍历到,kernel32.dll模块里面的LoadLibraryExA函数的时候,特征码就相等了:
如下图是干了什么,这里面一顿操作,通过循环次数获取在其导出序号表里面获取到导出序号,然后利用导出序号在导出地址表里面获取到其导出地址,之后就可以随便调用LoadLibraryExA:
如下是,获取到之后的动作,利用这个函数,来加载wininet这个dll:
试想为什么要加载这个dll,其实大概能猜出来,肯定是后面用的函数载这个wininet这个dll模块里面,而本身的程序里面没有加载这个模块。
我们继续往下看:之后就是使用相同的方式,传入对应的特征码,然后循环遍历模块去找函数,这里其实大概率下面的这些函数都载wininet这个dll里面:
大概看了下是如下的9个函数,根据对应特征码,使用同样的方法获取到对应函数的地址,并通过压入堆栈的数据作为参数并调用对应函数:
简单看下这些函数是什么:
第一个:A779563A对应的是wininet里面的InternetOpenA函数
第二个:C69F8957对应的wininet里面的InternetConnectA
第三个:3B2E55EB对应的wininet里面的HttpOpenrequestA
第四个:7B18062D对应的是wininet里面的HttpSendRequestA
第五个:315E2145对应的是user32里面的GetDesktopWindow
第六个:0BE057B7对应的是wininet里面的InternetErrorDlg
第七个:E553A458对应的是kernel32里面的VirtualAlloc
第八个:E2899612对应的是wininet里面的InternetReadFile
这里面的逻辑,就是建立和cobaltstrike server的链接,然后发送get请求,开辟内容空降将返回的内容存起来,最后运行对应返回的内容,(上述这个步骤就是我们说的通过stager拉取beacon的操作)
如下是来到了返回的内容:其实这里就是cobaltstrike里面的beacon了:
在流量侧我们看到的是:MDwT是符合checkSum8算法(cs里面对uri资源判断的一个算法,详情可参考笔者另一篇文章)
接下来我们进入beacon的内容来看这里面干了什么:如下图:beacon里面上来就是实现了一个解密操作,将46偏移之后内容通过和一个固定的3d偏移的内容,以4字节为单位做异或,还原对应内容。
还原的长度是:0x33000
内存中没解密之前的数据是这样的:
解密之后:稍微留心就会发现这个解密之后的内容其实就是一个PE文件,开头是4D5A,下面的是Dos引导区,再后面13D偏移的位置还有PE头:
继续往下看,发现通过一个pop edi,jmp edi, 直接跳到了 刚刚还原出来的数据 45偏移的位置开始执行:
这里就比较奇怪了,明明是个PE文件结构的内容,为啥beacon直接把这个当shellcode去运行执行了,就强行将jmp过去,从pe的头开始执行。
嗯,笔者之前写过一篇文章是记录一次对Cobaltstrike powershell 上线的分析,当时笔者也是遇到了这个问题,powershell上线的时候也是莫名其妙的构造了一个shellcodeloader,然后把dll文件丢到loader里面,具体为什么这样做,感兴趣可以看下那篇文章。
这里也不绕圈子了,其实就是利用反射dll修复技术,一般我们称其为Reflective DLL Injection技术,是由研究员 Stephen Fewer 再2009年提出的,后续在15年
加入了一些shellcode 技巧和引导程序来完善,发展的较为成熟,应用到恶意软件的中,很多apt组织使用了这个技术,当时基本可以过所有的av,从而大火。
关于这个技术的参考项目:
https://github.com/stephenfewer/ReflectiveDLLInjection
之前了解这个技术原理的师傅们可以去看看。
言归正传,我们继续在od中看下这个dll怎么被“执行”的:如下图是跟进之后,发现指令混到一起了
为了方便我们接下来的调试,我们将这个PE文件的内容dump下来:从这个0x29F0045开始,一直到后面没有指令的位置,或者你实在不放心,就dump长度为33000的长度,这个长度是刚刚解密的时候解密内容的长度。
每次涉及到内存dump的时候,我看网上的教程什么的,都是用这个插件,那个插件的,笔者其实不太懂为啥非要用插件,反正笔者觉得就手动dump挺香的,选中想要dump的内容,右键——backup——savetofile 就完事了,选中对应内容的时候还有一个小技巧,直接CTRL+G来找头和尾的位置,然后按住Shift 点两下就行了,就把要dump的内容全选了。
dump下来之后如下:
然后我们简单和之前一样,做个简易的loader,做的内容上文有,这里我就不罗嗦了:
如下代码:
#include <windows.h>
#include <iostream>
using namespace std;void run()
{
HANDLE hfile = CreateFileA("afterdecryptothingDLL1.mem", FILE_ALL_ACCESS, 0, NULL,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
LPVOID exec = VirtualAlloc(NULL, 0x4000000, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
DWORD realRead = 0;
ReadFile(hfile, exec, 0x4000000, &realRead, NULL);
((void(*)())exec)();
}
int main() {
run();
}
最后生成exe:(运行的时候记得把afterdecryptothingDLL1.mem这个文件放过来)
首先我们测下,这个有问题没:运行下,如下图没问题,运行之后成功上线
接着我们使用od对其调试,看下里面干了什么:
前面loader的代码我就不说了,这里我们直接来到强行执行PE的位置:如下图,上来就是一个经典的call pop,找到当前的位置:
然后将位置放到edi里面存着,后面跟了个push edx,和inc ebp ,来还原前面”4d 5a(dec ebp, pop edx)“干的事情:
然后同push ebp 和 mov ebp,esp,另起一个栈,并将刚刚获取到的真实位置+8150的偏移,刚刚获取到的是这段分配到空间的0007偏移位置的真实地址,所以我们得到的就是这段分配到空间的8157偏移的位置,调用对应位置执行:
在分析到对应位置之前,我们回过头来看下这个afterdecryptothingDLL1.mem 这个文件,使用PE查看器打开:
可以看到给出的信息是一个dll文件:
导出表:存在一个导出函数:RVA是08d57,对应的文件偏移就是:0x08d57-0x1000+0x400=0x08157,正好就是我们上面要跳往的地址:
所以也就是说,接下来就是在执行这个ReflectiveLoader函数:如下图,进来,先通过以下手段,找到PE头部的真实位置
然后就是三环fs寄存器那套了:获取到加载模块的名称:
然后计算hash,和特征码作比较:如下图
紧接着,不匹配就继续遍历下一个模块,匹配上了的话就获取对应dll的基址:如下图,在struct _LDR_DATA_TABLE_ENTRY结构中,下面的eax在本身就是在0x08偏移位置,这里加0x10,就变成了0x18,这个位置就是对应模块的基址位置,
如下图,可以看到这个”6A4ABC5B“特征码找的是kernel32这个模块:
接着又是老套路,从基址找到导出表,找到以函数名称导出的函数的个数,找到函数名称导出表INT:
接着就是遍历函数名,计算特征码:
计算出来的特征码,和以下六个特征码比较:其实就是找到下面六个特征码对应的函数,并且这些函数都是在kernel32里面的:
如果特征码相等,那么就接着获取对应IAT表(导出地址表)对应的导出地址(、这个过程是由 循环的次序—>导出序号—>导出地址),存在栈中:
这些函数是:
GetProcessAddress、GetModuleHandleA、LoadLibraryA、LoadLibraryExA、VirtualAlloc、VirtualProtect
在Reflective dll inject 技术里面,这里的reflectiveloader方法是用来处理这个DLL的,因为我们解密还原出来的dll其实是以“文件形式”存在内存中的,所以这里
reflectiveloader这个函数的主要功能就是:将这个文件形式存在的dll,加载到内存里面,也就是把reflectiveloader这个函数自己所在的dll加载到内存里面。
这里面要经过的过程是:
1、从文件形式到内存形式的拉伸,因为内存对齐和文件对齐是不一样的。这个过程主要过程就是复制,先将文件头复制到新开辟的空间,然后再按区节一个个复制到对应的相对位置。
2、修复导入表,之前以文件格式存储的时候被我们直接复制过来了,此时这里的导入表还是双桥结构,也就是INT(导入名称表)和IAT(导入地址表)是一样的,所以当我们把其加载到内存中的时候,我们要修复IAT表。修复过程就是根据导入名称表遍历导入的模块名称和函数名称,然后利用上面获取到的LoadLibrary函数和GetProcessAddress函数来找到对应函数名称的真实地址。
3、如果有重定位表,修复重定位表(这里肯定是有的,因为后面我们再这个dll的oep 也就是dllmain中还要实现我们c2 client的逻辑,不可能全部用PIC(位置无关代码)来写把),而且这里我们这个dll新加载的位置,是我们新通过virtualalloc开辟的,肯定和这个dll本身的预期的基址(imageaddress)是不一样的,没有加载到指定的基址,那么这里有重定位表,我们就要对其进行修复:
修复重定位表,在PE结构中考虑到重定位表的空间占用问题,重定位表里面并不是,存的一个个需要重定位内容的地址,而是以基址+偏移的方式来存储,几个或者多个偏移位置共用一个基址,从而使空间利用率提高:如下图这种格式:
结构体:
存储内容的形式:
内存对齐单位使0x1000h,来分页,在重定位表里面也是这样来做的,相同的1页共用一个上面的块结构体,也就是说如果一个pe文件的有8页,并且每页里面多多少少都有需要重定位的内容,那么这里就会有8个上面的块结构。
这里我们简单计算下页里面的大小,0x1000h 也就是说有4096个单位,那么要找到4096个页内偏移的可能呢,需要用几位来标记呢,2的12次就是4096,所以我们只要有12位bit就可以表示这里页内的偏移,pe里面用的2byte 也就是16位bit,其中高4位固定位0011,后12位为偏移位置。
如下是这个dll的重定位表:
简单看下对应汇编里面对上述三步的实现:
1、复制数据过:
如下:用virtualAlloc开辟新空间,大小是sizeofimage:
接着,将整个文件pe头复制过去:
然后一些判断,如下:判断pe头中的characteristics最低位是否为1:这里就是判断重定位表
接下来就是循环复制每个节块过去:
如下图,先找到块表:
获取块表的相关属性,然后循环复制到新空间:
2、修复导入表:
如下图:这里先找到dll的name
然后调用前面获取到的LoadLibrary,加载这个模块:
然后通过上面获取到的GetProcAddress,找到我们这里在导出表里面遍历的函数的内存地址,然后将内存地址写到其IAT表里面,从而实现导入表的修复:
3、修复重定位表:
如下图,找到重定位表,根据格式对其每个块里面的内容进行遍历,每个块中存的重定位资源的个数:(块—>size -8 %2),-8是把前面的virtualAddress和size占8个字节干掉,除2,是因为这里我们每个相对偏移占2个字节。
如下图,对里面的偏移进行修复
最后,如下图:在reflectiveloader这个函数里面,找到这个我们加载进内存dll的EP(入口点),压入参数并调用:这里我们看那个压入的参数”1“,其实就是dllmain里面的fdwReason参数,如下图中第二个红色部分:(但是这里要注意一个小细节,这里的ep并不是dllmain,笔者之前一直以为dll的ep就是dllmain函数,在cs实现的这个dll里面这里调用的是:DllEntryPoint函数,根据这个函数间接调用dllmain函数,并且这两个方法的参数是一样的)
我们来看下这个DllEntryPoint的实现:如下图,其实就是对fdwReason进行判断,如果等于1,就执行一个A call,然后执行B call;如果不等于1,直接执行Bcall。其实这里的B call就是dllmain, A call是一个__securiyt_init_cookie()的函数(这个函数好像用来提供缓冲区溢出保护的,不知道也没关系)
接着我们来看下B Call ,也就是dllmain:
dllmain函数的参数:这里我们需要重点注意第二个参数fdwReason:
根据msdn上对这个参数的解释:如下图,这个参数为1的时候一般用来做初始化操作:
CS沿用了这一特性,在这里其实也是在做初始化。
我们先不着急跟进对应call 到dllmain中,如下图,可以看到reflectiveLoader函数在下图中的标记的3处中,call下面有一句:
这里将这个dll的ep,放到了eax里面,然后后续返回,所以这个dllmain方法就被带出去了,返回之后继续来到PE头部的引导区:如下图:可以看到这里利用eax带回去的地址,继续压入参数并调用,此时压入的fwdReason是4:也就是说又调用了一遍dllmain方法。
接下来就是研究这个dllmain方法里面干了啥了,为啥要一共调用两次(一次fdwReason参数是1,一次fdwReason参数是4),等于1的时候是我们上文说到的,第一次是在做初始化吗?
来到dllmain方法:如下图,进来就判断fdwReason参数是否等于1,这里也就是第一次调用dllmain,cs其实是在做初始化操作,接下来我们跳转过去看看:
来到跳转后的位置:如下图
跟进到这个call里面:如下图,这个call里面对某块数据进行解密还原:循环的次数是0x1000:
还原之后的数据如下,其中红色的部分我们是能看出来端倪的,其中包括c2,ua,URI,CT,以及相关心跳传输内容的传输字段(下面这个是Cookie字段)
所以这里这个fwdReason为1的时候的call,其实就是在初始化,将一些被加密的要用的信息还原出来(这个可能是因为beacon生成的时候会受c2profile的配置影响,总不能修改一个c2profile里面的配置,我们就要大费周章的去beacon里面找对应位置做修改把,所以cs对beacon的修改接口就是对这个资源段的修改)。
回过头来,我们来看看dllmain中fwdReason等于4的时候:
一堆逻辑,这里我们用ida来打开这个dll文件看下:
找到dllmain函数:
如下图,也就是在8cdf偏移的位置(ida默认基址是10000000):
直接f5大法:如下图,可以看到其实就是我们分析的对fdwReason进行判断:进行如下逻辑:
上面通过VirtualQuery获取到这个dll的虚拟空间的一系列页面信息,对获取到的buffer(MEMORY_BASIC_INFORMATION 结构),判断其type属性:
type这个属性在msdn上是这么解释的:
所以这里对这个属性做了个判断,为20000的时候释放8000的空间,为40000的时候取消对应dll的映射,笔者这里也不太清除这是要干啥,是检查dll被分配到空间的权限问题吗?
无碍,这个逻辑最后不管这么说,都是去到0x1388这个偏移对应的函数,这个函数就是我们要分析的点,我们简单来看下主要的一些关键位置:
如下是这个偏移函数反汇编后开始的内容:首先获取到和c2通信要用的一些资源和配置,如c2IP、端口、心跳时间,ua等等
最关键的点是,beacon段要定时发送心跳,并接收返回,对返回的内容进行判断处理,然后做出对应的指令。这里其实就是下面这个死循环:其中1A69偏移的这个函数是在和c2建立连接:(里面就是Wininet里面的InternetOpen,InternetSetOption、InternetConnect)
发送心跳请求,如果有就获取响应体内容(通过InternetReadFile获取,cs通信中,如果心跳请求有响应体了,就说明这里在下发任务了):
然后根据返回内容来执行对应命令:如下图,当某个响应内容的值大于0的时候调用8831这个偏移的函数:
这个函数的实现如下:,其中有个8305的偏移的函数,这个函数就是在处理执行操作的类型,更具类型执行不同的命令:
如下图,可以看到这个cobaltstrike4.0的beacon里面内置了100个任务类型:
根据响应内容来判断调用不同形式命令,常见的什么截图、弹窗、代理等相关命令之类的
switch ( a3 )
{
case 1:
v3 = 1;
goto LABEL_3;
case 3:
return (void *)sub_100021AE(Src);
case 4:
return (void *)sub_10002231(Src, result);
case 5:
return (void *)sub_100021C2(result);
case 9:
return (void *)sub_100043D4(1);
case 10:
return (void *)sub_100026BA((int)result, Src, "wb");
case 11:
return (void *)sub_10003ACE(result, Src);
case 12:
return (void *)sub_10002269(result);
case 13:
return (void *)sub_10009D53(result, Src, 1);
case 14:
return (void *)sub_10006FC6(result, Src);
case 15:
return (void *)sub_100071D2(Src);
case 16:
return (void *)sub_10007214(Src);
case 17:
return (void *)sub_10006F72(result);
case 18:
return (void *)sub_1000477E(Src, 1);
case 19:
return (void *)sub_10003D0C(Src);
case 22:
return (void *)sub_100062FE(Src);
case 23:
return (void *)sub_10006445(Src);
case 24:
return (void *)sub_100062BD(Src);
case 27:
return (void *)sub_1000A2A2(Src);
case 28:
return (void *)sub_1000A16F(Src);
case 29:
return (void *)sub_1000274D(result, Src);
case 31:
return (void *)sub_1000A371(result, Src);
case 32:
return (void *)sub_1000894E(result, Src);
case 33:
return (void *)sub_10008886(Src, result);
case 37:
return (void *)sub_10007714(result);
case 38:
return (void *)sub_1000243C(result, Src);
case 39:
return (void *)sub_100028D4(Src);
case 40:
case 62:
return (void *)sub_10005F7A(result, Src);
case 41:
return (void *)sub_100060AA(Src);
case 42:
return (void *)sub_10006112(Src, result);
case 43:
return (void *)sub_100043D4(0);
case 44:
v4 = 1;
goto LABEL_40;
case 45:
return (void *)sub_100047C9(Src, 1);
case 46:
return (void *)sub_100047C9(Src, 0);
case 47:
return (void *)sub_10002938(Src, result);
case 48:
return (void *)sub_1000589B(result, Src);
case 49:
return (void *)sub_1000A5D8(result, Src);
case 50:
return (void *)sub_10007616(Src, result);
case 51:
return (void *)sub_100076C0(Src, result);
case 52:
return (void *)sub_100029DA(result, Src);
case 53:
return (void *)sub_10003FD1(result, Src);
case 54:
return (void *)sub_10003EAA(Src, result);
case 55:
return (void *)sub_10003DA1(Src, result);
case 56:
return (void *)sub_10003E68(Src, result);
case 57:
return (void *)sub_10002A92(result, Src);
case 58:
return (void *)sub_10002C10(result, Src);
case 59:
return (void *)sub_1000A8BD(Src, result);
case 60:
return (void *)sub_10002FE8(result);
case 61:
return (void *)sub_10003075(Src);
case 67:
return (void *)sub_100026BA((int)result, Src, "ab");
case 68:
return (void *)sub_1000660C((LPCSTR)result);
case 69:
return (void *)sub_10009D53(result, Src, 0);
case 70:
v5 = 1;
goto LABEL_63;
case 71:
v6 = 1;
goto LABEL_65;
case 72:
return (void *)sub_10002212(result);
case 73:
return (void *)sub_10003ED3(result, Src);
case 74:
return (void *)sub_10003F53(result, Src);
case 75:
return (void *)sub_10007A4A(Src, result);
case 76:
return (void *)sub_1000233F(result, Src);
case 77:
return (void *)sub_10003154(result, Src);
case 78:
return (void *)sub_100025C9(result, Src);
case 79:
return (void *)sub_1000774D(Src, result);
case 80:
return (void *)sub_1000499E(Src, result);
case 81:
return (void *)sub_100097FC(result, Src);
case 82:
return (void *)sub_1000766B(Src, result);
case 83:
return (void *)sub_10001092(result, Src);
case 84:
return (void *)sub_10001130(Src);
case 85:
return (void *)sub_100011D5(Src);
case 86:
return (void *)sub_1000695C(result, Src);
case 87:
v5 = 0;
LABEL_63:
result = (void *)sub_10004903(result, Src, 1, v5);
break;
case 88:
v6 = 0;
LABEL_65:
result = (void *)sub_10004903(result, Src, 0, v6);
break;
case 89:
v3 = 0;
LABEL_3:
result = (void *)sub_10004475(result, Src, 1, v3);
break;
case 90:
v4 = 0;
LABEL_40:
result = (void *)sub_10004475(result, Src, 0, v4);
break;
case 91:
result = (void *)sub_1000477E(Src, 0);
break;
case 92:
result = (void *)sub_10007966(Src, result);
break;
case 93:
result = (void *)sub_100046AD(result, Src, 1);
break;
case 94:
result = (void *)sub_100046AD(result, Src, 0);
break;
case 95:
result = (void *)sub_1000585E(Src, result);
break;
case 96:
result = (void *)sub_100045C7(1);
break;
case 97:
result = (void *)sub_100045C7(0);
break;
case 98:
result = (void *)sub_10004520(1);
break;
case 99:
result = (void *)sub_10004520(0);
break;
default:
return result;
}
笔者理解这里之所以要存在这个算法,主要是两个作用:
1、缩短shellcode的长度,有些函数名以及模块名比较长,如果直接写到shellcode里面我们要使用像下面这种位置无关代码,这样就会占比较长位置(shellcode越短越好,因为在一些系统溢出漏洞中对内存空间的大小限制是非常严格的)。
char szMessageBoxA[] = { 'M','e', 's', 's', 'a', 'g', 'e', 'B', 'o', 'x', 'A', 0 };
机器码的存储形式是,如下图:
2、对抗静态分析把,如果直接出现一些函数名,模块名在里面,特征太明显了。(但是其实算法后的特定值之后也会被作为静态分析的特征)
上文这里没有详细写,如果想具体了解,可以参考笔者之前写的文章:
https://forum.butian.net/share/1934
中的shellcode编写部分的内容,如下图,这里面有详细讲:
目前已有的对上述shellcode加载过程的检测:
1、拉取beacon文件的时候,可以检测到发起的请求,这个请求满足算法checksum8(),返回文件很大,200000个字节左右。
2、我们可以去对beacon文件里面的内容做检测,因为这里beacon文件加密方式就是和一个密钥异或,并且密钥也是在beacon里面的,解密出来之后我们就可以看到pe文件的全貌了,这是一种检测思路
3、执行反射加载的dll里面dllmain方法的时候,触发c2客户端逻辑,发起心跳流量和命令执行流量,心跳流量请求的uri(http/https隧道的)、元数据传输字段,主要由生成shellcode的时候对应cs的c2profile文件控制。
4、当是https隧道的时候,因为TLS在建立连接的时候要发送证书,这里可以通过一些cs默认的证书去检测(当然如果更换证书了,这里就检测不了了);除此之外,笔者之前看了一篇文章,说这个c2server在和beacon利用TLS协议建立连接的时候,c2server端发送的一些内容是有特征的,能检测。
笔者看github上有个beaconeyes的项目,这个项目检测的是主机内存空间,将进程的内存dump下来,去判断我们上面说的dllmain初始化逻辑里面还原出来的一些数据,通过这些数据的通用形式来匹配。
还有很多,比如3环的操作,通过fs寄存器获取模块地址的这一行为特征也可以作为我们内存查杀的方式。再比如,专门对抗反射dll加载的技术,通过管控为dll加载的内存,在三环对virtualAlloc、VirtualPortect等函数进行hook来检测。
简单总结下cs的shellcode的思路:
1、执行shellcode,shellcode会通过fs寄存器获取内存模块加载表,从而从kernel32模块里面获取loadlibrary的地址,来加载wininet这个模块,加载之后从这个模块里面找到一些网络连接要用的函数(如,InternetConnectA,HttpSendRequestA等等),通过调用这些函数,向cobaltstrike的c2 server拉取beacon文件,并执行。
2、执行beacon,对其中部分数据进行解密,还原出来一个pe文件,并执行。
3、执行pe文件头部,通过dos头引导,执行pe文件里面的reflectiveloader函数,reflectiveloader函数里面主要实现:
通过3环fs寄存器那套,找到kernel模块里面我们要用的几个函数(GetProcessAddress、GetModuleHandleA、LoadLibraryA、LoadLibraryExA、VirtualAlloc、VirtualProtect)
通过找到的函数,实现对dll本身的加载(1、将pe从文件格式映射到内存格式;2、修复导入表 3、修复重定位表 4、运行dllmian(初始化))
4、dos引导的最后也是会调用从reflectiveLoader函数里面返回的dllmain函数,dllmain里面实现c2客户端的通信逻辑,发送心跳,执行命令等
笔者才疏学浅,若文中存在错误观点,欢迎斧正。