通过 DNS 协议探测 Cobalt Strike 服务器

作者:非攻安全团队

原文链接:https://mp.weixin.qq.com/s/peIpPJLt4NuJI1a31S_qbQ

Cobalt Strike,是一款国外开发的渗透测试神器,其强大的内网穿透能力及多样化的攻击方式使其成为众多APT组织的首选。如何有效地检测和识别Cobalt Strike服务器一直以来都是安全设备厂商和企业安全关注的焦点。

近日,F-Secure的安全研究员发布了一篇文章讲述了如何探测Cobalt Strike DNS重定向服务。其主要探测方式是向Cobalt Strike服务器发起多个不同域名的查询(包括A记录和TXT记录),然后对比每个查询的返回结果。如果返回结果相同,那么对应的服务器很可能就是潜在的Cobalt Strike C2服务器。随后,我们对Cobalt Strike DNS 服务代码层面进行了分析,发现了检测Cobalt Strike DNS 服务的另一种方法,并选择在某大型演练活动后进行发布。

01 Stager 分析

在对代码分析前,我们有必要通过抓包简单了解Cobalt Strike DNS Beacon与DNS Server的通信过程。DNS Beacon主要有两种形式。一种是带阶段下载的Stager,另一种是无阶段的Stageless。这里我们主要分析Stager Beacon,本地搭建的Cobalt Strike版本为4.2,IP地址192.168.100.101,DNS Listener绑定的域名为ns.dns.com,用到的profile配置如下:

set host_stage "true";

set maxdns "255";

set dns_max_txt "252";

set dns_idle "74.125.196.113"; #google.com (change this to match your campaign)

set dns_sleep "0"; # Force a sleep prior to each individual DNS request. (in milliseconds)

set dns_stager_prepend ".resources.123456.";

set dns_stager_subhost ".feeds.123456.";

运行Stager的Beacon后,通过WireShark可以观察到Beacon与Cobalt Strike的通信过程。捕获的数据看下图:

其中ns.dns.com是Cobalt Strike Listener中绑定的域名,而.feeds.123456.是我们在profile中配置的dns_stager_subhost值。整个通信的过程中Beacon请求的都是TXT记录。

通过nslookup请求aaa.feeds.123456.ns.dns.com的TXT记录,查看返回结果可以看到传输的数据都在text字段中,而数据开头的.resource.123456.是我们profile中dns_stager_prepend的值。

进一步分析后发现,Beacon请求的第一个域名是aaa.feeds.123456.ns.dns.com,然后是baa.feeds.123456.ns.dns.com,随后按照一定顺序发出大量的TXT记录查询,直到最后一个请求tkc.feeds.123456.ns.dns.com。请求顺序可以表示如下:

aaa.feeds.123456.ns.dns.com

baa.feeds.123456.ns.dns.com

:

zaa.feeds.123456.ns.dns.com

aba.feeds.123456.ns.dns.com

cba.feeds.123456.ns.dns.com

:

zba.feeds.123456.ns.dns.com

aca.feeds.123456.ns.dns.com

cca.feeds.123456.ns.dns.com

:

zza.feeds.123456.ns.dns.com

aab.feeds.123456.ns.dns.com

cab.feeds.123456.ns.dns.com

:

tkc.feeds.123456.ns.dns.com

不难发现,每次请求域名中的第一个子域都是固定三个字母,并按照一定顺序进行排列。排列规则看起来是包含26个字母的集合连续进行了2次笛卡尔积。所以很容易就可以模拟Stager Beacon从Cobalt Strike DNS服务请求数据。

def stager():

buff = ""

str1 = 'abcdefghijklmnopqrstuvwxyz'

resolver = dns.resolver.Resolver()

resolver.nameservers = ['192.168.100.101']

for i in product(str1, str1, str1):

dnsc = '{0}.feeds.123456.ns.dns.com'.format(''.join(i[::-1])).strip()

try:

text = resolver.resolve(dnsc, 'txt')[0].to_text().strip('"')

except NoNameservers:

break

except:

return

if text=="":

break

#time.sleep(0.3)

buff = buff + text

return buff

查询结束后,将得到的数据进行拼接,最终数据可简单表示如下:

.resources.123456.WYIIIIIIIIIIIIIIII7QZjAX...8ioYp8hnMyoYoIoAAgogoJAJAJAJAJAJAJAJAJAENFKFCEFOIAAAAAAAAFLIJNPFFIJOFIBMDPPHJAAAAPPNDGIPALFKCFGGIAEAAAAAAFHPPNAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAAAAAAAHKDPGLIOCHPPLNKGNJINHEIMMEABKBEIKCFPBOAOAHDDPPFPKOGFBCDFFODANEJGBDANKODPGJIIIIPDDCODOGNCBLCMHHMPCEBNBMJKCF...AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...

由于数据并不直观,所以还需要逆向Cobal Strike的jar包源代码还原数据处理的过程。在使用Idea反编译后,可以直接定位到加密的入口是在beacon\beaconDns.java中的setPayloadStage()函数,而传入的数据var1则是DNS Beacon的Shellcode,也就是Stager Beacon请求的最终数据。

public void setPayloadStage(byte[] var1) {

this.stage = this.c2profile.getString(".dns_stager_prepend") + ArtifactUtils.AlphaEncode(var1);

}

setPayloadStage()函数首先获取的是profile中dns_stager_prepend值,也就是.resource.123456.,然后调用了AlphaEncode()函数加密Shellcode并与前面获取的值拼接。

跟进AlphaEncode()函数发现其位于common\BaseArtifactUtils.java

public static String AlphaEncode(byte[] var0) {

AssertUtils.Test(var0.length > 16384, "AlphaEncode used on a stager (or some other small thing)");

return _AlphaEncode(var0);

}

public static String _AlphaEncode(byte[] var0) {

String var1 = CommonUtils.bString(CommonUtils.readResource("resources/netbios.bin"));

var1 = var1 + "gogo";

var1 = var1 + NetBIOS.encode('A', var0);

var1 = var1 + "aa";

return var1;

}

可以看到,对Shellcode只是进行简单的NetBios编码,编码后再和固定字符拼接。所以我们只需将字符串aa和gogo中间部分的数据提取出来进行NetBios解码便可以得到Shellcode。

以上过程很容易就可以用Python实现,可以参考如下代码:

import time

from dns.resolver import *

from itertools import *

def stager():

buff = ""

str1 = 'abcdefghijklmnopqrstuvwxyz'

resolver = dns.resolver.Resolver()

resolver.nameservers = ['192.168.100.101']

for i in product(str1, str1, str1):

dnsc = '{0}.feeds.123456.ns.dns.com'.format(''.join(i[::-1])).strip()

try:

text = resolver.resolve(dnsc, 'txt')[0].to_text().strip('"')

except NoNameservers:

break

except:

return

if text=="":

break

#time.sleep(0.3)

buff = buff + text

if "aa" in buff and "gogo" in buff:

f = open("beacon.bin", "wb")

f.write(bytearray(netbios_decode(buff.split('gogo')[-1].split('aa')[0])))

f.close()

def netbios_decode(netbios):

i = iter(netbios.upper())

try:

return [((ord(c)-ord('A'))<<4)+((ord(next(i))-ord('A'))&0xF) for c in i]

except:

return ''

if __name__=="__main__":

stager()

运行上面的Python脚本后会在脚本目录下生成beacon.bin文件,可以直接使用Beacon Parser脚本解析配置,也可以直接使用Shellcode Loader加载上线。

02 特征分析

对代码进一步分析后,我们在beacon/beaconDns.java中还发现了有趣的地方。

public DNSServer.Response respond_nosync(String var1, int var2) {

StringStack var3 = new StringStack(var1.toLowerCase(), ".");

if (var3.isEmpty()) {

return this.idlemsg;

} else {

String var4 = var3.shift();

if (var4.length() == 3 && "stage".equals(var3.peekFirst())) {//判断第二个子域是非为stage

return this.serveStage(var4);

} else {

String var5;

String var6;

if (!"cdn".equals(var4) && !"api".equals(var4) && !"www6".equals(var4)) {

if (!"www".equals(var4) && !"post".equals(var4)) {

if (this.stager_subhost != null && var1.length() > 4 && var1.toLowerCase().substring(3).startsWith(this.stager_subhost)) {

return this.serveStage(var1.substring(0, 3));

} else if (CommonUtils.isHexNumber(var4) && CommonUtils.isDNSBeacon(var4)) {

var4 = CommonUtils.toNumberFromHex(var4, 0) + "";

...

...

}

}

}

}

Cobalt Strike服务器在处理DNS查询的时候会先对请求域名的前两个子域进行判断,比如请求的域名为aaa.bbb.ccc.com,会判断aaa的长度是不是等于3,bbb的值是不是等于stage。如果都满足就进入serveStage()函数。跟进后发现serveStage()函数也只是简单判断了stage的长度后就返回了请求对应的值。

protected DNSServer.Response serveStage(String var1) {

int var2 = CommonUtils.toTripleOffset(var1) * 255;

if (this.stage.length() != 0 && var2 <= this.stage.length()) {

return var2 + 255 < this.stage.length() ? DNSServer.TXT(CommonUtils.toBytes(this.stage.substring(var2, var2 + 255))) : DNSServer.TXT(CommonUtils.toBytes(this.stage.substring(var2)));

} else {

return DNSServer.TXT(new byte[0]);

}

}

也就是说,当请求的域名以aaa.stage.开头时,Cobalt Strike 服务器会直接响应我们的请求,请求aaa.stage.ns.dns.com等同于请求aaa.feeds.123456.ns.dns.com。

同时,由于Cobalt Strike服务器并没判断请求的域名后缀,当我们可以直接访问Cobalt Strike DNS服务的时候,可以直接忽略DNS Listener绑定的域名直接请求数据。当然,在profile配置host_stage为true的时候,可以使用将上面的Python代码替换feeds.123456.ns.dns.com为stage.xxx,运行后依然可以下载DNS Beacon的Shellcode。

当host_stage配置为false的时候,返回的结果有些不一样。

可以看到,Cobalt Strike服务器没有再返回Shellcode的数据,但是对以aaa.stage.开头的域名的TXT记录查询,Cobalt Strike服务器依旧响应了TXT记录。而其它的域名则像F-Secure研究员发现的那样,返回的是A记录,并且解析的IP就是profile中dns_idle的值。

当请求的域名第一个子域长度不为3开头并且第二个子域不是stage的时候,Cobalt Strike服务器还会进一步判断域名的第一个子域是否为cdn、api、www6、www、post。

if (var4.length() == 3 && "stage".equals(var3.peekFirst())) {

return this.serveStage(var4);

} else {

String var5;

String var6;

if (!"cdn".equals(var4) && !"api".equals(var4) && !"www6".equals(var4)) {

if (!"www".equals(var4) && !"post".equals(var4)) {

...

} else {

...

}

} else {//当请求域名的第一个子域是cdn、api、www6的时候

var3 = new StringStack(var1.toLowerCase(), ".");

var5 = var3.shift();

var6 = var3.shift();

var4 = CommonUtils.toNumberFromHex(var3.shift(), 0) + "";

if (this.cache.contains(var4, var6)) {

return this.cache.get(var4, var6);

} else {

SendConversation var7 = null;

if ("cdn".equals(var5)) {

var7 = this.conversations.getSendConversationA(var4, var5, var6);

} else if ("api".equals(var5)) {

var7 = this.conversations.getSendConversationTXT(var4, var5, var6);

} else if ("www6".equals(var5)) {

var7 = this.conversations.getSendConversationAAAA(var4, var5, var6);

}

DNSServer.Response var8 = null;

if (!var7.started() && var2 == 16) {

var8 = DNSServer.TXT(new byte[0]);//返回text=“”

} else if (!var7.started()) {

byte[] var9 = this.controller.dump(var4, 72000, 1048576);

if (var9.length > 0) {

var9 = this.controller.getSymmetricCrypto().encrypt(var4, var9);

var8 = var7.start(var9);

} else if (var2 == 28 && "www6".equals(var5)) {

var8 = DNSServer.AAAA(new byte[16]);//返回::

} else {

var8 = DNSServer.A(0L);//返回0.0.0.0

}

} else {

var8 = var7.next();

}

if (var7.isComplete()) {

this.conversations.removeConversation(var4, var5, var6);

}

this.cache.add(var4, var6, var8);

return var8;

}

}

当域名为cdn,www6, api作为第一个子域的时候,Cobalt Strike服务器会对不同的情况作处理。可以看到,当请求的类型是A记录的时候,Cobalt Strike服务器会返回固定的IP值为0.0.0.0。

当请求的类型是TXT记录的收获,返回的结果中text字段为空。

对于AAAA记录,Cobalt Strike服务器也会返回固定的地址::,只不过只能抓包看到。

由于返回的值都是固定的,同样没有判断域名后缀,所以完全可以拿来作为检测Cobalt Strike服务器的方法。以下是以api关键字作为检测的参考代码:

def checkA(host):

resolver = dns.resolver.Resolver()

resolver.nameservers = [host]

try:

#请求的xxxx.xxx最好是随机的,并多次尝试

ip = resolver.resolve("api.xxxx.xxx", 'A')[0].to_text()

except:

return False

if ip == "0.0.0.0":

return True

return False

当第一个子域为www,post的时候,处理情况又不相同,限于篇幅这里就不分析了,有兴趣的朋友可以自行研究。

03 检 测

本地验证没问题后,我们将目标转移到了公网上。为了快速地筛选出潜在的并且开启了DNS Server的Cobalt Strike服务器,我们可以通过一些关键字在网络空间探测平台中获取初定的目标。

通过分析发现Cobalt Strike返回的A记录中除返回的IP和域名外基本上数据是固定的。从Type字段开始到Data Length字段,Cobalt Strike每次响应都会返回\x00\x01\x00\x01\x00\x00\x00\x01\x00\x04,后面再接4个字节的IP,这里是0.0.0.0,也就是\x00\x00\x00\x00。如下图:

所以利用这样的特征,在FOFA或ZoomEye上可以很容易地就能找到潜在的开启了DNS 服务的Cobalt Strike服务器。因为有不少渗透测试人员喜欢把dns_idle设置为8.8.8.8。所以我们将0.0.0.0的IP地址替换为常用的8.8.8.8也就是\x08\x08\x08\x08作为查询关键字,便可以快速地找到潜在的监听了DNS服务的Cobalt Strike服务器。

导出了IP地址后,并用脚本进行了探测,探测的部分结果如下:

同时也发现了一些开启host_stage的IP,直接下载了DNS Beacon的Shellcode,下面是某IP的检测结果。

04 防 御

针对上面提到的特征,可以通过修改beacon/beaconDns.java中的代码,改变respond_nosync()处理请求的流程,增加判断,修改默认的返回值。可参考如下代码(注:该代码是4.2版本的代码,不过笔者本地测过CS最低版本是3.8,最高版本是4.2,代码可能会有差异,但是可以采取同样的方式):

public DNSServer.Response respond_nosync(String var1, int var2) {

StringStack var3 = new StringStack(var1.toLowerCase(), ".");

String dname = var1.toLowerCase().trim().substring(0, var1.length() - 1);

if (var3.isEmpty()) {

return this.idlemsg;

} else {

String var4 = var3.shift();

boolean CheckDname = false;

//增加了判断请求的类型是否为TXT同时验证了域名后缀是否为Listener配置的字符

if (var4.length() == 3 && var2 == 16 && dname.substring(3).startsWith(this.stager_subhost) && dname.endsWith(this.listener.getStagerHost().toLowerCase())) {

return this.serveStage(var4);

} else {

String var5;

String var6;

String[] dnameArray = dname.split("\\.");

String[] dC2Array = this.listener.getCallbackHosts().split(", ");

for (int i=0; i<dC2Array.length; i++){

if (dC2Array[i].endsWith(dnameArray[dnameArray.length - 2] + "." + dnameArray[dnameArray.length - 1])){

CheckDname = true;

}

}

//判断请求的域名后缀是否为绑定的域名后缀

if (!CheckDname){

return this.idlemsg;

}

if (!"cdn".equals(var4) && !"api".equals(var4) && !"www6".equals(var4)) {

if (!"www".equals(var4) && !"post".equals(var4)) {

//增加了判断请求的类型是否为TXT

if (this.stager_subhost != null && var2 == 16&& var1.length() > 4 && var1.toLowerCase().substring(3).startsWith(this.stager_subhost)) {

return this.serveStage(var1.substring(0, 3));

} else if (CommonUtils.isHexNumber(var4) && CommonUtils.isDNSBeacon(var4)) {

var4 = CommonUtils.toNumberFromHex(var4, 0) + "";

...

...

}

}

}else {//当请求域名的第一个子域是cdn、api、www6的时候

var3 = new StringStack(var1.toLowerCase(), ".");

var5 = var3.shift();

var6 = var3.shift();

var4 = CommonUtils.toNumberFromHex(var3.shift(), 0) + "";

if (this.cache.contains(var4, var6)) {

return this.cache.get(var4, var6);

} else {

SendConversation var7 = null;

if ("cdn".equals(var5)) {

var7 = this.conversations.getSendConversationA(var4, var5, var6);

} else if ("api".equals(var5)) {

var7 = this.conversations.getSendConversationTXT(var4, var5, var6);

} else if ("www6".equals(var5)) {

var7 = this.conversations.getSendConversationAAAA(var4, var5, var6);

}

DNSServer.Response var8 = null;

if (!var7.started() && var2 == 16) {

var8 = this.idlemsg;

//var8 = DNSServer.TXT(new byte[0]);返回text=“”

} else if (!var7.started()) {

byte[] var9 = this.controller.dump(var4, 72000, 1048576);

if (var9.length > 0) {

var9 = this.controller.getSymmetricCrypto().encrypt(var4, var9);

var8 = var7.start(var9);

} else if (var2 == 28 && "www6".equals(var5)) {

var8 = this.idlemsg;

//var8 = DNSServer.AAAA(new byte[16]);返回::

} else {

var8 = this.idlemsg;

//var8 = DNSServer.A(0L);返回0.0.0.0

}

} else {

var8 = var7.next();

}

if (var7.isComplete()) {

this.conversations.removeConversation(var4, var5, var6);

}

this.cache.add(var4, var6, var8);

return var8;

}

}

需要注意的是,上面的代码并没有修复域名请求返回的A记录IP固定为dns_idle值的特征。但是我们可以在Cobalt Strike服务器前面再部署一台正常的DNS服务,如下图,根据请求的域名进行转发,并利用Iptable设置白名单来绕过检测,这里就不详细介绍了。具体可以参考F-Secure发布的文章末尾提到的方法。

05 总 结

本篇文章简单分析了Cobalt Strike DNS Beacon与Cobalt Strike 服务之间的通信,并在分析Cobalt Strike DNS 服务的代码中找到了以下的特征:

  1. 当Cobalt Strike服务器的profile配置stage_host为true的时候,可以使用带有stage关键字的域名模拟stager下载DNS Beacon的Shellcode。

  2. 使用api、cdn、www6作为第一个子域的域名如api.ns.dns.com向Cobalt Strike DNS服务查询A记录时将返回固定ip地址0.0.0.0,查询TXT记录是返回的text字段为空。

  3. 当查询时用目标Cobalt Strike的作为名称解析服务器的时候,上述请求可以忽略域名后缀,比如查询api.xxx.xxxx和查询api.ns.dns.com都会返回0.0.0.0。

结合以上特征,可以精确地检测出监听了DNS的Cobalt Strike服务器,并在公网上得到了验证,同时也给出了防御的参考代码和思路。

参考链接:

https://labs.f-secure.com/blog/detecting-exposed-cobalt-strike-dns-redirectors/

扫码关注公众号:非攻安全

以上是 通过 DNS 协议探测 Cobalt Strike 服务器 的全部内容, 来源链接: utcz.com/p/199912.html

回到顶部