Skip to content

Commit 29073d4

Browse files
Introducing CENTS reporting module (#605)
* initial CENTS setup * add reporting module modules/reporting/cents.py -> checks if we have an extracted malware config -> checks if we have a parser for the config -> creates Suricata rules -> writes ruleset to cents.rules * add reporting module to config conf/reporting.conf * make cents.rules ruleset available for download in UI * various update after our meeting * add md5 of sample to rule (we can add a lot more info form the run) * make start sid a config item * move rule creation functions for each family in its own file and import it * add basic cobalt strike bacon rule function * add trickbot and squirrelwaffle * add azorult * deduplicate config dict list and return early if no rules have been created * impliment remcos rules for CENTS - force DC3-MWCP parser to include the password * add squirrelwaffle signatures * add date of the analysis run of the sample to rules * add link to analysis to rule reference * only display cents download button if we have rules * fix typo * fix path * reformat date to be align with ET format * trim incomplete malfamiles * move hostname to web.conf and cosmetic changes * fix issuer --> issuerdn * complete trickbot - remove azorult and cobaltstrike Co-authored-by: klingerko <[email protected]> Co-authored-by: Konstantin Klinger <[email protected]>
1 parent 751676c commit 29073d4

File tree

14 files changed

+500
-18
lines changed

14 files changed

+500
-18
lines changed

conf/reporting.conf

+5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
# You can also add additional options under the section of your module and
55
# they will be available in your Python class.
66

7+
[cents]
8+
enabled = yes
9+
# starting signature id for created Suricata rules
10+
start_sid = 1000000
11+
712
[mitre]
813
enabled = no
914

conf/web.conf

+3
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ search_limit = 50
3333
# Allow anon users to browser site but not submit/download
3434
anon_viewable = no
3535
existent_tasks = yes
36+
# hostname of the cape instance
37+
hostname = https://127.0.0.1/
38+
;hostname = https://www.capesandbox.com/
3639

3740
# ratelimit for anon users
3841
[ratelimit]

lib/cuckoo/common/cents/__init__.py

Whitespace-only changes.
+191
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import logging
2+
from Crypto.Cipher import ARC4
3+
from ipaddress import ip_address
4+
5+
log = logging.getLogger(__name__)
6+
7+
8+
def _chunk_stuff(stuff, group_size=20):
9+
# really just need to chunk out the ip into groups of....20?
10+
# https://stackoverflow.com/questions/312443/how-do-you-split-a-list-into-evenly-sized-chunks
11+
for i in range(0, len(stuff), group_size):
12+
yield ','.join(stuff[i:i + group_size])
13+
14+
15+
def _build_rc4_rule(passphrase):
16+
hex_plain_text = "5b4461746153746172745d00000000"
17+
18+
cipher = ARC4.new(passphrase)
19+
value = bytes.fromhex(hex_plain_text)
20+
enc_value = cipher.encrypt(value)
21+
22+
# conver the encrypted form if the plain text to a hex string
23+
enc_hex_value = enc_value.hex()
24+
25+
first_value = ""
26+
second_value = ""
27+
# split the first part into two char groups as hex, we need these for the rules
28+
# skip over the last 8 bytes
29+
for i in range(0, len(enc_hex_value) - 8, 2):
30+
first_value += f"{enc_hex_value[i:i + 2]} "
31+
32+
# only take the last 4 bytes
33+
for i in range(len(enc_hex_value) - 4, len(enc_hex_value), 2):
34+
second_value += f"{enc_hex_value[i:i + 2]} "
35+
36+
return first_value.rstrip(), second_value.rstrip()
37+
38+
39+
def _parse_mwcp(remcos_config):
40+
remcos_config_list = []
41+
control = remcos_config.get('control', [])
42+
for c in control:
43+
if c and c.startswith("tcp://"):
44+
# maxsplit here incase the passphrase includes :
45+
tmp = c.replace("tcp://", "").split(":", maxsplit=2)
46+
if tmp:
47+
# if we don't have a password, just add a blank one,
48+
if len(tmp) == 2:
49+
remcos_config_list.append(
50+
{"Version": remcos_config.get("version", ""), "C2": tmp[0], "Port": tmp[1], "Password": ""}
51+
)
52+
elif len(tmp) == 3 and tmp[2] != "":
53+
# we can include the passprhase
54+
remcos_config_list.append(
55+
{"Version": remcos_config.get("version", ""), "C2": tmp[0], "Port": tmp[1], "Password": tmp[2]}
56+
)
57+
else:
58+
log.debug(f"[CENTS - Remcos] MWCP config - found to be invalid --> {c}")
59+
60+
return remcos_config_list
61+
62+
63+
def _parse_ratdecoders(remcos_config):
64+
domains = remcos_config.get("domains", [])
65+
remcos_config_list = []
66+
for domain in domains:
67+
# why is this a list of lists
68+
for nested_domain in domain:
69+
remcos_config_list.append(
70+
# notice the typo here including the colon after c2:
71+
# https://github.com/kevthehermit/RATDecoders/blob/master/malwareconfig/decoders/remcos.py#L56
72+
{"C2": nested_domain.get("c2:", ""),
73+
"Port": nested_domain.get("port", ""),
74+
"Password": nested_domain.get("password", ""),
75+
}
76+
)
77+
78+
return remcos_config_list
79+
80+
81+
def cents_remcos(config_dict, sid_counter, md5, date, task_link):
82+
"""Creates Suricata rules from extracted Remcos malware configuration.
83+
84+
:param config_dict: Dictionary with the extracted Remcos configuration.
85+
:type config_dict: `dict`
86+
87+
:param sid_counter: Signature ID of the next Suricata rule.
88+
:type sid_counter: `int`
89+
90+
:param md5: MD5 hash of the source sample.
91+
:type md5: `int`
92+
93+
:param date: Timestamp of the analysis run of the source sample.
94+
:type date: `str`
95+
96+
:param task_link: Link to analysis task of the source sample.
97+
:type task_link: `str`
98+
99+
:return List of Suricata rules (`str`) or empty list if no rule has been created.
100+
"""
101+
if not config_dict or not sid_counter or not md5 or not date or not task_link:
102+
return []
103+
104+
next_sid = sid_counter
105+
# build out an array to store the parsed configs
106+
remcos_config_list = []
107+
108+
# lowercase the key names in the configs for constancy
109+
remcos_config = dict((k.lower(), v) for k, v in config_dict.items())
110+
111+
if not remcos_config:
112+
return []
113+
114+
# there are two remcos parsers that could be at work here
115+
# 1) RATDecoders - https://github.com/kevthehermit/RATDecoders/blob/master/malwareconfig/decoders/remcos.py
116+
# which is an optional configuration that can be enabled in the processing.conf file
117+
# 2) MWCP - https://github.com/kevoreilly/CAPEv2/blob/master/modules/processing/parsers/mwcp/Remcos.py
118+
# which is an optional configuration that can be enabled in the processing.conf file
119+
if 'control' in remcos_config and 'domains' not in remcos_config:
120+
# we have an MWCP config
121+
log.debug("[CENTS - Remcos] Parsing DC3-MWCP based config")
122+
parsed_remcos_config = _parse_mwcp(remcos_config)
123+
for _config in parsed_remcos_config:
124+
if _config not in remcos_config_list:
125+
remcos_config_list.append(_config)
126+
127+
if 'domains' in remcos_config and 'control' not in remcos_config:
128+
# we have a RATDecoders config
129+
log.debug("[CENTS - Remcos] Parsing RATDecoders based config")
130+
parsed_remcos_config = _parse_ratdecoders(remcos_config)
131+
for _config in parsed_remcos_config:
132+
if _config not in remcos_config_list:
133+
remcos_config_list.append(_config)
134+
135+
# if we don't have a parsed config, drop out
136+
log.debug("[CENTS - Remcos] Done Parsing Config")
137+
if not remcos_config_list:
138+
log.debug("[CENTS - Remcos] No parsed configs found")
139+
return []
140+
141+
# Now we want to create Suricata rules finally
142+
rule_list = []
143+
ip_list = set()
144+
domain_list = set()
145+
for c2_server in list(map(lambda x: x.get('C2'), remcos_config_list)):
146+
try:
147+
c2_ip = ip_address(c2_server)
148+
except ValueError:
149+
domain_list.add(c2_server)
150+
else:
151+
# only create rules for "global" ip addresses
152+
if c2_ip.is_global:
153+
ip_list.add(c2_server)
154+
else:
155+
log.debug("[CENTS - Remcos] Skipping c2 server due to non-routable ip")
156+
157+
log.debug("[CENTS - Remcos] Building IP based rules")
158+
for ip_group in _chunk_stuff(list(ip_list)):
159+
rule = f"alert tcp $HOME_NET any -> {ip_group} any (msg:\"ET CENTS Remcos RAT (C2 IP Address) " \
160+
f"C2 Communication - CAPE sandbox config extraction\"; flow:established,to_server; " \
161+
f"reference:md5,{md5}; reference:url,{task_link}; sid:{next_sid}; rev:1; " \
162+
f"metadata:created_at {date};)"
163+
rule_list.append(rule)
164+
next_sid += 1
165+
166+
log.debug("[CENTS - Remcos] Building Domain based rules")
167+
for c2_domain in domain_list:
168+
rule = f"alert dns $HOME_NET any -> any any (msg:\"ET CENTS Remcos RAT (C2 Domain) " \
169+
f"C2 Communication - CAPE sandbox config extraction\"; flow:established,to_server; " \
170+
f"dns.query; content:\"{c2_domain}\"; " \
171+
f"reference:md5,{md5}; reference:url,{task_link}; sid:{next_sid}; rev:1; " \
172+
f"metadata:created_at {date};)"
173+
rule_list.append(rule)
174+
next_sid += 1
175+
176+
log.debug("[CENTS - Remcos] Building Password based rules")
177+
for parsed_config in remcos_config_list:
178+
# if we have a password, we should create a rule for the RC4 encrypted stuff
179+
if parsed_config.get("Password", ""):
180+
first, second = _build_rc4_rule(parsed_config.get('Password'))
181+
rule = f"alert tcp $HOME_NET any -> $EXTERNAL_NET any (msg:\"ET CENTS Remcos RAT " \
182+
f"(passphrase {parsed_config.get('Password')}) " \
183+
f"C2 Communication - CAPE sandbox config extraction\"; flow:established,to_server; " \
184+
f"content:\"|{first}|\"; startswith; fast_pattern; content:\"|{second}|\"; distance:2; within:2; " \
185+
f"reference:md5,{md5}; reference:url,{task_link}; sid:{next_sid}; rev:1; " \
186+
f"metadata:created_at {date};)"
187+
rule_list.append(rule)
188+
next_sid += 1
189+
190+
log.debug("[CENTS - Remcos] Returning built rules")
191+
return rule_list
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import logging
2+
from urllib.parse import urlparse
3+
4+
log = logging.getLogger(__name__)
5+
6+
7+
def cents_squirrelwaffle(config_dict, sid_counter, md5, date, task_link):
8+
"""Creates Suricata rules from extracted SquirrelWaffle malware configuration.
9+
10+
:param config_dict: Dictionary with the extracted SquirrelWaffle configuration.
11+
:type config_dict: `dict`
12+
13+
:param sid_counter: Signature ID of the next Suricata rule.
14+
:type sid_counter: `int`
15+
16+
:param md5: MD5 hash of the source sample.
17+
:type md5: `int`
18+
19+
:param date: Timestamp of the analysis run of the source sample.
20+
:type date: `str`
21+
22+
:param task_link: Link to analysis task of the source sample.
23+
:type task_link: `str`
24+
25+
:return List of Suricata rules (`str`) or empty list if no rule has been created.
26+
"""
27+
if not config_dict or not sid_counter or not md5 or not date or not task_link:
28+
return []
29+
30+
next_sid = sid_counter
31+
rule_list = []
32+
url_list_main = config_dict.get("URLs", [])
33+
for urls in url_list_main:
34+
# why is this a list of lists
35+
for nested_url in urls:
36+
# urlparse expects the url to be introduced with a // https://docs.python.org/3/library/urllib.parse.html
37+
# Following the syntax specifications in RFC 1808, urlparse recognizes a netloc only if it is properly
38+
# introduced by ‘//’. Otherwise the input is presumed to be a relative URL and thus to start with a path
39+
# component.
40+
if not nested_url.lower().startswith("http://") and not nested_url.lower().startswith("https://"):
41+
nested_url = f"http://{nested_url}"
42+
c2 = urlparse(nested_url)
43+
# we'll make two rules, dns and http
44+
http_rule = f"alert http $HOME_NET any -> $EXTERNAL_NET any (msg:\"ET CENTS SquirrelWaffle CnC " \
45+
f"Activity\"; flow:established,to_server; http.method; content:\"POST\"; http.host; " \
46+
f"content:\"{c2.hostname}\"; fast_pattern; reference:md5,{md5}; reference:url,{task_link}; http.uri; " \
47+
f"content:\"{c2.path}\"; bsize:{len(c2.path)}; sid:{next_sid}; rev:1; " \
48+
f"metadata:created_at {date};)"
49+
50+
rule_list.append(http_rule)
51+
next_sid += 1
52+
53+
dns_rule = f"alert dns $HOME_NET any -> any any (msg:\"ET CENTS SquirrelWaffle CnC Domain in DNS Query\"; " \
54+
f"dns.query; content:\"{c2.hostname}\"; fast_pattern; reference:md5,{md5}; reference:url,{task_link}; " \
55+
f"sid:{next_sid}; rev:1; " \
56+
f"metadata:created_at {date};)"
57+
58+
rule_list.append(dns_rule)
59+
next_sid += 1
60+
61+
return rule_list
+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import logging
2+
3+
log = logging.getLogger(__name__)
4+
5+
def convert_needed_to_hex(input):
6+
# there has to be a better way to do this....
7+
result = ""
8+
for i in range(0, len(input)):
9+
if 0 <= ord(input[i]) <= 127:
10+
result += input[i]
11+
else:
12+
# determine if the last char was also hex encoded
13+
if i > 0 and ord(input[i - 1]) > 127:
14+
# we don't need the "opening" pipe
15+
result += f"{hex(ord(input[i])).replace('0x', '', 1)}"
16+
else:
17+
# if not, then we need the opening pipe
18+
result += f"|{hex(ord(input[i])).replace('0x', '', 1)}"
19+
20+
# if the next one isn't also going to need hex encoded, then close it.
21+
if i > 0 and ord(input[i + 1]) <= 127:
22+
result += "|"
23+
else:
24+
result += " "
25+
return result
26+
27+
def build_serv_dicts(servs):
28+
result = []
29+
# why is this an array of arrays? idk....
30+
for item in servs:
31+
for s in item:
32+
serv, port = s.split(':', 1)
33+
tmp_dict = {'server': serv, 'port': port}
34+
if tmp_dict not in result:
35+
result.append(tmp_dict)
36+
37+
return result
38+
39+
def cents_trickbot(config_dict, suricata_dict, sid_counter, md5, date, task_link):
40+
"""Creates Suricata rules from extracted TrickBot malware configuration.
41+
42+
:param config_dict: Dictionary with the extracted TrickBot configuration.
43+
:type config_dict: `dict`
44+
45+
:param sid_counter: Signature ID of the next Suricata rule.
46+
:type sid_counter: `int`
47+
48+
:param md5: MD5 hash of the source sample.
49+
:type md5: `int`
50+
51+
:param date: Timestamp of the analysis run of the source sample.
52+
:type date: `str`
53+
54+
:param task_link: Link to analysis task of the source sample.
55+
:type task_link: `str`
56+
57+
:return List of Suricata rules (`str`) or empty list if no rule has been created.
58+
"""
59+
log.debug(f"[CENTS] Config for TrickBot Starting")
60+
if not config_dict or not sid_counter or not md5 or not date or not task_link:
61+
log.debug(f"[CENTS] Config did not get enough data to run")
62+
return []
63+
64+
next_sid = sid_counter
65+
rule_list = []
66+
servs = build_serv_dicts(config_dict.get("servs", []))
67+
# create a list of dicts which contain the server and port
68+
gtag = config_dict.get("gtag", "")
69+
ver = config_dict.get("ver", "")
70+
trickbot_c2_certs = []
71+
log.debug(f"[CENTS - TrickBot] Looking for certs from {len(servs)} c2 servers")
72+
for s in servs:
73+
# see if the server and port are also in the tls certs
74+
matching_tls = list(
75+
filter(
76+
lambda x: x['dstip'] == s['server'] and str(x['dstport']) == str(s['port']),
77+
suricata_dict.get('tls', [])
78+
)
79+
)
80+
log.debug(f"[CENTS - TrickBot] Found {len(matching_tls)} certs for {s}")
81+
for tls in matching_tls:
82+
_tmp_obj = {'subject': tls.get('subject', None), 'issuerdn': tls.get('issuerdn', None)}
83+
if _tmp_obj not in trickbot_c2_certs:
84+
trickbot_c2_certs.append(_tmp_obj)
85+
86+
log.debug(f"[CENTS - TrickBot] Building {len(trickbot_c2_certs)} rules based on c2 certs")
87+
for c2_cert in trickbot_c2_certs:
88+
rule = f"alert tls $EXTERNAL_NET any -> $HOME_NET any (msg:\"ET CENTS Observed TrickBot C2 Certificate " \
89+
f"(gtag {gtag[0]}, version {ver[0]})\"; flow:established,to_client; "
90+
if c2_cert.get('subject'):
91+
# if the subject has some non-ascii printable chars, we need to hex encode them
92+
suri_string = convert_needed_to_hex(c2_cert.get('subject'))
93+
rule += f"tls.cert_subject; content:\"{suri_string}\"; "
94+
if c2_cert.get('issuerdn'):
95+
# if the subject has some non-ascii printable chars, we need to hex encode them
96+
suri_string = convert_needed_to_hex(c2_cert.get('issuerdn'))
97+
rule += f"tls.cert_issuer; content:\"{suri_string}\"; "
98+
99+
rule += f"reference:md5,{md5}; reference:url,{task_link}; sid:{next_sid}; rev:1; metadata:created_at {date};)"
100+
next_sid += 1
101+
102+
rule_list.append(rule)
103+
104+
log.debug("[CENTS - TrickBot] Returning built rules")
105+
return rule_list

0 commit comments

Comments
 (0)