作者:非攻安全團隊
原文鏈接: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/

掃碼關注公眾號:非攻安全


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1568/