编写高效的 YARA 规则对于保持快速、准确的扫描性能至关重要。本指南提供关键原则和最佳实践,帮助你优化规则、减少不必要的计算并避免常见陷阱。它融合了行业专家的见解,包括 Victor M. Alvarez、WXS 以及 YARA 社区的贡献。
本节提供 YARA 性能最佳实践的简明摘要。有关详细说明和示例,请参阅下面的完整指南。
"把 YARA 想象成一个两步过程:首先,搜索 strings 中列出的所有模式;其次,评估条件。你无法用精心构造的条件来弥补字符串选择不当的问题。"
— Wesley Shields
YARA 在扫描文件时遵循四个主要步骤:
YARA 首先搜索字符串,这使字符串选择成为影响规则效率的最重要因素。
✅ 字符串的最佳实践:
\x00\x00\x00\x00 出现得过于频繁。nocase——它会生成呈指数级增长的搜索变体。YARA 按顺序评估条件,并在第一次失败时停止。
✅ 条件的最佳实践:
filesize < X),然后再放开销大的条件。for all i in (1..filesize) 效率低下)。@)而不是正则表达式来进行顺序检查。⚠ 注意: 正则表达式条件不会短路,并且总是最后才被评估。
像 pe、elf 或 magic 这样的模块在评估之前必须解析整个文件,这会增加扫描时间。
✅ 替代方案:
pe.is_pe,改用 uint16(0) == 0x5A4D 来识别 PE 文件。过多的匹配会减慢扫描速度,并可能触发 “too many matches”(匹配过多)错误。
✅ 修复低效匹配:
.*、.+ 或 {x,}。@herrcore 制作了一个非常实用的视频教程,涵盖了本性能指南中讨论的主题。
为了更好地把握 YARA 性能可以在哪些方面、哪些环节进行优化,了解扫描过程会很有帮助。扫描过程基本上分为 4 个步骤,下面将使用这个示例规则进行非常简化的说明:
import "math"
rule example_php_webshell_rule
{
meta:
description = "Just an example php webshell rule"
date = "2021/02/16"
strings:
$php_tag = "<?php"
$input1 = "GET"
$input2 = "POST"
$payload = /assert[\t ]{0,100}\(/
condition:
filesize < 20KB and
$php_tag and
$payload and
any of ( $input* ) and
math.entropy(500, filesize-500) >= 5
}
这一步发生在实际扫描之前。YARA 会在搜索字符串中查找所谓的 atoms(原子),以提供给 Aho-Corasick 自动机。详细信息在原子章节中说明,但现在只需知道它们最长为 4 字节,并且 YARA 会相当巧妙地选择它们以避免过多匹配。在我们的示例中,YARA 可能会选择以下 4 个原子:
<?phGETPOSTsser(来自 assert)到这里,扫描已经开始了。步骤 2 到 4 将对所有文件执行。YARA 会借助一种称为 Aho-Corasick 自动机的前缀树,在每个文件中查找上面定义的 4 个原子。任何匹配都会被交给字节码引擎。
例如,如果匹配到 sser,YARA 会检查它是否以 a 为前缀,并且后面是否跟着 t。如果成立,它将接着匹配正则表达式 [\t ]{0,100}\(。通过这种巧妙的方法,YARA 避免了使用缓慢的正则表达式引擎遍历整个文件,而只是挑选特定部分进行更仔细的检查。
所有模式匹配完成后,就开始检查条件。YARA 还有另一种优化机制:只有当示例规则中它前面的 4 个条件都满足时,才执行 CPU 密集型的 math.entropy 检查。更详细的说明见条件与短路求值章节。
如果条件满足,就会报告一个匹配。然后扫描进入步骤 2,继续处理下一个文件。
YARA 会从字符串中提取最长 4 字节的短子串,这些子串被称为 “atoms”(原子)。这些原子可以从字符串中的任何位置提取。扫描文件时,YARA 会搜索这些原子,如果找到其中一个,则会验证字符串是否真正匹配。
例如,考虑以下字符串:
/abc.*cde/
=> 可能的原子是 abc 和 cde,可以使用两者中的任意一个。目前优先选择 abc 原子,因为它们的质量相同,而它是两者中的第一个。
/(one|two)three/
=> 可能的原子是 one、two、thre 和 hree,我们可以单独搜索 thre(或 hree),也可以同时搜索 one 和 two。优先选择原子 thre,因为它比 one 和 two(这两个更短)产生的潜在匹配更少,而且它不包含双写字母 e(字母越独特越好)。
YARA 会尽最大努力为每个字符串选择最佳原子,例如:
{ 00 00 00 00 [1-4] 01 02 03 04 }
=> 这里 YARA 使用原子 01 02 03 04,因为 00 00 00 00 太常见了
{ 01 02 [1-4] 01 02 03 04 }
=> 优先选择 01 02 03 04 而不是 01 02,因为它更长
因此,重要的一点是字符串应包含好的原子。以下是不好的字符串,因为它们包含的原子要么太短,要么太常见:
{00 00 00 00 [1-2] FF FF [1-2] 00 00 00 00}
{AB [1-2] 03 21 [1-2] 01 02}
/a.*b/
/a(c|d)/
最糟糕的字符串是那些根本不包含任何原子的字符串,例如:
/\w.*\d/
/[0-9]+\n/
这些正则表达式不包含任何可以用作原子的固定子串,因此必须在文件的每个偏移位置进行求值,以查看它们是否匹配。
另一个重要的建议是避免使用迭代次数过多的循环,尤其是当循环内的语句过于复杂时,例如:
strings:
$a = {00 00}
condition:
for all i in (1..#a) : (@a[i] < 10000)
这条规则有两个问题。第一,字符串 $a 太常见;第二,正因为 $a 太常见,#a 可能非常大,从而导致条件被评估成千上万次。
另一个条件也同样低效:迭代次数取决于文件大小,而文件大小也可能非常大:
for all i in (1..filesize) : ($a at i)
避免使用在 Windows 平台上不可用的 “magic” 模块。使用 “magic” 模块会减慢扫描速度,但能提供精确匹配。
自定义 GIF 魔数头定义:
rule gif_1 {
condition:
(uint32be(0) == 0x47494638 and uint16be(4) == 0x3961) or
(uint32be(0) == 0x47494638 and uint16be(4) == 0x3761)
}
使用 “magic” 模块:
import "magic"
rule gif_2 {
condition:
magic.mime_type() == "image/gif"
}
避免定义过短的字符串。任何少于 4 字节的字符串都很可能出现在大量文件中,或者作为异或(XOR)处理过的文件中的统一内容出现。
有些字符串长度足够,但出于另一个原因——内容单一——不应使用。以下是一些不应使用的字符串示例,因为它们可能导致文件中出现过多匹配。
$s1 = "22222222222222222222222222222222222222222222222222222222222222"
$s2 = "\x00\x20\x00\x20\x00\x20\x00\x20\x00\x20\x00\x20\x00\x20" // wide formatted spaces
错误消息看起来像这样:
error scanning yara-killer.dat: string "$mz" in rule "shitty_mz" caused too many matches
尽量将字符串定义描述得尽可能精确。如有可能,尽量避免使用 “nocase” 属性,因为会生成并搜索大量原子(内存占用更高,迭代次数更多)。请记住,在没有修饰符的情况下,默认采用 “ascii”。可能的组合如下:
LOW(低) - 只生成一个原子
$s1 = "cmd.exe" // (ascii only)
$s2 = "cmd.exe" ascii // (ascii only, same as $s1)
$s3 = "cmd.exe" wide // (UTF-16 only)
$s4 = "cmd.exe" ascii wide // (both ascii and UTF-16) two atoms will be generated
$s5 = { 63 6d 64 2e 65 78 65 } // ascii char code in hex
HIGH(高) - YARA 选定的 4 个字节的所有大小写字母组合都将生成为原子
$s5 = "cmd.exe" nocase (all different cases, e.g. "Cmd.", "cMd.", "cmD." ..)
如果你想匹配脚本命令,请在使用 nocase 之前确认该语言是否完全不区分大小写(例如 php、Windows 批处理)。如果只需要一两个字母的大小写变体,最好使用正则表达式,例如:
$re = /[Pp]assword/
使用交替(alternation)时要小心,例如:
$re = /(a|b)cde/
$hex = {C7 C3 00 (31 | 33)}
这些字符串会生成较短的原子,从而减慢扫描速度。当变体数量较少时,建议将字符串分开编写:
$re1 = /acde/
$re2 = /bcde/
$hex1 = {C7 C3 00 31}
$hex2 = {C7 C3 00 33}
只在必要时使用正则表达式。正则表达式的求值本质上比普通字符串匹配慢,并且会消耗大量内存。如果带跳转和通配符的十六进制字符串就能解决问题,就不要使用它们。
如果必须使用正则表达式,请避免贪婪量词 .*,甚至也要避免懒惰量词 .*?。改用精确的数字,例如 .{1,30},甚至 .{1,3000}。另外,不要忘记上界(例如避免 .{2,})。
使用量词时,可能会发生两种情况:
如果正则表达式的开头锚定在一个位置,只有后缀可以变化,YARA 将匹配尽可能长的结果。对于 .*、.+ 或 .{2,} 这类情况,这可能导致很长的字符串,进而拖慢扫描速度。
如果正则表达式有更多可能的开头,YARA 将全部匹配。
$re1 = /Tom.{0,2}/ // will find Tomxx in "Tomxx"
$re2 = /.{0,2}Tom/ // will find Tom, xTom, xxTom in "xxTom"
较短匹配的数量很容易超过限制,从而产生 “too many matches”(匹配过多)错误。
下面的示例是电子邮箱地址的正则表达式。当 [-a-z0-9._%+] 与量词一起使用时,YARA 会对同一个地址进行多次匹配,这并不理想。在这种情况下,建议找到一个小而合理的地址子集,以提供足够的信息供分析使用。
推荐
/[-a-z0-9._%+]@[-a-z0-9.]{2,10}\.[a-z]{2,4}/
OR
/@[-a-z0-9.]{2,10}\.[a-z]{2,4}/
避免
/[-a-z0-9._%+]*@[-a-z0-9.]{2,10}\.[a-z]{2,4}/
/[-a-z0-9._%+]+@[-a-z0-9.]{2,10}\.[a-z]{2,4}/
/[-a-z0-9._%+]{x,y}@[-a-z0-9.]{2,10}\.[a-z]{2,4}/
如果你想确保 exec 后面跟着 /bin/sh,可以使用 @ 符号提供的偏移量。这是较慢的正则表达式版本:
$ = /exec.*\/bin\/sh/
这是更快的偏移量方式:
strings:
$exec = "exec"
$sh = "/bin/sh"
conditions:
$exec and $sh and
@exec < @sh
另外,尽量在匹配过程中加入可作为锚点的长字符串序列。再说一次,越长越好。
差
$s1 = /http:\/\/[.]*\.hta/ // greedy [.]*
更好
$s1 = /http:\/\/[a-z0-9\.\/]{3,70}\.hta/ // better, with an the upper bound
最佳
$s1 = /mshta\.exe http:\/\/[a-z0-9\.\/]{3,70}\.hta/
“too many matches”(匹配过多)错误是由过于通用的字符串引起的,这些字符串在输入中出现得过于频繁,或者 YARA 对同一个实例进行了多次匹配。
扫描变慢(slowing down scanning)是由生成过短原子或根本不生成原子的字符串引起的。其结果是 YARA 退而使用朴素的模式匹配算法,从而导致扫描变慢。
在某些情况下,这两个问题都可以通过以下步骤修复:
.*、.+、.*?x{14,}x{1,300000})请注意,在下一章条件与短路求值中,会提到一些关于条件的技巧。然而,修改这些技巧并不能解决 “too many matches” 和 “slowing down scanning” 错误。
在编写条件语句时,尽量把最可能为 “False” 的元素放在最前面。条件从左到右进行求值。引擎越早确定规则不满足,就能越早跳过当前规则并评估下一条规则。这种排序方式带来的速度提升取决于处理每条语句所需的 CPU 周期差异。如果所有语句的开销大致相同,重新排序不会带来明显的改善。如果某条语句可以非常快地处理完,建议把它放在最前面,以便在第一条语句为 FALSE 时跳过开销大的语句评估。
改变下面这条语句中的顺序不会带来显著的改善:
$string1 and $string2 and uint16(0) == 0x5A4D
然而,如果各语句的执行时间差异很大,通过重新排序来触发短路将显著提高扫描速度:
慢(SLOW)
// EXPENSIVE and CHEAP
math.entropy(0, filesize) > 7.0 and uint16(0) == 0x5A4D
快(FAST)
// CHEAP and EXPENSIVE
uint16(0) == 0x5A4D and math.entropy(0, filesize) > 7.0
短路求值的引入是为了帮助优化开销大的语句,尤其是 “for” 语句。有些人曾使用过如下示例中的条件:
strings:
$mz = "MZ"
...
condition:
$mz at 0 and for all i in (1..filesize) : ( whatever )
由于 filesize 可能是一个非常大的数字,“whatever” 可能会被执行很多次,从而拖慢执行速度。现在,借助短路求值,“for” 语句只会在条件的第一部分满足时执行,因此这条规则只会在 MZ 文件上变慢。还可以做进一步的改进:
$mz at 0 and filesize < 100KB and for all i in (1..filesize) : ( whatever )
这样就为迭代次数设置了一个更高的上限。
从版本 3.10 开始,整数范围循环也得到了优化:
for all i in (0..100): (false)
for any i in (0..100): (true)
Both of these loops will stop iterating after the first time through.
遗憾的是,这对正则表达式并不适用,因为所有正则表达式最初都会被送入字符串匹配引擎。下面的示例会拖慢对所有文件的搜索速度,而不仅仅是那些文件大小小于 200 字节的文件:
strings:
$expensive_regex = /\$[a-z0-9_]+\(/ nocase
conditions:
filesize < 200 and
$expensive_regex
这种 “短路” 求值自 YARA 3.4 版本起开始应用。
YARA 会将元数据部分中的任何数据读入内存。(你可以很容易地测试这一点:向一条规则中插入 100,000 个哈希,然后检查 YARA 扫描前后的内存使用情况。)当然,你不会想永久性地从规则中删除元数据,但如果内存紧张,可以在工作流程中紧接扫描之前删除其中一些不需要的部分。