secskills
secskills / defense / writing-yara-rules

writing-yara-rules

defense verified 2026-07-26

Author durable YARA detection rules — meta/strings/condition anatomy, string types and modifiers, structural conditions with file magic and offsets, the PE/ELF/math/hash modules, specificity-vs-durability tuning, atom-aware performance, memory and process scanning, and YARA-X. Use when writing a YARA rule, creating a signature for a malware family or a file/memory artifact, turning IOCs or a captured sample into a detection, or hunting for a family across a corpus with Retrohunt or an on-host scanner.

$ /plugin install secskills-defense $ /plugin install secskills-core

A good YARA rule matches the malware's nature — its code, its structure, its unavoidable constants — not its costume, which is a filename or a mutable string that changes on the next build. Match the nature and the rule survives recompilation and catches the whole family; match the costume and you catch one sample once. The entire craft is choosing strings and a condition that are both specific enough to avoid false positives and durable enough to generalize.

When to Use

When NOT to Use

use writing-sigma-rules

syntax** — use engineering-detections

use analyzing-malware first

producing-threat-intelligence

Rule Anatomy

Three sections: meta (documentation, never matched on), strings (the patterns), condition (the boolean that decides a hit).

import "pe"

rule Family_Loader_v1
{
    meta:
        author      = "analyst"
        date        = "2026-07-26"
        description = "ExampleLoader stage-1, config-decode stub + family constants"
        hash        = "a1b2c3...<sha256 of the analyzed sample>"
        reference   = "https://internal/case/1234"
        version     = "1"
        tlp         = "amber"
    strings:
        $decode = { 8A 04 0? 32 0? 88 0? 4? 3B ?? 72 }
        $marker = "cfg::begin" ascii
        $mutex  = "Global\\ExL-%08x" ascii
    condition:
        uint16(0) == 0x5A4D and filesize < 2MB and 2 of them
}

Meta fields that matter. author and date for provenance; hash records the exact sample the rule was derived from so a future analyst can re-verify; reference links the case or report that justifies it; description states what and why. Meta is free-form text — YARA never evaluates it — so treat it as the rule's changelog. Add version/tlp/score fields your tooling consumes.

Strings and Modifiers

Text strings are the default. Hex strings match raw bytes and support wildcards and jumps. Regex is written between slashes.

strings:
    $text  = "Mozilla/5.0 (Custom-UA)" ascii wide
    // Hex: ?? = any byte, 0? = any low nibble, [4-6] = 4 to 6 arbitrary bytes
    $hex   = { 55 8B EC 83 EC ?? 53 56 57 [4-6] E8 ?? ?? ?? ?? }
    $re    = /\/gate\/[a-f0-9]{16}\.php/ nocase

Modifiers change how a string matches. Chain those that combine.

ModifierEffect
nocaseCase-insensitive text/regex match
wideMatch UTF-16LE (two bytes per char) — most Windows strings
asciiMatch single-byte form; pair with wide to match both
fullwordMatch only when bounded by non-alphanumeric bytes
xorMatch all 256 single-byte XOR-encoded variants (keys 0x00-0xFF)
xor(0x01-0xff)XOR over a bounded key range
base64 / base64wideMatch the three base64-encoded alignments
privateMatch is never shown in output (-s/callback); still counts in of them

xor finds strings hidden behind a one-byte key without knowing the key; base64 catches config values embedded in encoded blobs. Both cost scan time — use them when you have evidence the encoding is present, not by default.

Building Durable Conditions

The condition is where specificity lives. Beyond all of them / N of them:

condition:
    // Anchor on file type before touching strings — cheap and decisive
    uint16(0) == 0x5A4D and              // MZ (PE)
    uint32(0) == 0x464C457F and          // 0x7F 'E' 'L' 'F' (ELF)

    // Offset constraints
    $magic at 0 and                      // string must sit at offset 0
    $tag in (0..1024) and                // within the first 1 KB
    #beacon >= 3 and                     // the $beacon string appears 3+ times

    // Bound the search — a 40-byte dropper is not a 50 MB installer
    filesize < 500KB and

    // Quorum across a candidate set, resilient to any one string changing
    3 of ($cfg*) and

    // for..of: k of a set, with a per-match predicate
    for any i in (1..#url) : ( @url[i] < 0x2000 )

#string is the match count, @string[i] the offset of the i-th match, !string[i] its length. for..of expresses "any/all/N of these strings, each satisfying a condition" — the tool for rules like "an RWX marker within 100 bytes of a known stub." Anchoring on uint* magic first lets the engine skip non-matching files before scanning strings, which is both correctness and speed.

Specificity vs. Durability

Every rule sits on a line between too-broad (false positives) and too-narrow (catches one build). Aim for a cluster of family-specific constants plus one structural anchor.

Bad — a single common string, matches half of goodware:

rule Bad_TooBroad {
    strings:
        $ = "GetProcAddress"
    condition:
        $
}

Bad — a filename, the definition of a costume; the family renames it per campaign:

rule Bad_Filename {
    strings:
        $ = "svch0st_updater.exe" nocase
    condition:
        $
}

Good — several constants the author cannot casually change, gated by file type and size, requiring a quorum so one mutated string does not break the rule:

rule Good_FamilyCluster {
    meta:
        description = "ExampleRAT: config key schedule + mutex fmt + UA"
    strings:
        $keysched = { 66 0F 6F 05 ?? ?? ?? ?? 66 0F EF C8 66 0F 7F 4? }
        $mutexfmt = "ExR-{%04x-%04x}" ascii
        $ua       = "X-ExR-Build:" ascii wide
        $err      = "cfg decrypt failed @stage%d" ascii
    condition:
        uint16(0) == 0x5A4D and filesize < 3MB and 3 of them
}

The good rule generalizes because its anchors are things the malware needs — its own key schedule, its own mutex format, its own diagnostic strings — not things an operator flips between deployments.

Using the Modules

Modules expose parsed structure. import at the top of the file.

PE — the highest-value module for Windows samples:

import "pe"
rule PE_Structural {
    condition:
        pe.is_pe and
        pe.imphash() == "d2a6...<import hash>" and       // API import fingerprint
        pe.number_of_sections == 5 and
        pe.imports("wininet.dll", "InternetOpenA") and
        pe.exports("PluginInit") and
        for any s in pe.sections : ( s.name == ".xdata" and s.raw_data_size == 0 ) and
        pe.rich_signature.length > 0 and                 // Rich header (compiler fingerprint)
        pe.timestamp > 1577836800
}

imphash clusters samples built from the same import table; the Rich header fingerprints the build toolchain and is hard to forge; pe.imports/pe.exports anchor on capability rather than incidental bytes.

ELF mirrors PE for Linux (elf.number_of_sections, elf.type, elf.symtab). math scores regions — flag packed or encrypted blobs:

import "math"
rule Packed_HighEntropy {
    condition:
        uint16(0) == 0x5A4D and
        math.entropy(0x1000, filesize - 0x1000) >= 7.2 and   // near-random tail
        math.mean(0, filesize) > 100
}

hash computes digests inside the condition — useful to pin a specific embedded resource, not the whole file (a whole-file hash needs no YARA):

import "hash"
rule EmbeddedResource {
    condition:
        hash.sha256(0x400, 0x200) == "e3b0c4...<digest of the fixed config blob>"
}

Targeting Position-Independent Things

Anchor on what moves with the code, not with the campaign. In rough order of durability: crypto constants (S-boxes, MD5/SHA init values, custom key schedules) → the family's own mutex/pipe/registry name templates → config markers and delimiters → C2 URI templates (/gate/, /panel/upload.php) → unique error and debug strings the developer left in → stack strings once you have deobfuscated them with floss. A filename, a compile timestamp, a campaign C2 IP, and a single common Win32 API name are not durable — they are the costume.

Killing False Positives

A rule is not done when it hits the sample; it is done when it does not hit goodware. Three levers, applied together:

string can appear in benign software; three family constants together will not.

ELF magic so the rule never even scans an unrelated file type.

the bound removes whole categories of coincidental matches for free.

Testing and Validation

Validation is not optional. An untested rule is a future false-positive storm.

yara -w rule.yar sample                 # -w suppresses warnings; does it fire?
yara -s -w rule.yar sample              # -s prints which strings matched, and where
yara -w -r rule.yar ./samples/family/   # -r recurse: must hit every known-true sample
yara -w -r rule.yar ./corpus/goodware/  # must produce ZERO hits — the load-bearing step
yara -C rules.yarc ...                  # compile once, scan many with the compiled form

Atoms and why 4+ byte anchors matter. YARA scans by extracting short fixed-byte substrings — atoms — from each string and searching for those first; a match on an atom triggers full verification of the string. A string whose only fixed run is 1-2 bytes (an over-wildcarded hex pattern, a two-letter text string) yields a weak atom that matches constantly, forcing verification on nearly every file and collapsing performance. Give every string at least one run of 4+ contiguous non-wildcard bytes. YARA warns on slow strings and short atoms at compile time — drop -w (which suppresses them) and treat those warnings as errors. Avoid unbounded and catastrophic regex (.*, nested quantifiers); prefer bounded classes and jumps. Profile with yara -S (--print-stats, per-rule statistics) when a ruleset drags.

Memory and Process Scanning

File rules and memory rules are not interchangeable. A rule anchored on uint16(0) == 0x5A4D and PE sections will not fire on a process where the payload is unpacked, relocated, and has no on-disk header.

yara -w rule.yar mem.dmp                # scan a memory dump / minidump
yara -w rule.yar 4242                   # scan a live process — the PID is the final argument
yara -w -s rule.yar 4242                # -s prints which strings matched in the process, and where

For memory rules, drop the file-magic anchor and the filesize bound (a process has no meaningful filesize); anchor instead on the decoded strings and code that only exist after unpacking — the plaintext config, the C2 template, the stack strings assembled at runtime. Keep file-scan and memory-scan rules separate, or gate the file-only conditions so the same rule can serve both.

YARA-X: the Modern Engine

YARA-X is the Rust rewrite and the direction of travel: faster, safer, better diagnostics, and largely source-compatible. Author new rules to run under both where practical. Differences to know:

few deprecated constructs are gone; include and module semantics tightened.

language server ship in-box.

relying on an exotic module.

Test the same rule under both engines when your deployment fleet is mixed (LOKI/THOR and older scanners run classic YARA; newer pipelines run YARA-X).

Organizing and Sharing Rules

helpers; compile a directory into a single .yarc for distribution.

public rules as a reusable predicate (a shared "is a Windows PE" gate, a shared packer detector). Keeps conditions DRY and readable.

yara --tag loader. Tag by role and family so hunts can select subsets.

ext in conditions to make one rule behave differently per environment (e.g. a filename/filepath passed by the scanning host).

fields let downstream tooling (THOR, VT, your CI) sort, score, and attribute.

private rule is_pe {
    condition: uint16(0) == 0x5A4D and filesize < 10MB
}

rule Family_X_Loader : loader family_x {
    meta:
        author = "analyst"
        score  = 75
    strings:
        $a = "ExL-%08x" ascii
        $b = { 8A 04 0? 32 0? 88 0? 4? 3B ?? 72 }
    condition:
        is_pe and all of them
}

Rationalizations to Reject

nothing else. The next build has a different hash. That is an IOC, not a detection.

is an unexploded false-positive storm. Scanning the sample proves nothing about what else it hits.

and common strings appear everywhere. Require a quorum of family constants.

4-byte atom, so the rule matches on a weak anchor and destroys scan performance. Keep real byte runs.

per campaign at zero cost to the operator. Anchor on code and structure.

anchors do not exist in an unpacked process image. Memory needs its own rule.

and buries real matches. Tune before you deploy, not after.

References

point, never a finished rule)