Оптимизация правил 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_]+\(/ nocaseconditions:  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 — это инструмент, который экономит ваше время, если вы экономите его ресурсы. Надеемся, что этот гайд поможет оптимизировать сканирование, и пусть ваши сигнатуры работают на вас, а не против вас.

ссылка на оригинал статьи https://habr.com/ru/articles/1088954/