作者:非攻安全团队
原文链接: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 服务的另一种方法,并选择在某大型演练活动后进行发布。
在对代码分析前,我们有必要通过抓包简单了解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加载上线。
对代码进一步分析后,我们在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的时候,处理情况又不相同,限于篇幅这里就不分析了,有兴趣的朋友可以自行研究。
本地验证没问题后,我们将目标转移到了公网上。为了快速地筛选出潜在的并且开启了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的检测结果。
针对上面提到的特征,可以通过修改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发布的文章末尾提到的方法。
本篇文章简单分析了Cobalt Strike DNS Beacon与Cobalt Strike 服务之间的通信,并在分析Cobalt Strike DNS 服务的代码中找到了以下的特征:
当Cobalt Strike服务器的profile配置stage_host为true的时候,可以使用带有stage关键字的域名模拟stager下载DNS Beacon的Shellcode。
使用api、cdn、www6作为第一个子域的域名如api.ns.dns.com向Cobalt Strike DNS服务查询A记录时将返回固定ip地址0.0.0.0,查询TXT记录是返回的text字段为空。
当查询时用目标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/
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1568/