YARA для семейств малвари: от образца к устойчивой сигнатуре
Введение: почему сигнатуры на семейство — отдельная задача
Сигнатура на конкретный образец — это антивирусный хэш. Задача YARA-аналитика другая: написать правило, которое покроет всё семейство сразу, включая образцы которых ты ещё не видел, и не триггернет на легитимный код.
Это принципиально разные задачи. Хэш срабатывает ровно на один файл. Хорошее YARA-правило срабатывает на сотни образцов одного семейства — потому что оно описывает не конкретные байты конкретной сборки, а архитектурные и поведенческие константы всего семейства.
Проблема "сигнатура сломалась через неделю" почти всегда означает одно: в правиле были volatile-признаки — строки C2, мьютексы с таймштампом, случайные смещения в коде. Малварь обновилась, эти байты сменились, правило перестало работать.
Anti-fragile сигнатура строится иначе: она цепляется за то, что авторы малвари не могут поменять без переписывания всей архитектуры. Это опкодовые паттерны алгоритмов, структура PE, характеристики компоновщика, схемы XOR-расшифровки. Это то, что остаётся константным между кампаниями.
Гайд разбит по типам признаков: каждый раздел — отдельный класс индикаторов с практическими примерами правил, объяснением механики и анализом ошибок.
Раздел 1: Устойчивые признаки — что не меняется между кампаниями
Принцип выбора признаков
Перед написанием правила задать себе один вопрос: если авторы малвари выпустят следующую версию, это изменится?
Строки — меняются. C2-адреса — меняются каждые дни или недели. Имена мьютексов — меняются. Пути в реестре — меняются.
Не меняется: алгоритм расшифровки (потому что декриптор написан один раз и встроен в loader), структура конфига (потому что парсер конфига компилируется в бинарь), характеристики компоновщика (потому что семейство собирается одной и той же сборочной системой), схема anti-debug (потому что её пишет один человек и не трогает годами).
Практический отбор: кластерный анализ перед YARA
До написания правила нужно посмотреть на несколько образцов семейства. Минимум три-пять разных кампаний или версий.
Инструмент: bindiff для сравнения бинарей, или ручное сравнение в Ghidra через функцию "Compare". Смотреть: какие функции идентичны побайтово, какие похожи структурно, какие различаются. Идентичные и структурно похожие — кандидаты для сигнатуры.
Второй инструмент: capa (от MANDIANT). Он автоматически извлекает поведенческие характеристики — "использует RC4", "читает из реестра Run key", "вызывает NtWriteVirtualMemory". Если эти характеристики совпадают между образцами — они достаточно устойчивы.
Пример: устойчивый признак в AgentTesla
AgentTesla — стилер с многолетней историей. Строки C2 меняются ежедневно, мьютексы рандомизированы. Но семейство всегда использует один и тот же метод чтения clipboard через OpenClipboard + GetClipboardData + специфичный цикл обработки. Этот код не менялся с 2019 года.
rule AgentTesla_ClipboardSteal_Stable {
meta:
author = "SOC-analyst"
description = "AgentTesla clipboard harvesting - stable across versions"
date = "2024-01"
tlp = "WHITE"
strings:
// Устойчивый паттерн инициализации clipboard loop
// OpenClipboard(0) -> GetClipboardData(CF_TEXT=1) -> GlobalLock
$clip_seq = {
6A 00 // push 0 (hwnd = NULL)
FF 15 ?? ?? ?? ?? // call OpenClipboard
85 C0 // test eax, eax
74 ?? // jz (fail branch)
6A 01 // push 1 (CF_TEXT)
FF 15 ?? ?? ?? ?? // call GetClipboardData
50 // push eax (hData)
FF 15 ?? ?? ?? ?? // call GlobalLock
}
condition:
uint16(0) == 0x5A4D and
filesize < 5MB and
$clip_seq
}
Маска ?? на адресах вызовов — потому что адреса IAT различаются между сборками. Паттерн push-последовательности остаётся константным.
Раздел 2: Opcode patterns — поиск по байт-последовательностям
Как читать опкоды для YARA
Дизасм показывает мнемоники. YARA работает с байтами. Конвертация происходит в голове аналитика или через плагины x64dbg/Ghidra.
- Опкоды инструкций — стабильны.
E8— этоCALL rel32всегда. - Immediate-значения (константы, адреса) — маскировать через
??или[1-4]. - Относительные смещения в jmp/call — маскировать.
- Регистровые опкоды (ModRM байт) — зависят от выбора компилятора, маскировать осторожно.
Инструмент для конвертации: в x64dbg выделить блок → правый клик → Copy → Bytes. В Ghidra: Listing → правый клик на инструкции → Copy → Bytes. В IDA: Edit → Copy Special → Byte string.
XOR-декриптор как устойчивый паттерн
Большинство семейств реализуют простой XOR-декриптор строк или конфига. Это цикл из 4-6 инструкций. Компилятор может оптимизировать его по-разному, но базовая структура остаётся.
; input: ESI = src, EDI = dst, ECX = length, BL = key
xor_loop:
MOV AL, [ESI] ; 8A 06
XOR AL, BL ; 32 C3
MOV [EDI], AL ; 88 07
INC ESI ; 46
INC EDI ; 47
DEC ECX ; 49
JNZ xor_loop ; 75 F7
rule XOR_Decryptor_Simple_x86 {
meta:
description = "Simple XOR decryption loop - common in droppers/loaders"
strings:
// MOV AL, [reg] -> XOR AL, reg -> MOV [reg], AL -> INC/INC/DEC -> JNZ
$xor_loop_v1 = {
8A 0? // mov al/cl, [reg]
32 ?? // xor al/cl, reg
88 0? // mov [reg], al/cl
4? // inc reg
4? // inc reg
4? // dec reg (counter)
75 ?? // jnz (loop back)
}
// Вариант с LODSB/STOSB (компилятор оптимизирует через rep)
$xor_loop_v2 = {
AC // lodsb
32 ?? // xor al, reg (key)
AA // stosb
E2 ?? // loop (dec ecx, jnz)
}
condition:
uint16(0) == 0x5A4D and
any of ($xor_loop_*)
}
Rolling XOR — более сложный вариант
Многие современные семейства используют rolling key: ключ меняется на каждом байте (XOR с предыдущим зашифрованным байтом). Это частый паттерн в Emotet и похожих loader'ах.
; AL = текущий байт, BL = предыдущий расшифрованный байт (rolling key)
mov_byte:
MOV AL, [ESI] ; 8A 06
XOR AL, BL ; 32 C3
MOV BL, [ESI] ; 8A 1E (сохраняем ЗАШИФРОВАННЫЙ байт как следующий ключ)
MOV [EDI], AL ; 88 07
INC ESI ; 46
INC EDI ; 47
DEC ECX ; 49
JNZ ; 75 F4
Ключевой признак rolling XOR: два чтения из [ESI] в одной итерации — одно для расшифровки, одно для сохранения ключа.
rule RollingXOR_Loader {
meta:
description = "Rolling XOR decryption - typical Emotet/Trickbot loader pattern"
strings:
$rolling = {
8A 06 // mov al, [esi] (load byte)
32 C? // xor al, reg (decrypt)
8A 1? // mov bl/cl, [esi] (save encrypted as next key)
88 0? // mov [edi], al (store decrypted)
4? // inc esi
4? // inc edi
4? // dec ecx
75 ?? // jnz
}
condition:
uint16(0) == 0x5A4D and $rolling
}
CPUID как anti-VM маркер
Многие семейства делают CPUID с EAX=1 и проверяют бит ECX[31] — hypervisor present. Опкод CPUID это 0F A2.
rule CPUID_AntiVM_Check {
meta:
description = "CPUID hypervisor check - anti-VM technique"
strings:
// MOV EAX, 1 -> CPUID -> TEST/BT ECX, bit31
$cpuid_hv = {
B8 01 00 00 00 // mov eax, 1
0F A2 // cpuid
F7 C1 00 00 00 80 // test ecx, 0x80000000 (bit 31)
}
// Вариант через BT (bit test)
$cpuid_bt = {
B8 01 00 00 00
0F A2
0F BA E1 1F // bt ecx, 31
}
condition:
uint16(0) == 0x5A4D and any of them
}
Раздел 3: PE features — структурные признаки исполняемого файла
Что читать в PE-заголовке для YARA
YARA даёт прямой доступ к байтам файла. PE-структура лежит в предсказуемых смещениях, и функции uint8(), uint16(), uint32() позволяют читать их напрямую без парсера.
Что Как читать DOS-сигнатура MZ uint16(0) == 0x5A4D PE-offset uint32(0x3C) Machine (x86/x64) uint16(uint32(0x3C) + 4) Number of sections uint16(uint32(0x3C) + 6) TimeDateStamp uint32(uint32(0x3C) + 8) SizeOfOptionalHeader uint16(uint32(0x3C) + 20) Subsystem uint16(uint32(0x3C) + 92) NumberOfRvaAndSizes uint32(uint32(0x3C) + 116)
Для удобства: в YARA 4.x есть встроенный модуль pe — через него удобнее. Но понимание сырых смещений критично для случаев когда структура повреждена или модуль не доступен.
Количество и имена секций как признак
Семейства, использующие один и тот же пакер или компоновщик, имеют характерное количество секций и их имена.
Пример: Cobalt Strike Beacon с deflate-компрессией. Характерный набор секций: .text, .rdata, .data, .reloc — и всё. Нет .rsrc. Нет секций с нестандартными именами. Размер .text секции в определённом диапазоне.
import "pe"
rule CobaltStrike_Beacon_PE_Layout {
meta:
description = "CS Beacon structural PE fingerprint - section layout"
reference = "internal analysis"
condition:
uint16(0) == 0x5A4D and
pe.number_of_sections == 4 and
pe.sections[0].name == ".text" and
pe.sections[1].name == ".rdata" and
pe.sections[2].name == ".data" and
pe.sections[3].name == ".reloc" and
// Нет ресурсов — типично для инжектируемого shellcode-beacon
pe.data_directories[pe.DIRECTORY_ENTRY_RESOURCE].size == 0 and
// Характерный диапазон размера
pe.sections[0].raw_size > 0x5000 and
pe.sections[0].raw_size < 0x50000
}
Подозрительные характеристики секций
RWX-секции (Read + Write + Execute) — прямой индикатор in-memory распаковки или shellcode.
import "pe"
rule PE_Section_RWX {
meta:
description = "Section with RWX permissions - unpacking or shellcode staging"
condition:
uint16(0) == 0x5A4D and
for any i in (0 .. pe.number_of_sections - 1) : (
pe.sections[i].characteristics & 0xE0000000 == 0xE0000000
// 0x20000000 = EXECUTE, 0x40000000 = READ, 0x80000000 = WRITE
)
}
Секция с нулевым RawSize при ненулевом VirtualSize — классический UPX0:
import "pe"
rule PE_UPX0_Style_Section {
meta:
description = "Section with zero raw size - UPX0 pattern or custom packer"
condition:
uint16(0) == 0x5A4D and
for any i in (0 .. pe.number_of_sections - 1) : (
pe.sections[i].raw_size == 0 and
pe.sections[i].virtual_size > 0x1000
)
}
Точка входа в нестандартной секции
У нормального PE точка входа лежит в .text. Если OEP указывает в другую секцию — это упакован или инжектирован код.
import "pe"
rule PE_OEP_Outside_Text {
meta:
description = "Entry point outside .text section"
condition:
uint16(0) == 0x5A4D and
not (
pe.entry_point >= pe.sections[0].virtual_address and
pe.entry_point < pe.sections[0].virtual_address + pe.sections[0].virtual_size
) and
pe.sections[0].name == ".text"
}
IAT минимализм как признак паккера
Нормальный PE имеет 5-15 импортированных библиотек. Паккер оставляет только минимум для bootstrap — обычно kernel32.dll с GetProcAddress + LoadLibraryA.
import "pe"
rule Packed_Minimal_IAT {
meta:
description = "Minimal IAT - packer/loader bootstrap pattern"
strings:
$gpa = "GetProcAddress" ascii
$lla = "LoadLibraryA" ascii
condition:
uint16(0) == 0x5A4D and
pe.number_of_imports < 3 and
$gpa and $lla and
// Высокая энтропия подтверждает что это не просто маленький PE
math.entropy(0, filesize) > 6.5
}
Раздел 4: Rich Header — отпечаток инструментальной цепочки
Что такое Rich Header
Rich Header — блок данных между DOS-заглушкой и PE-сигнатурой. Он создаётся компоновщиком Microsoft Visual C++ и содержит список инструментов сборки: версии компилятора, ассемблера, компоновщика, и количество объектных файлов каждого типа.
Rich Header не является частью PE-стандарта и игнорируется загрузчиком. Но он несёт ценную информацию: по нему можно определить точную версию Visual Studio, и — критично для YARA — образцы, собранные одной и той же сборочной системой, будут иметь идентичный или очень похожий Rich Header.
Формат Rich Header
Структура: XOR-закодированный блок, начинается с сигнатуры DanS (в XOR), заканчивается сигнатурой Rich + XOR-ключ (4 байта). Ключ — это само значение для расшифровки.
После расшифровки: массив пар {CompId, Count} по 8 байт каждая. CompId кодирует тип инструмента и его версию.
Для поиска в сыром виде: искать 52 69 63 68 ("Rich") — это маркер конца блока. XOR-ключ стоит сразу после него.
Rich Header как семейственная сигнатура
Если несколько образцов одного семейства собраны одним и тем же билд-сервером с одними и теми же версиями инструментов — их Rich Header будет идентичен (после XOR с соответствующим ключом). Но сам ключ меняется между сборками.
Поэтому ищем не весь блок, а структурную константу: набор CompId без привязки к конкретному ключу.
Инструмент для анализа: richprint (open source), или DIE который показывает Rich Header во вкладке. Также: pefile Python library через rich_header.
import pefile
pe = pefile.PE("sample.exe")
if hasattr(pe, "RICH_HEADER"):
for entry in pe.RICH_HEADER.values:
print(f"CompId: {entry['count']:#010x}, Count: {entry['count']}")
YARA-правило на Rich Header паттерн
Задача: искать конкретный набор CompId, не завися от XOR-ключа.
Это сложнее прямого поиска, потому что значения в файле XOR-ены. Но: если взять несколько образцов одного семейства и проверить, какие байты встречаются в Rich Header всех из них — найдётся инвариант.
Практический подход: декодировать Rich Header из нескольких образцов, найти пересечение CompId, написать YARA на сырые XOR-значения используя тот факт что ключ находится сразу после Rich.
Альтернативный подход для YARA — искать характерный набор инструментов через косвенные признаки, например сочетание версии компоновщика (которая часто видна в Debug Directory) и специфичных строк:
import "pe"
rule RichHeader_VS2019_Specific_Toolchain {
meta:
description = "VS2019 v16.x toolchain - specific linker version in optional header"
note = "Linker 14.2x = VS2019"
condition:
uint16(0) == 0x5A4D and
// Linker version: major.minor из Optional Header
// Offset: PE_offset + 0x1A (MajorLinkerVersion) и +0x1B (MinorLinkerVersion)
uint8(uint32(0x3C) + 0x1A) == 14 and // MajorLinkerVersion = 14
uint8(uint32(0x3C) + 0x1B) >= 20 and // MinorLinkerVersion >= 20 (VS2019)
uint8(uint32(0x3C) + 0x1B) < 30 // MinorLinkerVersion < 30 (до VS2022)
}
Для точного матчинга по сырому Rich Header — заранее декодировать образцы и искать константную последовательность раскодированных CompId:
rule RichHeader_Specific_Build_Env {
meta:
description = "Specific build environment fingerprint via Rich Header"
note = "CompIds decoded from 3 samples of same campaign"
strings:
// Это пример - реальные значения берутся из конкретных образцов семейства
// После декодирования Rich Header из трёх образцов были найдены эти CompId
$rich_marker = { 52 69 63 68 } // "Rich" - конец блока
condition:
uint16(0) == 0x5A4D and
$rich_marker in (0x40..0x200) // Rich Header всегда в первых 512 байтах
}
Раздел 5: Section layout — компоновка секций как отпечаток
Почему компоновка секций устойчива
Компоновщик создаёт секции по правилам, встроенным в его алгоритм. Разные версии компоновщиков создают разные "подписи" в расположении и именовании секций. Паккеры добавляют секции с характерными именами. Кастомные сборочные системы оставляют специфичный след.
Ключевые параметры для анализа: количество секций, их имена, порядок, соотношение VirtualSize и RawSize, значения FileAlignment и SectionAlignment.
Паттерны специфичных паккеров
MPRESS: характерные имена .MPRESS1 и .MPRESS2.
import "pe"
rule Packer_MPRESS {
meta:
description = "MPRESS packer - section name fingerprint"
condition:
uint16(0) == 0x5A4D and
pe.number_of_sections >= 2 and
(
pe.sections[0].name == ".MPRESS1" or
pe.sections[1].name == ".MPRESS2"
)
}
Themida/WinLicense: секции с именами .themida, .winlice, или характерные имена в верхнем регистре.
NSPack: всегда три секции, последняя имеет имя .nsp1 или пустое имя с RWX флагами.
AutoIT-compiled: один из надёжных признаков — секция .rsrc содержащая ресурс с именем SCRIPT и типом RCDATA. Скрипт AutoIT хранится там.
import "pe"
rule AutoIT_Compiled_Script {
meta:
description = "Compiled AutoIT script - SCRIPT resource"
strings:
$autoit_magic = "AU3!EA06" ascii // AutoIT3 script magic
condition:
uint16(0) == 0x5A4D and
$autoit_magic
}
Выравнивание как признак нестандартной сборки
Нормальные PE-файлы используют FileAlignment = 0x200 (512 байт) или 0x1000 (4096 байт). Нестандартные значения — признак ручной сборки или модификации заголовка.
rule PE_NonStandard_Alignment {
meta:
description = "Non-standard FileAlignment - manually crafted PE"
condition:
uint16(0) == 0x5A4D and
// FileAlignment в Optional Header: offset 0x3C + PE_sig(4) + COFF(20) + 0x24
uint32(uint32(0x3C) + 60) != 0x200 and
uint32(uint32(0x3C) + 60) != 0x1000 and
uint32(uint32(0x3C) + 60) != 0x400
}
Соотношение VirtualSize и RawSize
При паковке: RawSize маленький (сжатый код), VirtualSize большой (место для распаковки). Соотношение VirtualSize/RawSize > 10 — сильный индикатор.
import "pe"
rule PE_High_Virtual_To_Raw_Ratio {
meta:
description = "High VirtualSize/RawSize ratio - packer decompression space"
condition:
uint16(0) == 0x5A4D and
for any i in (0 .. pe.number_of_sections - 1) : (
pe.sections[i].raw_size > 0 and
pe.sections[i].virtual_size > pe.sections[i].raw_size * 10
)
}
Раздел 6: XOR-маски — поиск зашифрованных конфигов
Как малварь хранит зашифрованные данные
Конфиг (C2, порты, ключи) обычно хранится в одном из трёх мест: в секции .data как бинарный blob, в секции .rsrc как ресурс, в оверлее (append-зоне после последней секции).
Задача: написать YARA-правило которое найдёт зашифрованный конфиг не зная ключа.
Entropy-паттерны для обнаружения зашифрованных блоков
XOR-шифрование с коротким ключом (1-4 байта) даёт характерное распределение байт: энтропия умеренная (5.5-7.0), не такая высокая как AES (7.9+). AES и RC4 дают близкую к равномерной энтропию.
import "math"
rule Encrypted_Config_Blob {
meta:
description = "High-entropy blob - encrypted config or payload"
condition:
uint16(0) == 0x5A4D and
filesize < 2MB and
// Средняя энтропия файла высокая
math.entropy(0, filesize) > 6.8 and
// Но не максимальная (не случайные данные)
math.entropy(0, filesize) < 7.8
}
Более точный подход: искать высокоэнтропийный блок конкретного размера внутри файла. Модуль math в YARA 4.x поддерживает math.entropy(offset, size):
import "math"
rule Encrypted_Payload_Section {
meta:
description = "High-entropy section of 4-64KB - typical config/payload size"
condition:
uint16(0) == 0x5A4D and
// Ищем блок 0x1000-0x10000 байт с высокой энтропией
for any offset in (0x400 .. filesize - 0x1000) : (
math.entropy(offset, 0x1000) > 7.2
)
}
Поиск XOR-ключа через known-plaintext
Если известна часть открытого текста (например, конфиг всегда начинается с известной сигнатуры или версии), можно восстановить XOR-ключ и затем искать его в файле.
Допустим конфиг начинается с ASCII-строки "http" (0x68 0x74 0x74 0x70). Если зашифрованный конфиг начинается с байт XX YY ZZ WW, то ключ байт 1 = 0x68 XOR XX, байт 2 = 0x74 XOR YY, и так далее. Найти этот ключ в файле и написать сигнатуру.
Rolling-XOR конфиг с маркером
Многие семейства предваряют зашифрованный блок маркером длины или сигнатурой. Например, первые 4 байта = длина зашифрованного блока, следующие N байт = payload.
rule Config_Length_Prefixed_Blob {
meta:
description = "Length-prefixed encrypted blob - common config pattern"
strings:
// Маркер: DWORD длина, за которым сразу идут шифрованные данные
// Длина обычно в диапазоне 0x100 - 0x10000
$len_prefix = {
[4-4] // DWORD length (любые 4 байта)
( E? | F? | 8? | 9? | A? | B? ) // первый байт зашифрованного блока
// не 00 и не FF (не пустые/заполненные данные)
}
condition:
uint16(0) == 0x5A4D and $len_prefix
}
Поиск XOR с константным ключом через статистику
Байты, XOR-ованные с константным однобайтным ключом, сохраняют частотное распределение исходного текста. Если ключ — нулевой байт, данные не изменятся. Ненулевой ключ сдвигает распределение.
В YARA нельзя делать статистический анализ напрямую, но можно искать паттерны результата. Например: если конфиг содержит много нулевых байт (padding, нулевые порты), они XOR-уются в значение ключа. Искать повторяющийся байт через hex-паттерн:
rule XOR_Encrypted_With_Repeated_Byte {
meta:
description = "Repeated byte pattern - suggests XOR with constant key (null bytes)"
note = "Change 0xAB to actual repeated byte found in samples"
strings:
// Если ключ = 0xAB, то нулевые байты конфига станут 0xAB
// Искать серию повторяющихся байт (padding -> XOR key bytes)
$repeated_xor_key = { AB AB AB AB AB AB AB AB }
condition:
uint16(0) == 0x5A4D and $repeated_xor_key
}
Раздел 7: Anti-fragile сигнатуры — архитектурные константы
Принцип anti-fragile для YARA
Anti-fragile сигнатура — та, которая становится сильнее при попытке авторов её обойти. Это достигается через комбинирование признаков разных уровней: один признак можно убрать, три связанных — уже сложнее.
Структура anti-fragile правила: несколько независимых признаков разных классов (структурный + поведенческий + строковый), объединённых через логику X из N.
Комбинированное правило: пример для Formbook/XLoader
Formbook имеет: характерный метод инжекции через NtQuerySystemInformation для поиска процессов, специфичный anti-debug через NtSetInformationThread с ThreadHideFromDebugger, и характерную структуру PE без ресурсов.
import "pe"
rule Formbook_XLoader_Combined {
meta:
description = "Formbook/XLoader - combined multi-layer signature"
author = "SOC-analyst"
date = "2024-01"
families = "Formbook, XLoader"
strings:
// Anti-debug: NtSetInformationThread с ThreadHideFromDebugger (0x11)
$antidebug_hide = {
6A 11 // push 11h (ThreadHideFromDebugger)
6A 00 // push 0 (ThreadInformationLength = 0)
6A 00 // push NULL (ThreadInformation)
FF 75 ?? // push [ebp+hThread]
FF 15 ?? ?? ?? ?? // call NtSetInformationThread
}
// NtQuerySystemInformation для enumeration процессов
$proc_enum = {
6A ?? // push size
FF 75 ?? // push buffer
6A ?? // push length
6A 05 // push 5 (SystemProcessInformation)
FF 15 ?? ?? ?? ?? // call NtQuerySystemInformation
}
// Характерный мьютекс-паттерн (не конкретное значение, а формат)
$mutex_fmt = /[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}/
condition:
uint16(0) == 0x5A4D and
pe.data_directories[pe.DIRECTORY_ENTRY_RESOURCE].size == 0 and
filesize < 1MB and
2 of ($antidebug_hide, $proc_enum, $mutex_fmt)
}
Правило с условием "2 из 5" — устойчивость к модификациям
Авторы малвари убирают один признак — правило всё равно срабатывает:
import "pe"
import "math"
rule Loader_AntiFragile_2of5 {
meta:
description = "Generic loader fingerprint - robust to partial modification"
strings:
// Признак 1: VirtualAlloc + PAGE_EXECUTE_READWRITE
$va_rwx = {
6A 40 // push 40h (PAGE_EXECUTE_READWRITE)
68 ?? ?? ?? ?? // push flAllocationType
68 ?? ?? 00 00 // push size
6A 00 // push NULL (lpAddress)
FF 15 ?? ?? ?? ?? // call VirtualAlloc
}
// Признак 2: WriteProcessMemory
$wpm = "WriteProcessMemory" ascii
// Признак 3: CreateRemoteThread
$crt = "CreateRemoteThread" ascii
// Признак 4: NtUnmapViewOfSection (Process Hollowing)
$unmap = "NtUnmapViewOfSection" ascii
// Признак 5: Характерный XOR loop
$xor_loop = {
8A 0?
32 ??
88 0?
4?
4?
75 ??
}
condition:
uint16(0) == 0x5A4D and
filesize < 5MB and
math.entropy(0, filesize) > 6.0 and
2 of ($va_rwx, $wpm, $crt, $unmap, $xor_loop)
}
Временна́я устойчивость: не цепляться за C2
Самая распространённая ошибка — включить в правило IP-адрес или домен C2. Это работает ровно до первой смены инфраструктуры — то есть дни или недели.
Правильный подход: писать правило на метаданные строк C2, а не на их значения.
Например, Emotet хранит список C2 как массив структур {IP, PORT} в зашифрованном виде. Признак — не конкретные IP, а количество и размер этого массива, плюс структура декриптора.
import "pe"
rule Emotet_C2_List_Structure {
meta:
description = "Emotet C2 list structural fingerprint - not hardcoded IPs"
note = "IP:PORT struct array, typically 64-100 entries * 6 bytes = 384-600 bytes"
strings:
// Декриптор структуры C2-листа: характерный паттерн обхода массива
// ADD REG, 6 — шаг 6 байт (4 байта IP + 2 байта PORT)
$c2_loop_step = {
83 C? 06 // add reg, 6 (next C2 entry, 4+2 bytes)
3B ?? // cmp reg, reg (boundary check)
72 ?? // jb (loop back)
}
condition:
uint16(0) == 0x5A4D and
$c2_loop_step
}
Семантические константы: магические числа алгоритмов
Криптографические алгоритмы содержат неизменяемые константы. Это self-evident anti-fragile признаки — убрать их нельзя, потому что без них алгоритм не работает.
rule RC4_KSA_Implementation {
meta:
description = "RC4 Key Scheduling Algorithm - constant loop 0-255"
strings:
// Инициализация S-блока: for(i=0; i<256; i++) S[i] = i
// Паттерн: счётчик до 256 (0x100), инкремент/сохранение
$rc4_sbox_init = {
33 C9 // xor ecx, ecx (i = 0)
88 0C ?? // mov [reg+ecx], cl (S[i] = i)
41 // inc ecx
81 F9 00 01 00 00 // cmp ecx, 100h (256)
72 ?? // jb (loop)
}
condition:
uint16(0) == 0x5A4D and $rc4_sbox_init
}
CRC32 полином (0xEDB88320 — reversed):
rule CRC32_Polynomial_Constant {
meta:
description = "CRC32 polynomial constant - integrity check or hashing"
strings:
$crc32_poly = { 20 83 B8 ED } // 0xEDB88320 little-endian
condition:
uint16(0) == 0x5A4D and $crc32_poly
}
Раздел 8: Написание правил для конкретных семейств
Метаданные правила — стандарт оформления
Правило без метаданных — это потерянное правило. Минимальный обязательный набор:
rule FamilyName_Variant_Component {
meta:
author = "analyst-handle"
description = "Краткое описание что именно детектирует"
date = "YYYY-MM"
version = "1.0"
tlp = "WHITE / GREEN / AMBER / RED"
reference = "URL или внутренний тикет"
families = "через запятую если покрывает несколько"
hash = "sha256 образца-источника (первый из которых написано правило)"
tags = "loader, dropper, stealer — категория"
}
Соглашение по именованию: Семейство_Вариант_Компонент. Например: AgentTesla_v3_Clipboard, Emotet_Loader_C2List, CobaltStrike_Beacon_x64_Staged.
Глубокий пример: QakBot/Qbot
QakBot — долгоживущее семейство с многолетней историей модификаций. Устойчивые признаки: характерный формат конфига (версия, campaign ID, timestamp, C2-лист), специфичный метод injection через SetWindowsHookExA, и характерный анти-анализ через cpuid+rdtsc.
import "pe"
import "math"
rule QakBot_Core_MultiVersion {
meta:
author = "SOC-analyst"
description = "QakBot core module - structural + behavioral fingerprint"
date = "2024-01"
version = "2.0"
tlp = "GREEN"
families = "QakBot, Qbot, Pinkslipbot"
reference = "internal"
strings:
// Campaign ID lookup - QakBot хранит строку вида "BB" или "obama" перед конфигом
// Паттерн доступа к campaign строке в памяти
$campaign_access = {
8D ?? ?? // lea reg, [reg+offset] (адрес campaign string)
50 // push eax
E8 ?? ?? ?? ?? // call (encrypt/hash campaign id)
83 C4 04 // add esp, 4
}
// SetWindowsHookEx injection pattern
$hook_inject = {
6A ?? // push hook type
FF 75 ?? // push [ebp+proc_handle]
FF 75 ?? // push [ebp+hmod]
FF 15 ?? ?? ?? ?? // call SetWindowsHookExA/W
85 C0
74 ?? // jz fail
}
// Anti-analysis: двойной RDTSC + сравнение
$timing_check = {
0F 31 // rdtsc (first)
89 ?? ?? // mov [ebp+var], eax (save)
?? ?? ?? // some instructions
0F 31 // rdtsc (second)
2B C? // sub eax, reg (diff)
3D ?? ?? 00 00 // cmp eax, threshold
}
// Характерный mutex формат QakBot: "{GUID}" в Wide string
$mutex_pattern = /\{[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\}/ wide
condition:
uint16(0) == 0x5A4D and
pe.is_dll() and // QakBot core module — DLL
filesize > 0x10000 and // Минимальный разумный размер
filesize < 0x200000 and
math.entropy(0, filesize) < 7.0 and // Не упакован (или легко распакован)
2 of ($campaign_access, $hook_inject, $timing_check, $mutex_pattern)
}
Пример для .NET семейства: Agent Tesla v3
.NET малварь отличается: её тело — MSIL, а не нативный x86/x64. Opcode-паттерны другие — это IL-байткод. Зато строки часто видны даже без де-обфускации.
import "pe"
import "dotnet"
rule AgentTesla_v3_dotNET {
meta:
description = "AgentTesla v3 - .NET stealer fingerprint"
families = "AgentTesla"
strings:
// Характерные строки обращения к SMTP/Telegram C2
$smtp_header = "MIME-Version: 1.0" ascii wide
$telegram_api = "api.telegram.org" ascii wide
// Специфичный формат отчёта AgentTesla
$report_header = "---------- PC NAME" ascii wide
$os_info = "OS Full Name" ascii wide
// Keylogger callback names (стабильны между версиями)
$kl_func = "KeyboardHook" ascii wide
condition:
uint16(0) == 0x5A4D and
// Это .NET сборка
pe.data_directories[pe.DIRECTORY_ENTRY_COM_DESCRIPTOR].size > 0 and
(
($smtp_header and $report_header) or
($telegram_api and $kl_func) or
($os_info and $kl_func and $report_header)
)
}
Раздел 9: Верификация и тестирование правил
Pipeline проверки перед релизом правила
Написать правило — половина работы. Вторая половина — убедиться что оно работает как задумано.
Шаг 1: True Positive rate. Прогнать по набору известных образцов семейства. Минимум 10 разных образцов из разных кампаний. Целевой показатель: срабатывание на 80%+ образцов. Если меньше — признаки слишком специфичны.
yara -r rule.yara /path/to/samples/ 2>/dev/null | grep "rule_name" | wc -l
Шаг 2: False Positive rate. Прогнать по чистому набору — системные DLL, стандартные утилиты, популярный open-source код. Ноль ложных срабатываний — обязательное условие для production.
yara -r rule.yara /path/to/clean_samples/ 2>/dev/null # Вывод должен быть пустым
Шаг 3: Performance. Правило с плохим паттерном (например, однобайтовая строка) будет тормозить сканирование. Тест:
time yara rule.yara /path/to/large_corpus/ # Если > 1 секунды на 1000 файлов — смотреть на паттерны
Частые причины ложных срабатываний
Слишком короткие строки: строка длиной меньше 4 байт будет находиться в случайных файлах. Минимум для hex-паттерна — 6-8 байт с редкими значениями.
Опкоды без контекста: 75 ?? (JNZ) встречается в любом файле. Всегда оборачивать в более длинный паттерн.
Общие API-имена: VirtualAlloc в строках импортирует почти каждый упаковщик, включая UPX-упакованные легитимные файлы. Использовать только в комбинации с другими признаками.
Энтропия как единственный признак: правило только на высокую энтропию даст 50%+ ложных срабатываний на любые зашифрованные файлы данных.
Инструменты для тестирования
YARA сам по себе: yara -r rule.yara directory/ — рекурсивный обход.
yarGen: генерирует строковые признаки из образца, автоматически фильтруя признаки встречающиеся в чистых файлах.
BASS (Bulk YARA Signature Scanner): тестирование на больших корпусах.
VirusTotal Livehunt: если правило публичное, можно проверить в условиях реального потока. Но: никогда не загружать non-public правила в VT.
CAPE sandbox: автоматически создаёт YARA-правила из динамического анализа. Использовать как отправную точку, не как финальный результат.
Финальный вывод
Иерархия устойчивости признаков
От наименее к наиболее устойчивым:
C2-адреса и домены → Строки сообщений об ошибках → Имена мьютексов → Имена файлов и пути → Строки конфигурации → Структура PE и компоновка секций → Опкодовые паттерны алгоритмов → Криптографические константы → Rich Header → Архитектура декриптора.
Последние три категории — основа anti-fragile сигнатуры.
Чек-лист перед релизом правила
Прежде чем правило уходит в production:
Метаданные заполнены полностью, включая хэш образца-источника. Правило прогнано на минимум 10 образцах семейства — True Positive проверен. Правило прогнано на чистом корпусе (системные файлы, популярные приложения) — False Positive = 0. Признаки не включают volatile-данные (C2, IP, домены). Логика условия использует X of (Y) для устойчивости к модификациям. Каждый строковый паттерн длиннее 6 байт или имеет достаточно редкие значения. Правило протестировано на performance — время сканирования приемлемо.
Ключевые принципы
Семья, не образец — сигнатура должна описывать архитектурные константы семейства, а не байты конкретной сборки.
Глубина признаков — комбинировать признаки разных классов: структурные, поведенческие, криптографические. Один признак можно убрать, три независимых — нельзя.
Маскировать volatile — адреса функций в IAT, относительные смещения прыжков, конкретные адреса — всё это ?? или [N-M].
Rich Header как корреляция — не как единственный признак, но как дополнительный слой для кластеризации образцов одной сборочной системы.
Энтропия дополняет, не заменяет — математические условия через math.entropy() сужают пространство до "скорее всего зашифрован", но не идентифицируют семейство.
Криптоконстанты неизменяемы — магические числа RC4, CRC32, AES S-box, ChaCha20 — это self-evident признаки, которые авторы малвари не могут убрать без потери функциональности.