BeaconEye绕过
BeaconEye中对cobalt strike内存的检测逻辑如下,检测的是内存中profile的配置信息。
![]()
cs源码分析profile构成方式。在服务端创建一个Listener之后会处理beacon、profile和sleepmask。首先进入到Common.ScListener#export()函数中。
![]()
一直跟到src.Beacon.BeaconPayload#exportBeaconStage()函数中的Settings结构,这个结构体保存了profile处理后的格式。在415行创建了Settings结构体后,读取配置信息并按照一定的格式写入结构体
![]()
跟进addShort(),多次调用了this.patch.addShort()写入key和value,写入后的数据先保存在this.patch.out.buf,写入对应的位置如下。addShort()会在写入key和value值的中间插入两个字节的1、2
![]()
第二次调用addShort()函数写入后buf中的结果
![]()
addInt()函数,这一回value的占用大小为4个字节。
![]()
继续往下,添加pivot、killDate、进程注入的配置,接着会填充4096个随机字节在配置的末尾,处理完这些后会对profile配置做一次异或加密。函数中94就是加密密钥。这里已经将默认的修改掉了。
![]()
![]()
将加密前后的profile配置信息导出到文件分析,和BeaconEye的扫描规则并不匹配,BeaconEye中开头有16个字节的0
![]()
在函数最后return的数据是将profile写入到beacon后的字节数据,将其也导出
![]()
逆向分析导出的BeaconDll,在写本文之前已经分析过一遍了,所以后面就用分析后的dll代码进行记录。
DllMain会进入process_profile_config函数中,这个函数是处理profile逻辑函数
![]()
跟进,函数会先创建一块新的内存空间并将句柄保存在profile_mem_addr变量中,之后将空间内容全部重置为6(原来是0,这里已经改成了6),向下进入for循环xor解密profile配置信息,如下图。
![]()
![]()
解密之后完成之后,会保存解密后的内存地址和大小到变量profile_decode_ptr中。然后执行下一个for循环,
在这个for循环中,首先调用readShort函数将结果返回给变量j,接着再执行for循环内的逻辑。函数readShort逻辑如下,首先会做一个if判断,然后调用ntohs函数读取两个字节,对应的正好是服务端代码中的addShort()函数的逻辑,读取完成后当前读取的指针地址+2,未读取内存空间大小-2,返回读取的结果。
![]()
在for循环语句体内,首先判断第一个readShort读取的数据是否为0,是则退出循环,不为0则继续读取两个字节赋值给变量data_type,第三次读取赋值给变量data_size(变量的含义是根据下面代码中switch对数据的使用得出的),之后计算一次地址偏移,第一次的offset根据动态调试发现是8,这也就对应上了BeaconEye为什么前面会有一堆0的检测,只要我将上面的memset重置的内存设置为非0就能绕过BeaconEye的规则了。
![]()
回到IDA,计算完偏移后将变量data_type的值写入到偏移指定的地址,(第一次写入的是数据类型),往下进入switch循环,对data_type的值进行判断如果是1则再次读取两个字节并写入到内存空间,是2则读取4个字节并写入,3则是string类型,sub_10006C81传入了数据大小data_size。
![]()
分析到这里就明白了,cobalt strike只会将profile中的数据类型和数据内容写入到内存,并且保存profile处理后的内存空间中前8个数据为0,这时候beaconeye扫描是能扫描到的,绕过方法就是在其memset的时候将其替换为非0
![]()
![]()