通過 DNS 協議探測 Cobalt Strike 伺服器

語言: CN / TW / HK

作者:非攻安全團隊

原文連結: 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/

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