众所周知,Cobalt Strike
是一款在渗透测试活动当中,经常使用的C2
(Command And Control/远程控制工具)。而Cobalt Strike
的对抗是在攻防当中逃不开的话题,近几年来该领域对抗也愈发白热化。而绝大多数厂商的查杀,也是基于内存进行,然而其检测方式的不当,导致非常容易被Bypass
,包括但不限于
RWX
内存(正常进程中的堆区域一般没有执行权限)
上面列出的这些方法,实际都能绕过,核心原因是,去掉这些特征可以不影响Cobalt Strike Beacon
的正常运行
BeaconEye
的核心原理是通过扫描Cobalt Strike
中的内存特征,并进行Beacon Config
扫描解析出对应的Beacon
信息,项目地址是https://github.com/CCob/BeaconEye
Cobalt Strike
的shellcode
,实际都是通过反射加载的方式加载Beacon.dll
,而Beacon.dll
中存在Beacon Config
配置信息(主要定义通信目标/通信方式等),在Cobalt Strike
中对应的Resource
是sleeve/beacon.dll
Beacon Config
的生成在BeaconPayload
类的exportBeaconStage
函数中
这上面指向的Settings
结构体就是Beacon Config
,比如var1
,它代表实际通信的端口
最终Cobalt Strike
会将Settings
转化为bytes
数组,然后使用固定的密钥进行Xor
,并对剩余空白字段填入随机字符
最后将生成的beacon.dll
嵌入到最终的PE
文件中
Settings
的Add
系列函数,如AddShort
,并不是简单的将Short
类型直接追加到bytes
数组中,而是追加了一个结构体
第一个字段是index
,第二个是type
(short/int/...),第三个是length
,第四个则是关键的value
值,因此根据这个结构即可解析在内存或在文件中的Beacon Config
接下来让我们看一下BeaconEye
的yara
规则
32位的Beacon Config
规则长这个样子,如果你认真阅读了前文一定会觉得很疑惑,因为按照Java
当中的结构,它应该分为四个部分
[ ID ] [ DATA TYPE ID ] [ LENGTH OF VALUE ] [ VALUE ]
但是实际的yara
规则却没有办法对上java
中的Beacon Config
结构,说明Beacon.dll
在装载的过程中,并没有直接将上述数据memcpy
分配到堆中,接下来让我们通过对beacon.dll
进行逆向
通过dllmain
跟进,发现有一个关键函数,里面首先解密了先前Beacon Config
的加密数据,然后遍历Beacon Config
。首先是在拿到了Type
之后,直接往堆中分配的内存写入WORD
长度的Type
,然后根据Type
进行判断,case 1
对应Short
,case 2
对应Int
,case 3
对应Data
,所以实际上最终的Beacon Config
的结构是
DWORD DWORD
[ DATA TYPE] [ VALUE ]
因此最终的yara
规则可以解读如下
??
代表通配符,实际匹配的就是beacon.dll
当中真正的config
结构体,到这一步,后面的结构体还原就是顺水推舟了
而近日有安全人员提出在执行Cobalt Strike
的Shellcode
之前,通过调用SymInitialize
即可实现Bypass,本着好奇的态度,笔者对原理进行了深入的探究
根据官方文档的描述,SymInitialize
的作用是用来初始化进程符号句柄的
它的传参有三个
SymLoadModule64
函数Bypass
,因此我们使用windbg
进行对比抓取接下来我们分别对调用了SymInitialize
和没有调用SymInitialize
的Cobalt Strike
的Beacon
进行windbg
调试,由于我们知道BeaconEye
扫描的是堆内存,因此我们直接对比两者的堆内存
从上面两张图我们可以很清晰的看到,这两个进程在堆内存中的最大的区别是,使用了SymInitialize
的第一个heap
区域,比没有使用SymInitialize
的第一个heap
区域,多了几个Segment
,那为什么多了几个Segment
就导致BeaconEye
无法扫描呢?
使用windbg
,执行如下命令,查看一下具体Heap
的结构
dt !_heap
可以看到heap
结构的字段非常的多,这里重点关心3个字段
BaseAddress ~ BaseAddress + NumberOfPages * PageSize
而每一个BaseAddress
以及NumberOfPages
,都仅仅只针对当前的堆段
BeaconEye
中查询内存信息实际调用的是NtQueryVirtualMemory
,我们都知道Nt
系列函数是Windows
中Ring3
进入Ring0
的入口,让我们查看该函数的官方文档
可以看到查询的信息都存到了MemoryInformation
中,而MemoryInformation
对应的结构体是MEMORY_INFORMATION_CLASS
,MEMORY_INFORMATION_CLASS
实际包含了一个 MEMORY_BASIC_INFORMATION
,MEMORY_BASIC_INFORMATION
结构如下
查看RegionSize
的描述
翻译过来的意思是,RegionSize
的计算方式是,从起始地址开始,直到内存页的属性不一致为止,包含的byte
数量,就是RegionSize
首先我们可以初步得出结论,BeaconEye
当中获取堆的信息时,实际只获取了第一个堆段(因为堆段和堆段之间是不连贯的,导致内存页属性不能保持一致),因此假设Beacon Config
没有被释放在第一个堆段中,就会导致BeaconEye
检测失败,为了实现这个猜想,笔者将SymInitialize
注释掉,转而手动调用HeapAlloc
进行堆分配(当堆空间分配的足够多时,就会触发系统自动生成堆段),如果这个猜想是正确的,那么BeaconEye
将同样无法扫描
编译运行,再使用BeaconEye
进行检测,发现已经无法检测了,猜想bingo
现在不能检测的原因已经找到了,修复其实非常简单,前面提到过,heap
结构中包含了堆段的双向链表,因此我们只需要在BeaconEye
当中,遍历这个双向链表,将所有堆段地址都添加到待扫描列表中即可,以下是修复代码
这个时候我们重新编译,扫描原先使用了SymInitialize
的Cobalt Strike Beacon
,发现已经可以扫出来了
但是事情远远没有那么简单,因为我发现先前手动调用HeapAlloc
的Cobalt Strike Beacon
并没有扫出来,这令我百思不得其解,为了解决问题,我的思路是先确定Beacon Config
在内存中哪个位置,这里同样使用yara
进行确认(扫描完整内存),得到具体的位置后,调试BeaconEye
并判断是否读取到了对应的内存。经过一番调试,发现BeaconEye
确实存在于堆段中,但是BeaconEye
并没有完整的读取到堆段的所有内存,示意图如下
红色部分是BeaconEye
实际读取到的内存,绿色部分是实际Beacon Config
存放的位置,为什么会出现这种情况,这个时候就得继续回到Windows
的内存设计上
在Windows
当的堆内存当中,除了堆段以外,还有一个概念叫堆块,每一个堆段都是由多个堆块组成的,使用vmmap
工具即可查看
不难发现每一个堆段包含了大量的堆块,而在实际的进程当中,堆块对应的结构体是_HEAP_ENTRY
而在_HEAP_SEGMENT
当中,只有FirstEntry
和LastValidEntry
,这两个字段的含义是指向第一个以及最后一个堆块
而经过阅读相关资料,发现并没有链表将所有堆块串联起来(无论堆块是何种状态),因此堆块的位置需要手动计算,这里存在一个小插曲,就是windows
实际是加密了_HEAP_ENTRY
这个结构的,加密方式是Xor
,而Xor
的密钥则在_HEAP
结构的0x88
(x86
是0x50
),因此在计算堆块大小时,需要手动解密Size
在之前的修复代码上,我们手动计算所有堆块的地址,并添加到待扫描列表当中,代码如下(方便演示这里只写了x64
部分)
重新扫描通过手动调用HeapAlloc
去Bypass
原版BeaconEye
的Cobalt Strike Beacon
,发现已经可以扫描了
目前这个加强修复版的代码,可以通杀Cobalt Strike
全版本(3.x
的yara
规则需要修改),这对攻击方来说提出了更高的挑战以及要求。目前该检测功能已经集成到 牧云Beta版 当中,也欢迎大家来申请试用体验更强大的主机安全产品。
另外,牧云正在招聘主机安全领域的产品安全研究员,如果你和我一样,喜欢研究红蓝对抗,并希望将它落地到产品当中,欢迎投递简历,投递邮箱为jingyuan.chen@chaitin.com