├── .gitignore ├── .pylintrc ├── README.md ├── bytes_utils.py ├── cobalt_commons.py ├── cobalt_config_tool.py ├── find_mem_config.yara └── import_fixer.py /.gitignore: -------------------------------------------------------------------------------- 1 | _local/ 2 | __pycache__/ 3 | TEST* -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [FORMAT] 2 | 3 | 4 | # Number of spaces of indent required inside a hanging or continued line. 5 | indent-after-paren=4 6 | 7 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 8 | # tab). 9 | indent-string=' ' 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CobaltStrikeConfigParser 2 | Parser (and extractor) for CobaltStrike config. 3 | 4 | Files: 5 | - cobalt_config_tool.py - extract, parse and print config 6 | - cobalt_const.py - all numeric constants 7 | - bytes_utils.py - all byte-level manipulation stuff 8 | - find_mem_config.yara - yara rule, find extracted config in memory dump 9 | - import_fixer.py - fixing encrypted (xored) imports of staged binary 10 | 11 | 12 | ## Usage 13 | ``` 14 | 15 | 16 | usage: cobalt_config_tool.py [-h] [--ftype {bin,minidump}] [--key KEY] [--mode {p,u,a}] --format {json,yaml,http,curl,request,text,none} [--decrypt] [--verbose] file_path 17 | 18 | +--- - - ---------+ 19 | | CobaltStrike Beacon config tool | 20 | +------- - ----+ 21 | 22 | 23 | positional arguments: 24 | file_path Path to file (config, dump, pe, etc) 25 | 26 | optional arguments: 27 | -h, --help show this help message and exit 28 | --ftype {bin,minidump} 29 | Input file type. Default=raw 30 | --key KEY Hex encoded, 1 byte xor key to use when doing xor-search 31 | --mode {p,u,a} Search for [p]acked or [u]npacked or try [a]ll config. Default=[a]ll 32 | --format {json,yaml,http,curl,request,text,none} 33 | Output format 34 | --decrypt Try to decrypt input file w/ 4b xor 35 | --verbose Verbose mode. Messages goes to STDERR 36 | 37 | Available output formats: 38 | - json : JSON output 39 | - yaml : YAML output 40 | - http : Prepare HTTP request body (for burp, etc) 41 | - curl : Craft CURL requests to c2 42 | - request : Try to make HTTP request to c2 43 | - text : Plain text output 44 | - none : Print nothing. just parse 45 | 46 | 47 | 48 | ``` 49 | 50 | ## Example output: 51 | 52 | 53 | ### Text output 54 | ``` 55 | #python cobalt_config_tool.py pe32 --format text | head | grep ... 56 | [ ID:1/0x01 type:1 size:2 name:CFG_BeaconType ] 57 | 'HTTP' 58 | [ ID:2/0x02 type:1 size:2 name:CFG_Port ] 59 | 80 60 | [ ID:3/0x03 type:2 size:4 name:CFG_SleepTime ] 61 | 60000 62 | ``` 63 | 64 | ### Getting config as JSON 65 | ``` 66 | #python cobalt_config_tool.py pe32 --format json | jq . | head 67 | [ 68 | { 69 | "idx": 1, 70 | "hex_id": "0x01", 71 | "name": "CFG_BeaconType", 72 | "kind": 1, 73 | "size": 2, 74 | "data": 0, 75 | "parsed": "[0x0000] HTTP" 76 | }, 77 | ``` 78 | ### Parsing minidump file 79 | 80 | ``` 81 | >python cobalt_config_tool.py runshell1_f2.dmp --ftype minidump --mode u --format text | head | grep ... 82 | [ ID:1/0x01 type:1 size:0 name:CFG_BeaconType ] 83 | '[0x0000] HTTP' 84 | [ ID:2/0x02 type:1 size:0 name:CFG_Port ] 85 | 80 86 | [ ID:3/0x03 type:2 size:0 name:CFG_SleepTime ] 87 | 60000 88 | 89 | ``` 90 | 91 | ### Getting ready HTTP request 92 | Ready to be pasted into BURP :-) 93 | ``` 94 | python cobalt_config_tool.py pe32 --format http | head 95 | ---- REQUEST http ---- 96 | GET /some/get/path HTTP/1.1 97 | Host: c2.hostname.com 98 | Accept: */* 99 | Accept-Language: en-US 100 | Connection: close 101 | Cookie: prov=)>;notice-ctt=2 102 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) 103 | 104 | ------ / -------- 105 | ``` -------------------------------------------------------------------------------- /bytes_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | ByteArray tools 3 | """ 4 | import io 5 | import struct 6 | import re 7 | 8 | try: 9 | from minidump.minidumpfile import MinidumpFile 10 | except ImportError: 11 | MinidumpFile = None 12 | 13 | 14 | SIZE_DWORD = 4 15 | SIZE_WORD = 2 16 | 17 | def netbios_encode(bin_string, from_char=b'a'): 18 | """nibble-encoder""" 19 | from_char = from_char[0] 20 | return b''.join([bytes([(c>>4)+from_char,(c&0xF)+from_char]) for c in bin_string]) 21 | 22 | def netbios_decode(bin_string, from_char=b'a'): 23 | """nibble-decoder""" 24 | retval = [] 25 | from_char = from_char[0] 26 | for i in range(0,len(bin_string),2): 27 | byte1, byte2 = bin_string[i:i+2] 28 | retval.append( (byte1-from_char)*0x10 + (byte2-from_char) ) 29 | return bytes(retval) 30 | 31 | 32 | class BinStream(io.BytesIO): 33 | """ Extended version of BytesIO """ 34 | 35 | def __init__(self, blob): 36 | self.size = len(blob) 37 | io.BytesIO.__init__(self, blob) 38 | 39 | def read_n(self, how_many): 40 | """ Read exacly N bytes, raise exception otherwise""" 41 | tmp = self.read(how_many) 42 | if len(tmp) < how_many: 43 | raise Exception("Not enough data ;/") 44 | return tmp 45 | 46 | def read_one(self, fmt): 47 | """ Read exacly ONE format data """ 48 | size = struct.calcsize(fmt) 49 | return struct.unpack(fmt, self.read_n(size))[0] 50 | 51 | def read_byte(self): 52 | """ read one byte """ 53 | return struct.unpack("B", self.read_n(1))[0] 54 | 55 | def read_n_word(self): 56 | """ read net-order WORD """ 57 | return struct.unpack(">H", self.read_n(SIZE_WORD))[0] 58 | 59 | def read_n_dword(self): 60 | """ read net-worder DWORD """ 61 | return struct.unpack(">I", self.read_n(SIZE_DWORD))[0] 62 | 63 | def read_h_word(self): 64 | """ read host-order WORD """ 65 | return struct.unpack(" ", end='') 99 | self.pattern = encoder(pattern) 100 | self.size = len(pattern) 101 | self.end = self.start + self.size 102 | #print(self) 103 | 104 | def test(self, data, base=0): 105 | #print("TEST : ", list(chunks_generator(data[base+self.start:][:40].hex(), 8) ) ) 106 | #print("MATCH: ", list(chunks_generator(self.pattern.hex() , 8) ) ) 107 | #print(self) 108 | #print(data[base + self.start : base + self.end ], self.pattern) 109 | return data[base + self.start : base + self.end ] == self.pattern 110 | 111 | def __str__(self): 112 | return f"{self.start}...{self.end} == {self.pattern}" 113 | 114 | NOT_FOUND = -1 115 | 116 | class AlmostLikeYara: 117 | """ super simple binary pattern matching engine """ 118 | patterns = None 119 | total_size = 0 120 | 121 | def __init__(self, pattern, encoder=None): 122 | chunk = '' 123 | offset = 0 124 | self.patterns = list() 125 | for element in re.split('[^0-9A-Fa-f?]+',pattern): 126 | if element != '??': 127 | chunk += element 128 | continue 129 | # it is "??" 130 | offset += 1 131 | if chunk == '': # buffer is empty .. 132 | continue 133 | obj = SinglePattern(offset -1 , bytes.fromhex(chunk), encoder) 134 | self.patterns.append(obj) 135 | offset += obj.size 136 | chunk = '' 137 | self.total_size = offset 138 | 139 | def test_data(self, data, offset=0): 140 | match_cnt = 0 141 | for element in self.patterns: 142 | if element.test(data, offset): 143 | match_cnt +=1 144 | else: 145 | return False 146 | return True 147 | 148 | def find_in_data(self, data): 149 | max_size = len(data) - self.total_size 150 | if max_size < 1: 151 | return NOT_FOUND 152 | cursor = 0 153 | while cursor < max_size: 154 | success = self.test_data(data, cursor) 155 | if success: 156 | return cursor 157 | cursor += 1 158 | return NOT_FOUND 159 | 160 | def smart_find_callback(self, data, candidate_generator): 161 | first = self.patterns[0] 162 | #print(first, len(data)) 163 | for candidate in candidate_generator(first.pattern): 164 | #print("Candidate:", candidate) 165 | result = self.test_data(data, candidate) 166 | if result: 167 | return candidate 168 | return NOT_FOUND 169 | 170 | def smart_search(self, data): 171 | def _gen(pattern): 172 | for offset in bytes_find_generator(data, pattern): 173 | yield offset 174 | return self.smart_find_callback(data, _gen) 175 | 176 | 177 | class AbstractDataProvider: 178 | config_at = NOT_FOUND 179 | data_encoder = None 180 | source = None 181 | 182 | def __init__(self, source, *a, **kw): 183 | self.source = source 184 | self.setup(*a, **kw) 185 | 186 | def setup(self): 187 | pass 188 | 189 | def config_found(self, addr): 190 | self.config_at = addr 191 | 192 | def set_encoder(self, enc): 193 | self.data_encoder = enc 194 | 195 | def read(self, addr, size): 196 | chunk = self._raw_read(addr, size) 197 | if self.data_encoder: 198 | return self.data_encoder(chunk) 199 | return chunk 200 | 201 | def find_using_func(self, func): 202 | return NOT_FOUND 203 | 204 | 205 | class BinaryData(AbstractDataProvider): 206 | """ Interface to flat binary file """ 207 | ## TODO: implement buffered reader/mapFile for large flat files ? 208 | 209 | data = b'' 210 | def setup(self): 211 | self.data = open(self.source,'rb').read() 212 | 213 | def replace_data(self, data): 214 | self.data = data 215 | 216 | def find_using_func(self, func): 217 | """ find using callback, feed w/ data """ 218 | result = func(self.data) 219 | self.found_at = result 220 | return result 221 | 222 | def _raw_read(self, addr, size): 223 | """ read ( address, size ) """ 224 | return self.data[addr:addr+size] 225 | 226 | 227 | 228 | class MinidumpData(AbstractDataProvider): 229 | """ interface for minidump file format """ 230 | 231 | def setup(self): 232 | if MinidumpFile is None: 233 | raise Exception("Need to have working minidump module !") 234 | self.obj = MinidumpFile.parse(self.source) 235 | self._reader = self.obj.get_reader() 236 | 237 | 238 | def find_using_func(self, func): 239 | """ find using callback, feed w/ data """ 240 | for seg in self._reader.memory_segments: 241 | blob = seg.read(seg.start_virtual_address, seg.size, self._reader.file_handle) 242 | result = func(blob) 243 | if result != NOT_FOUND: 244 | self.found_at = result + seg.start_virtual_address 245 | return result 246 | return NOT_FOUND 247 | 248 | def _raw_read(self, addr, size): 249 | """ read ( address, size ) """ 250 | return self._reader.read(addr, size) 251 | -------------------------------------------------------------------------------- /cobalt_commons.py: -------------------------------------------------------------------------------- 1 | """ 2 | Various CS constant values. 3 | For clear source code purpose 4 | """ 5 | 6 | from bytes_utils import _chunks_generator 7 | 8 | MAX_ID = 60 9 | MAX_SIZE = 4096 10 | MAX_REC_SIZE = 0x100 11 | 12 | PACKED_CONFIG_PATTERN = """ 13 | 00 01 00 01 00 02 ?? ?? 14 | 00 02 00 01 00 02 ?? ?? 15 | 00 03 00 02 00 04 16 | """ 17 | 18 | UNPACKED_CONFIG_PATTERN = """ 19 | 00 00 00 00 00 00 00 00 20 | 01 00 00 00 ?? 00 00 00 21 | 01 00 00 00 ?? ?? 00 00 22 | 02 00 00 00 ?? ?? ?? ?? 23 | 02 00 00 00 ?? ?? ?? ?? 24 | 01 00 00 00 ?? ?? 00 00 25 | 01 00 00 00 ?? ?? 00 00 26 | 03 00 00 00 27 | """ 28 | 29 | OPT_TYPE_WORD = 1 30 | OPT_TYPE_DWORD = 2 31 | OPT_TYPE_BINARY = 3 32 | 33 | ## 34 | def _cfg_entry(_id, exp_type=None, exp_size=None): 35 | if exp_size is None: 36 | if exp_type == OPT_TYPE_WORD: 37 | exp_size = 2 38 | if exp_type == OPT_TYPE_DWORD: 39 | exp_size = 4 40 | return dict(id=id, exp_type=exp_type, exp_size=exp_size) 41 | 42 | CFG_OPT_BY_NAME = dict( 43 | CFG_BeaconType = _cfg_entry(1, OPT_TYPE_WORD), 44 | CFG_Port = _cfg_entry(2, OPT_TYPE_WORD), 45 | ) 46 | 47 | CFG_OPT_BY_ID = dict() 48 | for name, elem in CFG_OPT_BY_ID.items(): 49 | elem['name'] = name 50 | CFG_OPT_BY_ID[elem['id']] = elem 51 | 52 | ## 53 | 54 | OPT_TO_ID = dict( 55 | CFG_BeaconType = 1, 56 | CFG_Port = 2, 57 | CFG_SleepTime = 3, 58 | CFG_MaxGetSize = 4, 59 | CFG_Jitter = 5, 60 | CFG_MaxDNS = 6, 61 | CFG_PublicKey = 7, 62 | CFG_C2Server = 8, 63 | CFG_UserAgent = 9, 64 | CFG_HttpPostUri = 10, 65 | CFG_Malleable_C2_Instructions = 11, 66 | CFG_HttpGet_Metadata = 12, 67 | CFG_HttpPost_Metadata = 13, 68 | CFG_SpawnTo = 14, 69 | CFG_PipeName = 15, 70 | CFG_DNS_Idle = 19, 71 | CFG_DNS_Sleep = 20, 72 | CFG_SSH_Host = 21, 73 | CFG_SSH_Port = 22, 74 | CFG_SSH_Username = 23, 75 | CFG_SSH_Password_Plaintext = 24, 76 | CFG_SSH_Password_Pubkey = 25, 77 | CFG_HttpGet_Verb = 26, 78 | CFG_HttpPost_Verb = 27, 79 | CFG_HttpPostChunk = 28, 80 | CFG_Spawnto_x86 = 29, 81 | CFG_Spawnto_x64 = 30, 82 | CFG_CryptoScheme = 31, 83 | CFG_Proxy_Config = 32, 84 | CFG_Proxy_User = 33, 85 | CFG_Proxy_Password = 34, 86 | CFG_Proxy_Behavior = 35, 87 | CFG_Watermark = 37, 88 | CFG_bStageCleanup = 38, 89 | CFG_bCFGCaution = 39, 90 | CFG_KillDate = 40, 91 | CFG_TextSectionEnd = 41, 92 | CFG_ObfuscateSectionsInfo = 42, 93 | CFG_bProcInject_StartRWX = 43, 94 | CFG_bProcInject_UseRWX = 44, 95 | CFG_bProcInject_MinAllocSize = 45, 96 | CFG_ProcInject_PrependAppend_x86 = 46, 97 | CFG_ProcInject_PrependAppend_x64 = 47, 98 | CFG_ProcInject_Execute = 51, 99 | CFG_ProcInject_AllocationMethod = 52, 100 | CFG_ProcInject_Stub = 53, 101 | CFG_bUsesCookies = 50, 102 | CFG_HostHeader = 54, 103 | ) 104 | 105 | ID_TO_OPT = {value: k for k, value in OPT_TO_ID.items()} 106 | 107 | BEACON_TYPE = {0x0: "HTTP", 0x1: "Hybrid HTTP DNS", 108 | 0x2: "SMB", 0x4: "TCP", 0x8: "HTTPS", 0x10: "Bind TCP"} 109 | ALLOCA_TYPE = {0: "VirtualAllocEx", 1: "NtMapViewOfSection"} 110 | EXECUTE_TYPE = {0x1: "CreateThread", 0x2: "SetThreadContext", 111 | 0x3: "CreateRemoteThread", 0x4: "RtlCreateUserThread", 112 | 0x5: "NtQueueApcThread", 0x6: None, 0x7: None, 0x8: "NtQueueApcThread-s"} 113 | 114 | 115 | 116 | 117 | 118 | xor_strings = lambda a,b: bytes(list(x^y for x,y in zip(a,b))) 119 | 120 | def xor_chain_key_gen(key, data): 121 | key_size = len(key) 122 | for chunk in _chunks_generator(data, key_size): 123 | xored = xor_strings(key, chunk) 124 | key = chunk 125 | yield xored 126 | 127 | def xor_chain_key(data, key0=None): 128 | if key0 is None: 129 | key0 = xor_strings(data[:4], bytes.fromhex('4D 5A E8 00')) 130 | # for config extraction purposes, it is irrelevant. 131 | return b''.join(xor_chain_key_gen(key0, data)) 132 | 133 | 134 | def decrypt_xor_chain_filename(filename): 135 | blob = open(filename,'rb').read() 136 | open(f"{filename}_xored","wb").write(xor_chain_key(blob)) 137 | -------------------------------------------------------------------------------- /cobalt_config_tool.py: -------------------------------------------------------------------------------- 1 | """ 2 | CobaltStrike config extractor && parser. 3 | 4 | Some stuff copied from https://github.com/Sentinel-One/CobaltStrikeParser.git 5 | """ 6 | 7 | import argparse 8 | import pprint 9 | import sys 10 | from enum import Enum 11 | #from Crypto.Cipher import XOR 12 | from Crypto.Hash import MD5 13 | 14 | try: 15 | import json 16 | except ImportError: 17 | json = None 18 | 19 | try: 20 | import yaml 21 | except ImportError: 22 | yaml = None 23 | 24 | try: 25 | from loguru import logger 26 | except ImportError: 27 | import logging as logger 28 | logger.remove() 29 | logger.add(sys.stderr, level="DEBUG" ) # if args.verbose else "WARNING") 30 | 31 | 32 | 33 | 34 | from bytes_utils import BinStream, AlmostLikeYara, NOT_FOUND, SIZE_DWORD, BinaryData, MinidumpData 35 | #, netbios_decode, netbios_encode 36 | import cobalt_commons as CobaltCommons 37 | 38 | 39 | HTTP_NEWLINE = "\r\n" 40 | 41 | _UNKNOWN = "!UNKNOW!" 42 | 43 | class FileFormat(Enum): 44 | """ supported file types """ 45 | BINARY = 'bin' 46 | MINIDUMP = 'minidump' 47 | 48 | class SearchMode(Enum): 49 | """ config search modes """ 50 | PACKED='p' 51 | UNPACKED='u' 52 | ALL='a' 53 | 54 | def _bytes_strip(bstr): 55 | return bstr.strip(b'\x00').decode() 56 | 57 | def _as_c_string(data): 58 | val = data[:data.find(b"\x00")] 59 | if len(val) == 0: 60 | return '' 61 | return val.decode() 62 | 63 | 64 | class XOR: 65 | def new(key): 66 | return XOR_cipher(key) 67 | class XOR_cipher: 68 | def __init__(self, key:bytes): 69 | self.key = key 70 | self.key_len = len(self.key) 71 | 72 | def encrypt(self, data:bytes): 73 | key = self.key 74 | data_len = len(data) 75 | if data_len > self.key_len: 76 | ratio = int(data_len / self.key_len) 77 | key += key * ratio 78 | 79 | return bytes([a^b for a,b in zip(key, data)]) 80 | 81 | 82 | 83 | class ConfigEntry(): 84 | """ 85 | Store single config entry 86 | """ 87 | def __init__(self, idx, kind, size, data): 88 | self.idx = idx 89 | self.hex_id = f"0x{idx:02X}" 90 | self.name = CobaltCommons.ID_TO_OPT.get(idx, _UNKNOWN) 91 | self.kind = kind 92 | self.size = size 93 | self.data = data 94 | self.parsed = None 95 | 96 | def _to_json(self): 97 | """ used by JSON converter """ 98 | return self.__dict__ 99 | 100 | def short_str(self): 101 | """ single line representation of record """ 102 | return f"[ ID:{self.idx}/{self.hex_id} type:{self.kind} size:{self.size:<4} name:{self.name} ]" 103 | 104 | def __str__(self): 105 | """ return printable representation """ 106 | def _try_to_str(bstr): 107 | try: 108 | return _bytes_strip(bstr) 109 | except UnicodeDecodeError: 110 | return str(bstr) 111 | retval = [ self.short_str() ] 112 | if self.parsed is not None: 113 | retval.append(" " + pprint.pformat(self.parsed, indent=2)) 114 | else: 115 | if self.kind == CobaltCommons.OPT_TYPE_BINARY: 116 | retval.append(" " + _try_to_str(self.data)) 117 | else: 118 | retval.append(" " + str(self.data)) 119 | retval.append('') 120 | return '\n'.join(retval) 121 | 122 | 123 | class CobaltConfigParser(): 124 | """ 125 | Parse & store config 126 | """ 127 | 128 | def __init__(self, data_provider, mode): 129 | self.data_provider = data_provider 130 | self.mode = mode 131 | self.records = [] 132 | self.records_by_id = dict() 133 | 134 | def safe_get_opt(self, idx=None, opt=None, default=None): 135 | """ get record by id, softfail """ 136 | if idx is None: 137 | idx = CobaltCommons.OPT_TO_ID[opt] 138 | return self.records_by_id.get(idx, default) 139 | 140 | def parse_0x01(self, rec): 141 | """ beacon type """ 142 | return "[0x{0:04X}] {1}".format(rec.data, CobaltCommons.BEACON_TYPE.get(rec.data, _UNKNOWN)) 143 | 144 | def parse_0x07(self, rec): 145 | return dict(hash=MD5.new(rec.data).hexdigest()) 146 | 147 | def parse_0x0B(self, rec): 148 | """ junk """ 149 | stream = BinStream(rec.data) 150 | retval = [] 151 | cmd = 'data' 152 | min_bytes = 0 153 | while stream.available()>1: 154 | oper = stream.read_n_dword() 155 | descr = f" [{oper:08X}] " 156 | if not oper: 157 | break 158 | if oper == 1: 159 | val = stream.read_n_dword() 160 | descr += f"Remove {val} bytes from the end" 161 | cmd = f"{cmd}[:-{val}]" 162 | min_bytes += val 163 | elif oper == 2: 164 | val = stream.read_n_dword() 165 | descr += f"Remove {val} bytes from the beginning" 166 | cmd = f"{cmd}[{val}:]" 167 | min_bytes += val 168 | elif oper == 3: 169 | descr += "Base64 decode" 170 | cmd = f"b64decode({cmd})" 171 | elif oper == 4: 172 | descr += "NOPE" 173 | elif oper == 8: 174 | descr += "decode nibbles 'a'" 175 | cmd = f"netbios_decode({cmd},ord('a'))" 176 | elif oper == 11: 177 | descr += "decode nibbles 'A'" 178 | cmd = f"netbios_decode({cmd},ord('A'))" 179 | elif oper == 13: 180 | descr += "Base64 URL-safe decode" 181 | cmd = f"b64decode_urlsafe({cmd})" 182 | elif oper == 15: 183 | descr += "XOR mask w/ random key" 184 | cmd = f"dexor({cmd})" 185 | retval.append(descr) 186 | return dict(algo=retval, code=cmd, minimal_size=min_bytes) 187 | 188 | def parse_0x2E(self, rec): 189 | """ code prefix/sufix """ 190 | data = BinStream(rec.data) 191 | size = data.read_n_dword() 192 | prep_val = data.read_n(size) 193 | size = data.read_n_dword() 194 | appe_val = data.read_n(size) 195 | return dict(prepend = prep_val.hex(), append = appe_val.hex()) 196 | 197 | parse_0x2F = parse_0x2E 198 | 199 | def parse_0x0C(self, rec): 200 | """ HTTP metadata """ 201 | stream = BinStream(rec.data) 202 | opts = dict( 203 | headers = '', 204 | path = '', 205 | body = '', 206 | ) 207 | code = [] 208 | tmp_buf = "" 209 | 210 | _path_not_empty = lambda : len(opts['path'])>1 211 | _read_len_value = lambda x: x.read_n(x.read_n_dword()).decode() 212 | host_entry = self.safe_get_opt(opt='CFG_HostHeader') 213 | host_str = '' 214 | if host_entry is not None: 215 | host_str = _as_c_string(host_entry.data) 216 | #print(host_str) 217 | 218 | while stream.available()>4: 219 | #print(" BUFFERS ", tmp1," # ",tmp_buf) 220 | oper = stream.read_n_dword() 221 | op_str = f"[{oper:02X}] " 222 | #print(" OP:",op, d.data[s.tell():][:20] ) 223 | if oper == 0: 224 | if len(host_str)>1: 225 | opts['headers'] += host_str + HTTP_NEWLINE 226 | break 227 | elif oper == 1: 228 | value = _read_len_value(stream) 229 | tmp_buf = tmp_buf + value 230 | op_str += f"append {value} to tmp_buf" 231 | elif oper == 2: 232 | value = _read_len_value(stream) 233 | tmp_buf = value + tmp_buf 234 | op_str += f"prepend {value} to tmp_buf" 235 | elif oper == 3: 236 | tmp_buf = f"BASE64({tmp_buf})" 237 | op_str += " BASE64(tmp_buf)" 238 | elif oper == 4: 239 | opts['body'] = tmp_buf 240 | op_str += f"set BODY to tmp_buf: {tmp_buf}" 241 | elif oper == 5: 242 | value = _read_len_value(stream) 243 | if _path_not_empty(): 244 | opts['path'] = f"{opts['path']}&{value}={tmp_buf}" 245 | else: 246 | opts['path'] = f"?{value}={tmp_buf}" 247 | op_str += f"add GET PARAM from tmp_buf : {value}={tmp_buf}" 248 | elif oper == 6: 249 | value = _read_len_value(stream) 250 | hdr = f"{value}: {tmp_buf}" 251 | opts['headers'] = opts['headers'] + hdr + HTTP_NEWLINE 252 | op_str += f"add header from TMPtmp_buf2 => {value} : {tmp_buf}" 253 | elif oper == 7: 254 | value = stream.read_n_dword() 255 | tmp_buf = f"" 256 | op_str += f"load {tmp_buf} to tmp_buf" 257 | elif oper == 8: 258 | tmp_buf = f"" 259 | op_str += "lower-case encode tmp_buf" 260 | elif oper == 9: 261 | value = _read_len_value(stream) 262 | if _path_not_empty(): 263 | opts['path']= f"{opts['path']}&{value}" 264 | else: 265 | opts['path'] = f"?{value}" 266 | op_str += f"add GET PARAM: {value}" 267 | elif oper == 10: 268 | value = _read_len_value(stream) 269 | opts['headers'] = opts['headers'] + value + HTTP_NEWLINE 270 | op_str += f"ADD HEADER: {value}" 271 | elif oper == 11: 272 | tmp_buf = f"" 273 | op_str += "upper-case encode tmp_buf" 274 | elif oper == 12: 275 | opts['path']= f"{opts['path']}{tmp_buf}" 276 | op_str += "append local_buf to http_path" 277 | elif oper == 13: 278 | tmp_buf = f"" 279 | op_str += " BASE64_URLSAFE(tmp_buf)" 280 | elif oper == 15: 281 | tmp_buf = f"" 282 | op_str = "xor_random4b_key(tmp_buf)" 283 | elif oper == 16: 284 | val = _read_len_value(stream) 285 | if len(host_str) > 1: 286 | opts['headers'] += host_str + HTTP_NEWLINE 287 | else : 288 | opts['headers'] += val + HTTP_NEWLINE 289 | op_str += " add HOST header or {val}" 290 | else: 291 | op_str += " <-- implement me" 292 | #print(" COMMAND : ",op_str) 293 | code.append(op_str) 294 | #import yaml 295 | #print(yaml.dump(opts)) 296 | #print(yaml.dump(code)) 297 | opts['_code'] = code 298 | return opts 299 | 300 | 301 | 302 | parse_0x0D = parse_0x0C 303 | 304 | def parse_0x28(self, rec): 305 | """ Kill data """ 306 | return "No kill date set!" if rec.data==0 else rec.data 307 | 308 | _rec_as_c_string = lambda self, rec: _as_c_string(rec.data) 309 | parse_0x08 = _rec_as_c_string 310 | parse_0x09 = _rec_as_c_string 311 | parse_0x0A = _rec_as_c_string 312 | parse_0x0F = _rec_as_c_string 313 | parse_0x1A = _rec_as_c_string 314 | parse_0x1B = _rec_as_c_string 315 | parse_0x1D = _rec_as_c_string 316 | parse_0x1E = _rec_as_c_string 317 | 318 | def parse_0x33(self, rec): 319 | """ payload execution """ 320 | data = BinStream(rec.data) 321 | retval = [] 322 | while data.available()>1: 323 | oper = data.read_byte() 324 | cmd = f"[{oper:02X}] " 325 | if oper == 0: 326 | break 327 | # 1, 2, 3, 4, 5 ,8 = from dict 328 | if oper in (6,7): 329 | offset = data.read_n_word() 330 | len1 = data.read_n_dword() 331 | val1 = _bytes_strip(data.read_n(len1)) 332 | len2 = data.read_n_dword() 333 | val2 = _bytes_strip(data.read_n(len2)) 334 | func = "CreateThread" if oper == 6 else "CreateRemoteThread" 335 | cmd += f"{func}({val1}::{val2} + {offset})" 336 | else: 337 | name = CobaltCommons.EXECUTE_TYPE.get(oper, None) 338 | cmd += str(name) 339 | retval.append(cmd) 340 | return retval 341 | 342 | def parse_0x34(self, rec): 343 | """ allocation method """ 344 | return CobaltCommons.ALLOCA_TYPE.get(rec.data, _UNKNOWN) 345 | 346 | 347 | 348 | def _read_single_packed_record(self, source): 349 | """ parse single record """ 350 | idx = source.read_n_word() 351 | if idx == 0: 352 | return None 353 | kind = source.read_n_word() 354 | size = source.read_n_word() 355 | val = None 356 | if kind == CobaltCommons.OPT_TYPE_WORD: 357 | val = source.read_n_word() 358 | elif kind == CobaltCommons.OPT_TYPE_DWORD: 359 | val = source.read_n_dword() 360 | elif kind == CobaltCommons.OPT_TYPE_BINARY: 361 | val = source.read_n(size) 362 | else: 363 | raise Exception(f"UKNOWN RECORD id:{idx} type:{kind} !") 364 | 365 | return ConfigEntry(idx, kind, size, val) 366 | 367 | def _add_record(self, rec): 368 | self.records.append(rec) 369 | self.records_by_id[rec.idx] = rec 370 | 371 | def _parse_packed(self, verbose=False): 372 | source = BinStream( 373 | self.data_provider.read( 374 | self.data_provider.found_at, CobaltCommons.MAX_SIZE 375 | ) 376 | ) 377 | while source.available() > 6: 378 | rec = self._read_single_packed_record(source) 379 | if rec is None: 380 | break 381 | self._add_record(rec) 382 | if verbose: 383 | print(" PARSED " + rec.short_str()) 384 | print() 385 | 386 | def _parse_unpacked(self, verbose=False): 387 | data = self.data_provider.read( 388 | addr = self.data_provider.found_at, 389 | size = (SIZE_DWORD * 2 ) * (CobaltCommons.MAX_ID+2) 390 | ) 391 | #print(data) 392 | source = BinStream(data) 393 | self.records = [] 394 | source.read_n(2 * SIZE_DWORD) # 2x null 395 | for i in range(1,CobaltCommons.MAX_ID): 396 | kind = source.read_h_dword() 397 | value = source.read_h_dword() 398 | if kind == CobaltCommons.OPT_TYPE_BINARY: 399 | if verbose: 400 | print("Try to read ptr") 401 | blob = self.data_provider.read(value, CobaltCommons.MAX_REC_SIZE) 402 | value = blob 403 | rec = ConfigEntry(i, kind, 0, value) 404 | #print(rec) 405 | self._add_record(rec) 406 | 407 | def parse(self, verbose=False): 408 | """ parse binary data """ 409 | #verbose = 1 410 | self.records = [] 411 | self.records_by_id = dict() 412 | if self.mode == SearchMode.PACKED: 413 | self._parse_packed(verbose) 414 | if self.mode == SearchMode.UNPACKED: 415 | self._parse_unpacked(verbose) 416 | 417 | for rec in self.records: 418 | if verbose: 419 | print(" ENRICH : " + rec.short_str()) 420 | func = getattr(self, "parse_{0}".format(rec.hex_id), lambda x:None) 421 | if func and callable(func): 422 | rec.parsed = func(rec) 423 | if verbose: 424 | print(" VALUE :" + repr(rec.parsed)) 425 | #print(rec) 426 | 427 | 428 | 429 | 430 | def try_to_find_config(data_provider, mode=SearchMode.ALL, hint_key=None, ): 431 | """ try all the XOR magic to find data looking like config """ 432 | 433 | if mode in (SearchMode.PACKED, SearchMode.ALL): 434 | logger.debug("MODE == PACKED") 435 | keys_to_test = range(0xff) if hint_key is None else [hint_key] 436 | for cur_key in keys_to_test: 437 | logger.debug(f"TESTING KEY = {cur_key}") 438 | _xor_array_fn = lambda arr, key=cur_key: XOR.new(bytes([key])).encrypt(arr) 439 | finder = AlmostLikeYara(CobaltCommons.PACKED_CONFIG_PATTERN, encoder=_xor_array_fn) 440 | result = data_provider.find_using_func(finder.smart_search) 441 | if result != NOT_FOUND: 442 | data_provider.set_encoder(_xor_array_fn) 443 | logger.debug(f"Found config @ 0x{result:08X} , key={cur_key}") 444 | return data_provider, SearchMode.PACKED 445 | 446 | if mode in (SearchMode.UNPACKED, SearchMode.ALL): 447 | logger.debug("MOD == UNPACKED") 448 | finder = AlmostLikeYara(CobaltCommons.UNPACKED_CONFIG_PATTERN) 449 | result = data_provider.find_using_func(finder.smart_search) 450 | if result != NOT_FOUND: 451 | logger.debug(f"Found config @ {result}") 452 | return data_provider, SearchMode.UNPACKED 453 | 454 | return None 455 | 456 | 457 | 458 | # ----------------------- 459 | # - PRINTING FUNCTIONS - 460 | # -------------------- 461 | 462 | FORMATTERS={} 463 | def register_format(name, info): 464 | """ decorator to register new output format """ 465 | def _wrap1(func): 466 | FORMATTERS[name] = dict(func=func, info=info) 467 | return func 468 | return _wrap1 469 | 470 | 471 | @register_format("json","JSON output") 472 | def _to_json(config): 473 | 474 | class JSONEncoder(json.JSONEncoder): 475 | """ hack to parse object """ 476 | def default(self, o): 477 | attr = getattr(o, '_to_json', None) 478 | if attr is not None: 479 | return attr() 480 | return str(o) 481 | 482 | if json is not None: 483 | print(json.dumps(config.records, cls=JSONEncoder)) 484 | else: 485 | print("Install JSON fiest ... ") 486 | 487 | @register_format("yaml","YAML output") 488 | def _to_yaml(config): 489 | if yaml is not None: 490 | print(yaml.dump(config.records, width=1000, default_flow_style=False )) 491 | else: 492 | print("Install YAML first ... ") 493 | 494 | def proxy_http_params(func): 495 | """ call http_prepare w/ proper callback """ 496 | def _proxy_func(conf): 497 | _http_prepare_params(conf, func) 498 | return _proxy_func 499 | 500 | @register_format("http","Prepare HTTP request body (for burp, etc) ") 501 | @proxy_http_params 502 | def _gen_http_request(scheme, c2_addr, verb, metadata, base_path, agent): 503 | print(f" ---- REQUEST {scheme} ---- ") 504 | out = [] 505 | out.append(f"{verb} {base_path}{metadata['path']} HTTP/1.1") 506 | if "Host" not in metadata['headers']: 507 | out.append(f"Host: {c2_addr}") 508 | for hdr in metadata['headers'].split(HTTP_NEWLINE): 509 | if len(hdr)>1: 510 | out.append(f"{hdr}") 511 | out.append(f"User-Agent: {agent}") 512 | 513 | if len(metadata['body'])>1: 514 | out.append("Content-length: ") 515 | out.append('') 516 | out.append(f"{metadata['body']}") 517 | print("\n".join(out)) 518 | print("") 519 | print(" ------ / -------- ") 520 | 521 | @register_format("curl","Craft CURL requests to c2") 522 | @proxy_http_params 523 | def _gen_curl(scheme, c2_addr, verb, metadata, base_path, agent): 524 | curl_opts = ['curl', '-v -k -g'] 525 | curl_opts.append(f"'{scheme}://{c2_addr}{base_path}{metadata['path']}'") 526 | curl_opts.append(f" -X {verb}") 527 | curl_opts.append(f" -A \"{agent}\" ") 528 | for hdr in metadata['headers'].split(HTTP_NEWLINE): 529 | if len(hdr)>1: 530 | curl_opts.append(f" -H '{hdr}'") 531 | if len(metadata['body'])>1: 532 | curl_opts.append(f" -d '{metadata['body']} '") 533 | print("") 534 | print(" ".join(curl_opts)) 535 | print("") 536 | 537 | @register_format('request','Try to make HTTP request to c2') 538 | @proxy_http_params 539 | def _gen_reqest(*_): 540 | print("Work in progress :-)") 541 | 542 | 543 | def _http_prepare_params(conf, calback): 544 | c2_type = conf.safe_get_opt(opt='CFG_BeaconType') 545 | if c2_type.data not in [0,8]: 546 | return print("INVALID C2 BEACON TYPE" + str(c2_type)) 547 | 548 | req_params = {} 549 | req_params['scheme'] = "https" if c2_type.data == 8 else 'http' 550 | c2_addr, get_path = conf.safe_get_opt(opt='CFG_C2Server').parsed.split(",",1) 551 | req_params['c2_addr'] = c2_addr 552 | req_params['agent'] = conf.safe_get_opt(opt='CFG_UserAgent').parsed 553 | 554 | req_params['metadata'] = conf.safe_get_opt(opt='CFG_HttpGet_Metadata').parsed 555 | req_params['verb'] = conf.safe_get_opt(opt='CFG_HttpGet_Verb').parsed 556 | req_params['base_path'] = get_path 557 | calback(**req_params) 558 | 559 | req_params['metadata'] = conf.safe_get_opt(opt='CFG_HttpPost_Metadata').parsed 560 | req_params['verb'] = conf.safe_get_opt(opt='CFG_HttpPost_Verb').parsed 561 | req_params['base_path'] = conf.safe_get_opt(opt='CFG_HttpPostUri').parsed 562 | calback(**req_params) 563 | return None 564 | 565 | 566 | 567 | @register_format("text","Plain text output") 568 | def _to_text(config): 569 | for rec in sorted(config.records, key=lambda rec: rec.idx): 570 | print(rec) 571 | 572 | DEFAULT_OUTPUT = 'none' 573 | @register_format("none","Print nothing. just parse") 574 | def _no_print(_): 575 | pass 576 | 577 | # ----------------------- 578 | # - MAIN -------------- 579 | # -------------------- 580 | 581 | 582 | def main(): 583 | """ main function """ 584 | parser = argparse.ArgumentParser( 585 | description=""" 586 | +--- - - ---------+ 587 | | CobaltStrike Beacon config tool | 588 | +------- - ----+ 589 | """, 590 | epilog = "Available output formats: \n" + "\n".join( 591 | f"- {key:5} : {val['info']}" for key,val in FORMATTERS.items() 592 | ), 593 | formatter_class=argparse.RawDescriptionHelpFormatter 594 | ) 595 | parser.add_argument( 596 | "file_path", 597 | help="Path to file (config, dump, pe, etc)" 598 | ) 599 | ## TODO: add ability to attach to process and just RIP the memory :-) 600 | parser.add_argument( 601 | "--ftype", 602 | help="Input file type. Default=raw", 603 | choices=[x.value for x in FileFormat], 604 | #type=FileFormat, 605 | default=FileFormat.BINARY, 606 | ) 607 | parser.add_argument( 608 | "--key", 609 | help="Hex encoded, 1 byte xor key to use when doing xor-search", 610 | default=None 611 | ) 612 | parser.add_argument( 613 | '--mode', 614 | help='Search for [p]acked or [u]npacked or try [a]ll config. Default=[a]ll', 615 | choices=[x.value for x in SearchMode], 616 | default=SearchMode.ALL, 617 | ) 618 | parser.add_argument( 619 | '--format', 620 | help="Output format", 621 | choices=FORMATTERS.keys(), 622 | default=DEFAULT_OUTPUT, 623 | required=True 624 | ) 625 | parser.add_argument( 626 | '--decrypt', 627 | help="Try to decrypt input file w/ 4b xor", 628 | action='store_true', 629 | default=False, 630 | ) 631 | parser.add_argument( 632 | '--verbose', 633 | help = "Verbose mode. Messages goes to STDERR", 634 | action='store_true', 635 | default=False 636 | ) 637 | 638 | args = parser.parse_args() 639 | 640 | args.mode = SearchMode(args.mode) 641 | args.ftype = FileFormat(args.ftype) 642 | 643 | data_provider = None 644 | if args.ftype == FileFormat.BINARY: 645 | data_provider = BinaryData(args.file_path) 646 | if args.ftype == FileFormat.MINIDUMP: 647 | data_provider = MinidumpData(args.file_path) 648 | 649 | if args.decrypt: 650 | if args.ftype != FileFormat.BINARY: 651 | raise Exception("Can only do guessed decryption on raw binary file") 652 | data_provider.replace_data(CobaltCommons.xor_chain_key(data_provider.data)) 653 | 654 | hint_key = None 655 | if args.key: 656 | if "0x" in args.key: 657 | hint_key = int(args.key, 16) 658 | else: 659 | hint_key = int(args.key) 660 | 661 | config_found = try_to_find_config( 662 | data_provider = data_provider, 663 | hint_key = hint_key, 664 | mode = args.mode, 665 | ) 666 | 667 | if config_found is None: 668 | print("FAIL TO FIND CONFIG !") 669 | return 670 | file_obj, mode = config_found 671 | config = CobaltConfigParser(file_obj, mode=mode) 672 | config.parse() 673 | 674 | if args.format is not None: 675 | entry = FORMATTERS.get(args.format, None) 676 | if entry is not None: 677 | entry['func'](config) 678 | 679 | if __name__ == '__main__': 680 | main() 681 | # Next line empty 682 | -------------------------------------------------------------------------------- /find_mem_config.yara: -------------------------------------------------------------------------------- 1 | 2 | rule CobaltStrike_inMemory_config 3 | { 4 | strings: 5 | $hex_string = { 6 | 00 00 00 00 00 00 00 00 7 | 01 00 00 00 (00|01|02|04|08|0A) 00 00 00 8 | 01 00 00 00 ?? ?? 00 00 9 | 02 00 00 00 ?? ?? ?? ?? 10 | 02 00 00 00 ?? ?? ?? ?? 11 | 01 00 00 00 ?? ?? 00 00 12 | 01 00 00 00 13 | } 14 | condition: 15 | $hex_string 16 | } 17 | 18 | 19 | -------------------------------------------------------------------------------- /import_fixer.py: -------------------------------------------------------------------------------- 1 | import pefile 2 | import sys 3 | import struct 4 | import Crypto.Cipher.XOR as XOR 5 | 6 | if len(sys.argv) != 2: 7 | raise Exception('Usae: code.py [path_to_pe_file]') 8 | FILENAME = sys.argv[1] 9 | OUTFILE = f'{FILENAME}_fix.bin' 10 | binary_data = open(FILENAME, 'rb').read() 11 | 12 | 13 | def _unpack_ex(fmt, data=None, read_cb=None, into=None): 14 | if data is None and read_cb is None: 15 | raise Exception("DATA or readCB !") 16 | if data is None: 17 | size = struct.calcsize(fmt) 18 | data = read_cb(size) 19 | res = struct.unpack(fmt, data) 20 | if into is None: 21 | if len(res) == 1: 22 | return res[0] 23 | else: 24 | return res 25 | else: 26 | return dict(zip(info,res)) 27 | 28 | 29 | 30 | class BinStream(object): 31 | def __init__(self, d): 32 | self.data = bytearray(d) 33 | self.ptr = 0 34 | 35 | def read_n(self,n, move=True): 36 | tmp = self.data[self.ptr : self.ptr+n] 37 | if move: 38 | self.ptr += n 39 | return tmp 40 | 41 | def read_fmt(self, fmt, into=None): 42 | size = struct.calcsize(fmt) 43 | data = self.read_n(size) 44 | return _unpack_ex(fmt, data, into) 45 | 46 | 47 | def available(self): 48 | return len(self.data) - self.ptr 49 | 50 | def at_patch(self, where, what): 51 | i = 0 52 | #print(what) 53 | #print(self.data[where:where+20]) 54 | for b in what: 55 | self.data[where + i] = b 56 | i+=1 57 | #print(self.data[where:where+20]) 58 | 59 | def at_read_n(self, at, n): 60 | return bytes(self.data[at:at+n]) 61 | 62 | def at_read_fmt(self, fmt, into=None): 63 | return _unpack_ex(fmt, read_cb=self.at_read_n, into=into) 64 | 65 | def save(self, fn): 66 | open(fn,'wb').write(self.data) 67 | 68 | 69 | def byte_till(bts, stop, include_stop=False): 70 | i=0 71 | while bts[i] != stop: 72 | i+=1 73 | if include_stop: 74 | return bts[:i+1] 75 | else: 76 | return bts[:i] 77 | 78 | 79 | pe = pefile.PE(FILENAME) 80 | stream = BinStream(binary_data) 81 | 82 | key = 0x00FF & pe.FILE_HEADER.NumberOfSymbols 83 | print(f"Xor key : {key} / 0x{key:04X}") 84 | 85 | algo = XOR.new(key=bytes([key])) 86 | 87 | print("## ## FIXING SECTION NAMES ## ##") 88 | for section in pe.sections: 89 | print(f' SECTION : {section.Name} @ {section.get_file_offset()}') 90 | data_ptr = section.get_file_offset() 91 | enc_name = stream.at_read_n(data_ptr, 8) 92 | dec_name = algo.decrypt(enc_name) 93 | print(f' - > Decrypted name : {dec_name}') 94 | stream.at_patch(data_ptr, dec_name) 95 | stream.save(OUTFILE) 96 | print() 97 | 98 | print("## ## FIXING IMPORTS ## ##") 99 | 100 | rva_of_import_table = pe.OPTIONAL_HEADER.DATA_DIRECTORY[1].VirtualAddress 101 | 102 | offset = rva_of_import_table 103 | print(f' >> Import Table start @ {offset:08X} .. ') 104 | 105 | while True: 106 | if pe.get_data(offset, 2)[0]== 0: 107 | break 108 | rva_of_dllname = pe.get_dword_at_rva(offset + 12) 109 | off_of_dllname = pe.get_offset_from_rva(rva_of_dllname) 110 | print(f" + DLL NAME @ 0x{rva_of_dllname:08X} ~~> {off_of_dllname:08X}") 111 | 112 | enc_dll_name = stream.at_read_n(off_of_dllname, 30) 113 | dec_dll_name = byte_till(algo.decrypt(enc_dll_name), 0, include_stop=True) 114 | stream.at_patch(off_of_dllname, dec_dll_name) 115 | print(f" DLL NAME: {dec_dll_name} ") 116 | 117 | rva_of_names = pe.get_dword_at_rva(offset + 0) 118 | #off_of_name = pe.get_offset_from_rva(rva_of_names) 119 | print(f" + NAMES @ 0x{rva_of_names:08X} ") #~~> {off_of_name:08X}") 120 | for entry in pe.get_import_table(rva_of_names): 121 | hx = hex(entry.AddressOfData)[:4] 122 | if hx[:4] == "0x80": 123 | print(f" >> SKIP ! {hex(entry.AddressOfData)}") 124 | #print(entry) 125 | continue 126 | off = pe.get_offset_from_rva(entry.AddressOfData) 127 | enc = stream.at_read_n(off, 100) 128 | dec = algo.decrypt(enc) 129 | name = byte_till(dec[2:], 0, include_stop=1) 130 | entry_len = 2 + len(name) 131 | stream.at_patch(off, dec[:entry_len]) 132 | print(f" FIXED: {name}") 133 | offset += 0x14 134 | stream.save(OUTFILE) 135 | --------------------------------------------------------------------------------