[Перевод] Оптимизация правил YARA: руководство по ускорению и точности сканирования

Грамотно написанные YARA-правила обеспечивают высокую скорость и точность сканирования. Однако при неправильном подходе этот полезный инструмент «легким движением руки» превращается в пожирателя CPU, особенно когда сигнатуры исчисляются десятками, а количество файлов для анализа — миллионами.
В статье приведем полезные лайфхаки, которые помогут оптимизировать правила, сократить избыточные вычисления и избежать распространенных ошибок. Для подготовки данного гайда мы использовали материалы, размещенные в открытом доступе на гитхабе. Рекомендации статьи применимы ко всем версиям YARA, начиная с 3.7 и выше.
Всё еще пишете YARA-правила «на глазок»? Тогда мы идем к вам…
Анатомия процесса сканирования YARA
Прежде чем переходить к оптимизации YARA-правил, не помешает пару слов сказать про устройство самого процесса сканирования. Он состоит из четырех основных этапов. Для наглядности рассмотрим их в упрощенном виде на примере следующего правила:
import "math"
rule example_php_webshell_rule
{
meta:
description = "Пример правила для обнаружения PHP-веб-шелла"
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Итак, пройдемся по каждому из этапов.
Этап 1. Компиляция правил (подготовка)
Этот статический этап выполняется один раз при загрузке правил, до начала сканирования. YARA извлекает из строковых литералов и регулярных выражений так называемые атомы (atoms) — короткие подстроки длиной до 4 байт, которые будут служить «якорями» для быстрого поиска. Затем атомы передаются в автомат Ахо-Корасик, который будет построен на их основе. Детали выбора атомов опишем далее. Пока же отметим, что здесь мы не работаем с файлами — только готовим «карту» для будущего поиска.
В нашем случае (см. пример выше) YARA может выбрать следующие четыре атома:
<?ph
GET
POST
sser (из последовательности assert)
Этап 2. Поиск по алгоритму Ахо-Корасик (предварительное сканирование)
Данный этап — динамический и выполняется для каждого файла отдельно. YARA «прогоняет» содержимое файла через заранее построенный автомат Ахо-Корасик, выявляя только атомы — короткие фрагменты, а не полные строки или регулярные выражения. Такая задача выполняется на уровне простого сравнения байтов. Найденные совпадения передаются движку байт-кода для дальнейшей проверки. В отличие от первого этапа, здесь мы уже взаимодействуем с данными, но лишь на поверхностном уровне — без анализа контекста.
Этап 3. Работа движка байт-кода (верификация совпадений)
На этом этапе YARA берет найденные атомы и проверяет полное совпадение с исходными строками и регулярными выражениями. Например, если движок находит атом sser, то проверяется, предшествует ли ему символ a и следует ли за ним t — так восстанавливается полная строка assert. Если это условие выполняется, продолжается проверка по регулярному выражению [\t ]{0,100}\\(. Такой подход ускоряет обработку всего файла движком регулярных выражений: анализируются лишь те фрагменты, где было найдено предварительное совпадение по атому.
Этап 4. Проверка условий (финальная логика)
Это исполнение дополнительной логики, описанной в блоке condition. Здесь работает еще один механизм оптимизации: ресурсоемкая проверка math.entropy из нашего примера запустится, только если истинны четыре предыдущих условия. Впрочем, об этом расскажем подробнее немного позже. При выполнении условий YARA сообщает о совпадении и переходит к следующему файлу.
Что же, с базовыми понятиями и этапностью сканирования в общих чертах разобрались. Теперь можно переходить к обещанным лайфхакам по повышению эффективности применения YARA-правил.
Золотые правила выбора строк
«Работу YARA можно разбить на два шага: сначала поиск строк, потом проверка условий. Если строки подобраны неудачно, их не спасут даже идеальные условия.
Ниже — семь рекомендаций по работе со строками, которые помогут снизить риски падения производительности.
Избегайте коротких строк (менее 4 байт). Короткие строки приводят к множеству ложноположительных срабатываний. Любая такая строка с высокой вероятностью встретится в массе файлов. Или же она окажется однородным содержимом в файле, обработанном операцией XOR.
Используйте уникальные 4-байтные атомы. На них основан механизм быстрого сканирования YARA.
Минимизируйте символы подстановки (wildcards) в HEX-строках. Оставляйте хотя бы один длинный непрерывный сегмент данных.
Применяйте регулярные выражения обдуманно. При необходимости добавляйте фиксированный 4-байтный «якорь» для повышения производительности.
Избегайте паттернов из повторяющихся байтов (например, \x00\x00\x00\x00), так как они встречаются слишком часто.
Некоторые строки не стоит использовать из-за их однотипности (даже если они обладают достаточной длиной). Ниже приведены примеры строк, от которых стоит отказаться, поскольку они могут вызывать слишком большое количество совпадений в файлах.
$s1 = "22222222222222222222222222222222222222222222222222222222222222"
$s2 = "\x00\x20\x00\x20\x00\x20\x00\x20\x00\x20\x00\x20\x00\x20" // пробелы в кодировке wide
Сообщение об ошибке будет выглядеть так:
error scanning yara-killer.dat: string "$mz" in rule "shitty_mz" caused too many matches
Используйте оператор nocase с осторожностью. Он генерирует множество атомов, увеличивая потребление памяти и число итераций. По умолчанию для строк используется тип ascii. Приведем возможные комбинации и их влияние на производительность.
Пример возможных комбинаций
Низкая нагрузка (генерируется только один атом):
$s1 = "cmd.exe" // (только ascii)
$s2 = "cmd.exe" ascii // (только ascii, идентично $s1)
$s3 = "cmd.exe" wide // (только UTF-16)
$s4 = "cmd.exe" ascii wide // (ascii и UTF-16, будет сгенерировано два атома)
$s5 = { 63 6d 64 2e 65 78 65 } // коды символов ascii в hex
Высокая нагрузка (генерируются атомы для всех комбинаций регистра):
$s5 = "cmd.exe" nocase (все варианты регистра, например, "Cmd.", "cMd.", "cmD." и т. д.)
Прежде чем использовать nocase для поиска команд в скриптах, убедитесь, что синтаксис целевого языка действительно нечувствителен к регистру (например, как в PHP или пакетных файлах Windows). Если регистр может меняться лишь у одной или двух букв, гораздо эффективнее будет использовать регулярное выражение, например:
$re = /[Pp]assword/
Правильно используйте оператор чередования. Для наглядности рассмотрим следующие строки:
$re = /(a|b)cde/
$hex = {C7 C3 00 (31 | 33)}
Они создают короткие атомы и замедляют сканирование. Если вариантов немного, лучше объявить каждую комбинацию отдельной строкой:
$re1 = /acde/
$re2 = /bcde/
$hex1 = {C7 C3 00 31}
$hex2 = {C7 C3 00 33}Лайфхаки по работе с условиями
Если строки — это фундамент сканирования, то условия можно сравнить с несущими стенами. В конце концов, они тоже во многом определяют, выдержит ли система нагрузку при проверке YARA-правилами.
Несколько полезных лайфхаков по работе с условиями.
Правило 1. Сначала выполняйте быстрые проверки
YARA проверяет условия слева направо, останавливается на первом же несовпадении (результате, возвращающем 'false') и переходит к следующему правилу. Выигрыш в скорости от такого порядка зависит от разницы в ресурсоемкости вычисления каждого из выражений. Если выражения равнозначны по затратам, их порядок не важен. Если же одно из них вычисляется быстрее, его следует ставить первым в блоке conditions, чтобы избежать проверки более ресурсоемких условий. Например, перестановка в следующем условии не даст значительного ускорения, так как все проверки относительно быстрые:
$string1 and $string2 and uint16(0) == 0x5A4D
Если же ресурсоемкость проверок разная, их перестановка для вычисления по короткой схеме значительно ускорит сканирование.
Пример
Медленный вариант
// Сначала ресурсоемкая проверка, затем быстрая
math.entropy(0, filesize) > 7.0 and uint16(0) == 0x5A4D
Быстрый вариант
// Сначала быстрая проверка, затем ресурсоемкая
uint16(0) == 0x5A4D and math.entropy(0, filesize) > 7.0
Правило 2. Избегайте циклов по большим объемам данных
Механизм вычисления по короткой схеме был добавлен в YARA для оптимизации ресурсоемких условий, в первую очередь содержащих цикл for. Ранее некоторые специалисты писали правила со следующими условиями:
strings:
$mz = "MZ"
...
condition:
$mz at 0 and for all i in (1..filesize) : (некое_условие)Поскольку значение filesize может быть очень большим, тело цикла (некое_условие) выполнялось множество раз, что сильно замедляло процесс сканирования. Теперь, с вычислением по короткой схеме, цикл for выполняется, только если первая часть условия истинна. Правило замедляется лишь на MZ-файлах. Также улучшить производительность можно ограничением размера файла:
$mz at 0 and filesize < 100KB and for all i in (1..filesize) : (некое_условие)
Таким образом, задается верхний предел для количества итераций цикла.
В YARA 3.10 также оптимизировали циклы по диапазону целых чисел:
for all i in (0..100): (false)
for any i in (0..100): (true)
Оба цикла прервутся после первой же итерации.
Правило 3. Для проверки данных по известному смещению используйте указатель положения (@) вместо регулярных выражений
Условия с регулярными выражениями всегда проверяются в последнюю очередь и не используют вычисления по короткой схеме, так как они заранее передаются в движок поиска строк. Поэтому пример ниже замедлит сканирование всех файлов, а не только тех, что меньше 200 байт:
strings:
$expensive_regex = /\$[a-z0-9_]+\(/ nocase
conditions:
filesize < 200 and
$expensive_regexВычисление по короткой схеме доступно с версии YARA 3.4.
Модули и производительность сканирования
Модули pe, elf, magic и другие действительно бывают полезны при сканировании и глубоком анализе структуры файла. Правда, они должны полностью проанализировать (распарсить) файл перед началом проверки, что замедляет сканирование.
Поэтому применять модули важно обдуманно, иначе проверка превращается в стрельбу из «Джавелина» по воробьям. Скажем, если для логики правила не требуется глубокий анализ структуры файла, то модули лучше не использовать.
Возьмем для примера модуль [magic]. Да, этот модуль позволяет получать точные совпадения по типам файлов на основе их сигнатур, однако замедляет сканирование и недоступен на платформе Windows.
Пример определения заголовка 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"
}Избыточные совпадения и ошибка too many matches
Еще одна распространенная причина просадки производительности и затягивания сканирования — слишком большое количество совпадений строк. К тому же это может привести к ошибке too many matches.
Вот как можно исправить неэффективные правила и избежать ошибки too many matches:
в регулярных выражениях избегайте квантификаторов
.*, .+или{x,}без верхней границы.сократите количество символов подстановки (wildcards) в HEX-строках.
по возможности разделяйте
|(операторы чередования) на отдельные строки.
К слову, в OpenAnalysis Inc (@herrcore) есть наглядный видеоурок, как написать эффективные YARA-правила.
Поиск неэффективных строк
Ошибка «слишком много совпадений» (too many matches) возникает, когда строки в правиле чересчур общие и часто встречаются в проверяемых данных. Другая возможная причина — YARA находит множество пересекающихся совпадений для одного фрагмента.
Замедление сканирования вызывают строки, которые генерируют слишком короткие атомы или не создают их вовсе. В результате YARA вынуждена использовать медленный и наивный алгоритм поиска по шаблону, что и приводит к падению производительности.
В некоторых случаях обе эти проблемы решаются следующими действиями:
проверкой наличия «жадных» и «ленивых» квантификаторов ( .*, .+, .*?) или квантификаторов без верхней границы (например, x{14,});
проверкой наличия слишком больших диапазонов (например, x{1,300000}), или больших «прыжков» (jumps) в шестнадцатеричных строках;
анализом использования подстановочных знаков (wildcards). Например, можно ли заменить их более точными символами или разбить строку на две, исключив подстановочный знак;
включением модификатора для поиска целых слов (например, fullword, \b);
использованием оператора чередований. Например, проверяем, можно ли разбить строку на две или более отдельные строки?
Эффективные и неэффективные атомы
Повторимся, что YARA выделяет из строк короткие подстроки длиной до 4 байт — атомы. Они могут быть взяты из любого участка строки. При сканировании файла YARA ищет именно эти атомы, а при обнаружении хотя бы одного — выполняет полную проверку на соответствие всей строки.
Рассмотрим, как это работает на примерах.
Скажем, в строке /abc.*cde/ возможные атомы — abc и cde.YARA может использовать любой из них, но в данном случае выберет abc, поскольку при одинаковом «качестве» он стоит первым.
Возможными атомами для строки /(one|two)three/ будут one, two, thre и hree. В теории YARA может искать либо thre (или hree) отдельно, либо one и two вместе. Однако выбор почти гарантированно падет на thre, так как он выдаст меньше потенциальных совпадений, чем one и two (они короче). К тому же thre не содержит повторяющуюся букву e (в отличие от hree), что делает его комбинацию символов более уникальной.
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/
В этих регулярных выражениях нет ни одной фиксированной подстроки, которую можно было бы использовать в качестве атома. Поэтому YARA придется проверять такое выражение по каждому смещению в файле, что крайне ресурсозатратно.
Влияние количества итераций в цикле на производительность
Избегайте слишком большого числа итераций в цикле. Особенно это актуально, если тело цикла содержит сложные инструкции for. И снова рассмотрим правило на примере:
strings:
$a = {00 00}
condition:
for all i in (1..#a) : (@a[i] < 10000)У этого правила две проблемы. Во-первых, строка $a слишком распространена. Во-вторых, из-за этого счетчик ее вхождений #a может достигать огромных значений, что приводит к тысячам итераций цикла и многократной проверке условия.
Следующее условие тоже крайне неэффективно, поскольку число итераций в его цикле зависит от размера файла (filesize), который может быть чрезмерно большим:
for all i in (1..filesize) : ($a at i)
Регулярные выражения и производительность
Как и обещали, возвращаемся к теме регулярных выражений. Их обработка по определению медленнее, чем сопоставление с обычными строками, и требует значительного объема памяти. В качестве альтернативы зачастую можно использовать hex-строки с пропусками и подстановочными символами.
Если без регулярных выражений всё же не обойтись, избегайте как жадных (.*), так и ленивых (.*?) квантификаторов. Вместо них следует использовать конструкции с точным указанием числа повторений, например, .{1,30} или даже .{1,3000}. Также важно всегда задавать верхнюю границу диапазона (то есть избегать выражений вроде .{2,}).
При использовании квантификаторов возможны две ситуации.
Ситуация 1. Начало регулярного выражения зафиксировано, а варьируется только его окончание. Тогда YARA найдет одно, самое длинное из возможных совпадений. В случае с неограниченными квантификаторами (.*, .+, .{2,}) это может привести к захвату очень длинных строк и замедлению сканирования.
Ситуация 2. Варьируется начало выражения. В таком случае YARA найдет все возможные совпадения.
$re1 = /Tom.{0,2}/ // в строке "Tomxx" найдет одно совпадение: "Tomxx"
$re2 = /.{0,2}Tom/ // в строке "xxTom" найдет три совпадения: "Tom", "xTom", "xxTom"Множественные совпадения могут вызвать ошибку too many matches.
Рассмотрим в качестве примера регулярное выражение для поиска адресов электронной почты. Применение квантификаторов (*, +, {x,y}) к части адреса перед символом @ (например, [-a-z0-9._%+]) приведет к тому, что YARA найдет множество частичных совпадений для одного и того же адреса, а это крайне неэффективно. Рекомендуется либо зафиксировать начало поиска, либо использовать более узкий шаблон, предоставляющий достаточно информации для анализа.
Хорошие варианты, например, могут выглядеть так:
/[-a-z0-9._%+]@[-a-z0-9.]{2,10}\.[a-z]{2,4}/
/@[-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), вместо регулярных выражений лучше использовать проверку смещений по файлу, доступную через символ @.
Старайтесь также включать в правила длинные строковые последовательности в качестве «якорей» для поиска совпадений. Чем они длиннее, тем лучше.
Пример
Плохой вариант:
$s1 = /http:\/\/[.]*\.hta/ // «жадный» квантификатор [.]*
Оптимальный вариант:
$s1 = /mshta\.exe http:\/\/[a-z0-9\.\/]{3,70}\.hta/
YARA — это инструмент, который экономит ваше время, если вы экономите его ресурсы. Надеемся, что этот гайд поможет оптимизировать сканирование, и пусть ваши сигнатуры работают на вас, а не против вас.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.