June 16

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 инструкций. Компилятор может оптимизировать его по-разному, но базовая структура остаётся.

Классический x86 XOR loop:

; 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

YARA-паттерн:

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 признаки — убрать их нельзя, потому что без них алгоритм не работает.

RC4 (KSA инициализация):

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 признаки, которые авторы малвари не могут убрать без потери функциональности.