Skip to content

Commit 288d717

Browse files
Fix custom YARA rules matching but emitting nothing (#3374)
* fix custom YARA rule emission and rule splitting CustomExtractor.process() called .get() on YaraRuleSettings, which has no get(), so every custom rule match raised AttributeError before the event was emitted. The scan logged one error per match and reported zero findings. Replace the regex-based rule splitter with a brace-depth-aware parser that skips comments, string literals, and regex literals, so braces inside them no longer truncate or drop rules. Rule names now tolerate arbitrary whitespace, import statements are collected and preserved, and unparseable input fails loudly instead of silently dropping a rule. * test: use per-worker HTTPSERVER_URL in custom YARA tests The two custom-YARA module tests hardcoded http://127.0.0.1:8888/ as their target. Under pytest-xdist the httpserver port is offset per worker (worker_port(8888) in bbot/test/worker), so 8888 is only correct on gw0. On any other worker the scan hit the wrong (or no) server, so no HTTP_RESPONSE was produced, the custom rule never matched, and the test failed with 'produced no FINDING, got: []'. TestExcavateYaraCustomEdgeCases passed only because it happened to land on gw0. Point both at HTTPSERVER_URL like every other test in this file. Verified green under -n 2 --dist loadgroup, which reproduced the original failure. --------- Co-authored-by: Shane Engelman <shane.engelman@blacklanternsecurity.com>
1 parent ff171ad commit 288d717

2 files changed

Lines changed: 309 additions & 30 deletions

File tree

bbot/modules/internal/excavate.py

Lines changed: 168 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,156 @@ def extract_params_location(location_header_value, original_parsed_url):
138138
yield "GET", parsed_url, p, p_value, "location_header", _exclude_key(flat_params, p)
139139

140140

141+
_yara_identifier_regex = re.compile(r"[A-Za-z_]\w*")
142+
_yara_rule_modifiers = ("private", "global")
143+
144+
145+
def _skip_yara_noncode(source, i):
146+
"""
147+
If a comment, string literal, or regex literal starts at index i, return the index just past it.
148+
149+
Returns None if index i is ordinary code. YARA uses a backslash for division, so a bare
150+
"/" is unambiguously either a comment or a regex literal.
151+
"""
152+
c = source[i]
153+
if c == "/":
154+
following = source[i + 1 : i + 2]
155+
if following == "/":
156+
newline = source.find("\n", i)
157+
return len(source) if newline == -1 else newline + 1
158+
if following == "*":
159+
end = source.find("*/", i + 2)
160+
if end == -1:
161+
raise ExcavateError("unterminated block comment")
162+
return end + 2
163+
j = i + 1
164+
in_character_class = False
165+
while j < len(source):
166+
char = source[j]
167+
if char == "\\":
168+
j += 2
169+
continue
170+
if char == "\n":
171+
break
172+
if in_character_class:
173+
if char == "]":
174+
in_character_class = False
175+
elif char == "[":
176+
in_character_class = True
177+
elif char == "/":
178+
# consume any trailing regex modifiers
179+
j += 1
180+
while j < len(source) and source[j] in "is":
181+
j += 1
182+
return j
183+
j += 1
184+
raise ExcavateError("unterminated regular expression")
185+
if c == '"':
186+
j = i + 1
187+
while j < len(source):
188+
char = source[j]
189+
if char == "\\":
190+
j += 2
191+
continue
192+
if char == '"':
193+
return j + 1
194+
j += 1
195+
raise ExcavateError("unterminated string literal")
196+
return None
197+
198+
199+
def split_yara_rules(source):
200+
"""
201+
Split YARA source into its individual rules.
202+
203+
Returns (imports, rules), where imports is a list of import statements and rules is a
204+
list of (rule_name, rule_text) tuples in source order.
205+
206+
Rule boundaries are found by brace depth, skipping over comments, string literals, and
207+
regex literals so that a brace inside any of them cannot end a rule early. Hex strings
208+
are brace-balanced, so depth counting covers them for free. Raises ExcavateError on
209+
anything it cannot parse, rather than silently dropping a rule.
210+
"""
211+
imports = []
212+
rules = []
213+
i = 0
214+
length = len(source)
215+
# start offset of any private/global modifiers preceding the "rule" keyword
216+
modifier_start = None
217+
218+
while i < length:
219+
skipped = _skip_yara_noncode(source, i)
220+
if skipped is not None:
221+
i = skipped
222+
continue
223+
if source[i].isspace():
224+
i += 1
225+
continue
226+
227+
keyword_match = _yara_identifier_regex.match(source, i)
228+
if not keyword_match:
229+
raise ExcavateError(f"unexpected character [{source[i]}] outside of any rule")
230+
keyword = keyword_match.group()
231+
232+
if keyword == "include":
233+
raise ExcavateError("include statements are not supported; inline the included rules instead")
234+
if keyword == "import":
235+
quote = source.find('"', keyword_match.end())
236+
if quote == -1 or source[keyword_match.end() : quote].strip():
237+
raise ExcavateError("malformed import statement")
238+
end_of_import = _skip_yara_noncode(source, quote)
239+
imports.append(source[i:end_of_import])
240+
i = end_of_import
241+
modifier_start = None
242+
continue
243+
if keyword in _yara_rule_modifiers:
244+
if modifier_start is None:
245+
modifier_start = i
246+
i = keyword_match.end()
247+
continue
248+
if keyword != "rule":
249+
raise ExcavateError(f"expected 'rule' but found [{keyword}]")
250+
251+
rule_start = i if modifier_start is None else modifier_start
252+
modifier_start = None
253+
254+
name_start = keyword_match.end()
255+
while name_start < length and source[name_start].isspace():
256+
name_start += 1
257+
name_match = _yara_identifier_regex.match(source, name_start)
258+
if not name_match or name_start == keyword_match.end():
259+
raise ExcavateError("could not find rule name")
260+
rule_name = name_match.group()
261+
262+
# walk to the closing brace that matches the rule's opening brace
263+
j = name_match.end()
264+
depth = 0
265+
rule_end = None
266+
while j < length:
267+
skipped = _skip_yara_noncode(source, j)
268+
if skipped is not None:
269+
j = skipped
270+
continue
271+
char = source[j]
272+
if char == "{":
273+
depth += 1
274+
elif char == "}":
275+
if depth == 0:
276+
raise ExcavateError(f"unexpected closing brace in rule [{rule_name}]")
277+
depth -= 1
278+
if depth == 0:
279+
rule_end = j + 1
280+
break
281+
j += 1
282+
if rule_end is None:
283+
raise ExcavateError(f"unterminated rule [{rule_name}]")
284+
285+
rules.append((rule_name, source[rule_start:rule_end]))
286+
i = rule_end
287+
288+
return imports, rules
289+
290+
141291
class YaraRuleSettings:
142292
def __init__(self, description, tags, emit_match, severity, confidence):
143293
self.description = description
@@ -346,9 +496,8 @@ async def process(self, yara_results, event, yara_rule_settings, discovery_conte
346496
)
347497
if yara_rule_settings.emit_match:
348498
event_data["description"] += f" and extracted [{result}]"
349-
event_data["severity"] = yara_rule_settings.get("severity", "LOW")
350-
event_data["confidence"] = yara_rule_settings.get("confidence", "UNKNOWN")
351499

500+
# severity and confidence are filled in from the rule's meta by report()
352501
await self.report(event_data, event, yara_rule_settings, discovery_context)
353502

354503

@@ -391,9 +540,6 @@ class Config(BaseModuleConfig):
391540

392541
_module_threads = 6
393542

394-
yara_rule_name_regex = re.compile(r"rule\s(\w+)\s{")
395-
yara_rule_regex = re.compile(r"(?s)((?:rule\s+\w+\s*{[^{}]*(?:{[^{}]*}[^{}]*)*[^{}]*(?:/\S*?}[^/]*?/)*)*})")
396-
397543
def in_bl(self, value):
398544
# Check if the value is in the blacklist or starts with a blacklisted prefix.
399545
lower_value = value.lower()
@@ -1190,10 +1336,6 @@ def add_yara_rule(self, rule_name, rule_content, rule_instance):
11901336
self.yara_rules_dict[rule_name] = rule_content
11911337
self.yara_preprocess_dict[rule_name] = rule_instance.preprocess
11921338

1193-
async def extract_yara_rules(self, rules_content):
1194-
for r in await self.helpers.re.findall(self.yara_rule_regex, rules_content):
1195-
yield r
1196-
11971339
async def emit_web_parameter(
11981340
self, host, param_type, name, original_value, url, description, additional_params, event, context
11991341
):
@@ -1228,6 +1370,7 @@ async def emit_custom_parameters(self, event, config_key, param_type, descriptio
12281370
async def setup(self):
12291371
self.yara_rules_dict = {}
12301372
self.yara_preprocess_dict = {}
1373+
self.custom_yara_imports = []
12311374

12321375
modules_WEB_PARAMETER = [
12331376
module_name
@@ -1268,7 +1411,6 @@ async def setup(self):
12681411

12691412
self.custom_yara_rules = self.config.get("custom_yara_rules", "")
12701413
if self.custom_yara_rules:
1271-
custom_rules_count = 0
12721414
if Path(self.custom_yara_rules).is_file():
12731415
with open(self.custom_yara_rules) as f:
12741416
rules_content = f.read()
@@ -1278,28 +1420,28 @@ async def setup(self):
12781420
rules_content = self.custom_yara_rules
12791421

12801422
self.debug(f"Final combined yara rule contents: {rules_content}")
1281-
custom_yara_rule_processed = self.extract_yara_rules(rules_content)
1282-
async for rule_content in custom_yara_rule_processed:
1423+
try:
1424+
self.custom_yara_imports, custom_rules = split_yara_rules(rules_content)
1425+
except ExcavateError as e:
1426+
return False, f"Custom Yara rules are formatted incorrectly: {e}"
1427+
if not custom_rules:
1428+
return False, "Custom Yara rules contain no rules"
1429+
1430+
import_prefix = "".join(f"{i}\n" for i in self.custom_yara_imports)
1431+
for rule_name, rule_content in custom_rules:
1432+
if rule_name in self.yara_rules_dict:
1433+
return False, f"Custom Yara rule [{rule_name}] collides with the name of an existing rule"
12831434
try:
1284-
yara.compile(source=rule_content)
1435+
yara.compile(source=f"{import_prefix}{rule_content}")
12851436
except yara.SyntaxError as e:
1286-
return False, f"Custom Yara rule failed to compile: {e}"
1287-
1288-
rule_match = await self.helpers.re.search(self.yara_rule_name_regex, rule_content)
1289-
if not rule_match:
1290-
return False, "Custom Yara formatted incorrectly: could not find rule name"
1291-
1292-
rule_name = rule_match.groups(1)[0]
1293-
c = CustomExtractor(self)
1294-
self.add_yara_rule(rule_name, rule_content, c)
1295-
custom_rules_count += 1
1296-
if custom_rules_count > 0:
1297-
self.hugeinfo(f"Successfully added {str(custom_rules_count)} custom Yara rule(s)")
1437+
return False, f"Custom Yara rule [{rule_name}] failed to compile: {e}"
1438+
self.add_yara_rule(rule_name, rule_content, CustomExtractor(self))
1439+
self.hugeinfo(f"Successfully added {len(custom_rules):,} custom Yara rule(s)")
12981440

12991441
yara_max_match_data = self.config.get("yara_max_match_data", 2000)
13001442

13011443
yara.set_config(max_match_data=yara_max_match_data)
1302-
yara_rules_combined = "\n".join(self.yara_rules_dict.values())
1444+
yara_rules_combined = "\n".join([*self.custom_yara_imports, *self.yara_rules_dict.values()])
13031445
try:
13041446
start = time.time()
13051447
self.verbose(f"Compiling {len(self.yara_rules_dict):,} YARA rules")

0 commit comments

Comments
 (0)