Writing good YARA rules

YARA is the common language for malware detection. Incident responders scan disk and RAM images with it, threat hunters run it over VirusTotal (using Livehunt or Retrohunt), and security vendors ship it inside their products. Writing a rule that compiles is easy, but writing one that catches a whole malware family, has a low false positive rate, and does not slow down a scan over millions of files is a different story. This post collects the practices I rely on when writing rules.

What makes a rule good?

A good rule is:

Anatomy of a rule

The running example in this post is Ymir, a ransomware I analyzed in 2024:

import "pe"

rule crime_ZZ_Ymir_ransomware {
    meta:
        author = "Cristian Souza"
        description = "Detects the Ymir ransomware family"
        date = "2026-10-05"
        version = "1.0"
        reference = "https://securelist.com/new-ymir-ransomware-found-in-colombia/114493/"
        hash1 = "cb88edd192d49db12f444f764c3bdc287703666167a4ca8d533d51f86ba428d8"
        tlp = "CLEAR"

    strings:
        // Unique artefacts: any one of them is enough
        $x1 = "6C5oy2dVr6" fullword ascii wide          // extension appended to encrypted files
        $x2 = "D:20240831154833-06" ascii               // creation date of the embedded ransom note PDF

        // Weaker on their own, meaningful together
        $s1 = "INCIDENT_REPORT.pdf" ascii wide
        $s2 = "powershell -w h -c Start-Sleep -Seconds 5; Remove-Item -Force -Path" ascii
        $s3 = "setup-qtox-x86_64-release.exe" ascii
        $s4 = "x64dbg" fullword ascii
        $s5 = "ChaCha" ascii

    condition:
        uint16(0) == 0x5A4D and
        filesize < 5MB and
        (1 of ($x*) or 3 of ($s*)) and
        pe.imports("msvcrt.dll", "memmove")
}

Naming: I use <type>_<origin>_<actor>_<family or trait>: the type (apt, crime, exploit, susp, hunt), a two-letter country or language designator for APTs (ZZ when unknown), the actor, and the family or specific feature. Consistent names let you triage matches at a glance and slice rule sets by prefix. The YARA Style Guide proposes a similar generic-to-specific scheme (e.g., MAL_, HKTL_, SUSP_). Which scheme you pick matters less than sticking to it.

Metadata: at minimum, author, description, date, version (or modified), reference, and the hashes of the samples the rule was built from. The hashes are what allow anyone, including you, to reproduce and debug a match later. If you share rules, add a TLP field.

String names: prefixes that encode confidence ($x for unique artefacts, $s for weaker strings, $fp for false-positive filters) keep the condition readable: 1 of ($x*) or 3 of ($s*) says exactly what the rule believes.

Picking strings

Good rules start with good strings. The best candidates usually require no reverse engineering at all. These include:

One level deeper, with some reverse engineering: encryption keys and constants, string decryption routines, API hashing values, and custom data structures. These tend to survive longer than plain strings, since changing them means changing code.

Things to avoid:

In practice, run strings -n 6 and strings -e l (for UTF-16LE) over your samples, diff the output against clean files, and check how common each candidate is before trusting it. I also recommend using strings -tx to show the offsets for each identified string. Then choose the right modifiers:

$a = "INCIDENT_REPORT.pdf"          // default: ASCII, case-sensitive
$b = "x64dbg" fullword              // must be delimited by non-alphanumeric chars
$c = "6C5oy2dVr6" wide              // UTF-16LE ("6\x00C\x005\x00...")
$d = "6C5oy2dVr6" ascii wide        // both encodings
$e = "cmd.exe /c" nocase            // case-insensitive (slower)
$f = "cdn-update.example.com" xor(0x01-0xff)   // every single-byte XOR key
$g = "Invoke-Expression" base64 base64wide     // base64-encoded occurrences

A few notes on these:

Hex patterns from code

When the most distinctive part of a sample is its code, turn the disassembly into a hex pattern. Take this string decryption loop:

8A 04 0E     mov  al, [esi+ecx]      ; load encrypted byte
34 5C        xor  al, 5Ch            ; family-specific key
2C 11        sub  al, 11h            ; family-specific constant
88 04 0F     mov  [edi+ecx], al      ; store decrypted byte
41           inc  ecx
3B CA        cmp  ecx, edx
72 F1        jb   short loop_start

The goal is to keep the opcodes and the family-specific constants while masking what the compiler is free to change: registers, addresses, and offsets. Here are two ways to do it:

// Over-wildcarded: no fixed run longer than 2 bytes
$decode_bad  = { 8A 04 ?? 34 ?? 2C ?? 88 04 ?? 4? 3B ?? 72 ?? }

// Balanced: registers masked, family-specific constants kept
$decode_good = { 8A 04 ?? 34 5C 2C 11 88 04 ?? [1-4] 3B ?? 72 ?? }

Both compile and both match the sample, but the first one wildcards everything that could change, so its longest fixed run is two bytes. YARA then has no good anchor to search for and has to verify a huge number of candidate positions. The second one keeps 34 5C 2C 11 88 04 as a six-byte anchor, and the [1-4] jump absorbs inc ecx in 32-bit builds (41) as well as inc ecx (FF C1) or inc rcx (48 FF C1) in 64-bit builds, with one byte to spare for the REX prefix of a following cmp rcx, rdx (48 3B CA). As a rule of thumb, every hex pattern should contain at least one run of four or more distinctive bytes, should not start with wildcards, and should only use bounded jumps.

Writing conditions

Start every condition with a cheap check of the file format. Reading an integer at offset zero costs almost nothing and skips the rest of the condition right away for most files:

uint16(0) == 0x5A4D                   // PE (MZ)
uint32(0) == 0x464C457F               // ELF
uint32(0) == 0xFEEDFACF               // Mach-O 64-bit (little-endian)
uint32be(0) == 0xD0CF11E0             // OLE2 (legacy .doc/.xls/.msi)
uint32(0) == 0x04034B50               // ZIP (OOXML, JAR, APK)

Prefer these integer reads over $mz at 0: in classic YARA, a string is one more pattern to search for in every file, while uint16(0) is a single comparison. Pair the format check with a filesize limit that makes sense for the family, which excludes large files where your strings could show up by accident.

Next, combine independent groups of evidence: unique artefacts, a set of weaker strings that only matter together, and structural features such as section names or timestamps. When the actor changes one of them in the next version, the other groups still fire. The 1 of ($x*) or 3 of ($s*) pattern above is the simplest form of this.

Finally, use counts, offsets, and ranges when the position of a string carries information:

condition:
    uint16(0) == 0x5A4D and
    filesize < 2MB and
    $cfg_marker and
    $rc4_key in (@cfg_marker .. @cfg_marker + 128) and   // key close to the marker
    #url_fmt >= 3                                        // format string used at least 3 times

Order matters too, although not in the way many people expect. In classic YARA, all strings are searched before the condition is evaluated, so reordering $a and $b does not speed up the search itself. What ordering does control is whether expensive operations run at all: since and short-circuits, putting math.entropy(), hashes, and loops at the end means they only run on files that already passed the cheap checks.

Beyond strings: modules

Modules expose parsed file structures to your conditions. The pe module is the one I use most (imports, imphash, exports, sections, version info, signatures, timestamps), followed by elf, dotnet, math, and hash. Modules are especially good at catching things that legitimate software rarely does. For example, malware often copies the version information of Microsoft binaries to blend in:

import "pe"

rule susp_PE_fake_Microsoft_versioninfo {
    meta:
        author = "Cristian Souza"
        description = "PE claiming Microsoft as CompanyName while signed by someone else"

    condition:
        uint16(0) == 0x5A4D and
        filesize < 5MB and
        pe.version_info["CompanyName"] contains "Microsoft" and
        for any sig in pe.signatures : (
            not (sig.issuer contains "Microsoft")
        )
}

Microsoft binaries are either signed by Microsoft or catalog-signed (with no embedded signature at all), so an embedded signature from someone else next to a Microsoft CompanyName is an anomaly worth looking at. The same logic applies to stolen certificates: matching only the serial number of a stolen certificate will flag every legitimate file the real owner signed with it. Combine the serial with something the legitimate files never have, such as version info that does not match the vendor, or malware-specific strings.

Entropy is another good structural signal. Legitimate UPX-packed files keep their first section (UPX0) empty on disk, so a UPX0 section full of high-entropy data is a payload wearing a UPX costume:

import "pe"
import "math"

rule susp_PE_UPX_section_names_encrypted_payload {
    meta:
        author = "Cristian Souza"
        description = "PE using UPX section names while UPX0 carries high-entropy raw data"

    condition:
        uint16(0) == 0x5A4D and
        filesize < 2MB and
        pe.sections[0].name == "UPX0" and
        pe.sections[1].name == "UPX1" and
        pe.sections[0].raw_data_size > 1024 and
        math.entropy(pe.sections[0].raw_data_offset,
                     pe.sections[0].raw_data_size) > 7.2
}

Some module features come with traps:

import "dotnet"

rule hunt_dotnet_builder_typelib {
    meta:
        author = "Cristian Souza"
        description = "Pivots on the TypeLib GUID of a .NET builder (placeholder GUID, replace with the real one)"

    condition:
        uint16(0) == 0x5A4D and
        filesize < 3MB and
        dotnet.typelib == "0f3c6d2e-9a41-4b7c-8e25-6a1d0b7c3f94"
}

Performance

To understand YARA's performance, you need to know about atoms. For every string, YARA picks a short substring of up to four bytes (the atom), searches all atoms at once using the Aho-Corasick algorithm, and only verifies the full string where an atom hits. Rare atoms mean few verifications and a fast scan. Common atoms (00 00 00 00, FF FF FF FF, CC CC CC CC, a single letter) mean the engine stops at every other offset of every file. Some examples:

// Slow: unbounded repetition, nothing bounds what the engine has to verify
$re_bad  = /https?:\/\/.*\/gate\.php/

// Better: long fixed anchor, bounded repetition
$re_good = /\/gate\.php\?id=[0-9a-f]{16}/

// Slow: nothing to anchor on
$hex_bad  = { 00 00 00 00 [4-20] ?? ?? 00 00 }

// Better: at least 4 distinctive bytes
$hex_good = { 43 46 47 31 01 00 [4] 00 00 }   // "CFG1" config header (illustrative)

The practical guidelines:

To measure, Panopticon benchmarks individual rules, yaraQA points out common quality and performance issues, and a yr binary built with the rules-profiling feature supports yr scan --profiling to show which rules take the longest. The YARA Performance Guidelines go into much more detail.

Testing your rules

A rule is not done when it matches the samples it was written from. Before deploying it:

The command I use most:

# print matching strings, recurse, 8 threads
yara -s -r -p 8 ymir.yar /cases/samples/

Rule generators such as yarGen are useful when you have a large set of samples: they extract strings, drop the ones that appear in goodware, and produce a draft. Treat that output as a draft, though. Remove the garbage strings, group the rest by confidence, and add the structural checks a generator cannot infer. Comparing the generated rule with your hand-written one over the same corpus is a good exercise and can also help you improve your rules.

Hunting with YARA

Detection rules and hunting rules have different jobs. A detection rule should almost never be wrong. A hunting rule can be looser, since a human reviews what it finds, and its value comes from surfacing samples nobody has seen yet. On VirusTotal, the vt module lets hunting rules filter on metadata as well as content:

import "vt"

rule hunt_Ymir_fresh_samples {
    strings:
        $x1 = "6C5oy2dVr6" fullword ascii wide

    condition:
        uint16(0) == 0x5A4D and
        $x1 and
        vt.metadata.new_file and
        vt.metadata.analysis_stats.malicious < 5
}

This rule only reports samples that are new to VirusTotal and that most engines still miss. Since vt.metadata.new_file is only true while a file is being submitted for the first time, this rule is meant for Livehunt; in a Retrohunt it never matches. The vt module only works in Livehunt and Retrohunt; a local YARA will refuse to compile it.

YARA or YARA-X?

YARA-X is a rewrite of YARA in Rust. VirusTotal's stated goal is 99% compatibility at the rule level, and in practice most existing rules work unchanged. Most of the differences are about stricter correctness.

My recommendation: write rules that compile on both engines, run yr check over your rule sets, and move your scanning to YARA-X. Its stricter checks will likely find a few bugs you did not know you had.

A quick checklist

Conclusion

Good YARA rules come from understanding what is truly specific to the threat, and verifying that assumption against real data. Start from unique artefacts, back them up with structure, keep an eye on atoms, and test before you deploy. Happy hunting!