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:
- Precise: it does not fire on legitimate software.
- Generic enough: it catches the family, not only the one sample you just analyzed.
- Fast: it does not slow down scans, which matters when the rule runs across a whole infrastructure or over a sample repository.
- Maintainable: someone else (or you a year from now) can tell why every string and condition is there.
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:
- PDB paths and build directories;
- Mutex, event, and named pipe names, as well as GUIDs;
- C2 URL formats, custom user agents, and protocol markers;
- Custom error and status messages, command names, and typos;
- Registry keys, dropped file names, and configuration markers.
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:
- Strings from statically linked libraries and runtimes (OpenSSL, the C runtime, Go or Delphi internals). They are in thousands of benign files.
- Common imports as the only evidence.
- Random-looking fragments from compressed or encrypted data (they change with every build, so the rule catches one sample and nothing else).
- Strings that only exist at runtime.
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:
- Use
fullwordfor short strings. Without it,"hostname"also matches inside"gethostname", which is a common Winsock import. widemeans UTF-16LE with ASCII characters (each byte followed by00), not "Unicode" in general. Strings in PE resources and version info are usually wide.nocasemultiplies the variants YARA has to look for. Only use it when the case actually varies.xorreplaces the old trick of writing 255 strings for a single-byte XOR key.xor(0x01-0xff)skips key zero, which is the plaintext itself.base64requires at least three characters, and the characters at the edges of the encoded string are inherently fuzzy, so prefer longer strings.
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:
- imphash is great for native code and useless for .NET. Most .NET executables import only
mscoree.dll!_CorExeMain, so they share the imphashf34d5f2d4577ed6d9ceec516c1f5a744(dae02f32a21e03ce65412f6e56942daafor DLLs, which import_CorDllMain). - For .NET, use the
dotnetmodule instead. The TypeLib GUID generated by Visual Studio for each project is a great pivot, since it often stays the same across builds:
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"
}
- Timestamps are less reliable than they used to be. "An x64 binary compiled before x64 existed" sounds like a perfect anomaly, but since Windows 10, many Microsoft binaries are built deterministically and their timestamp field holds a hash instead of a date. Some of those "dates" land in the past, others in the future. Test such rules against current Windows builds before trusting them.
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:
- Give every pattern at least four distinctive fixed bytes.
- Anchor regular expressions on a fixed string and use bounded repetition (
.{1,64}instead of.*). Use a regex only when a plain or hex string cannot do the job. - Use
nocaseandascii wideonly when needed, since both multiply the atoms. - Only loop over module data (sections, signatures, resources), never over
filesize. - Put expensive functions at the end of the condition.
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:
- Test against more positives: check that it matches samples of the family that you did not use to write it. If you started with two samples, look for a third.
- Test against goodware: build a clean set: several Windows versions (x86 and x64), a couple of Linux distributions, macOS, common installers, and the software your organization actually runs. Every false positive you find here is one your SOC will not have to triage :).
- Test at scale: run the rule over a large corpus before deploying it: a VirusTotal Retrohunt, or your own repository.
- Treat rules as code: keep them in version control, run syntax and format checks, and bump
versionormodifiedon every change.
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
- File format check first, followed by a sensible
filesizelimit; - Unique artefacts, groups of weaker strings, and structural features, combined with
or; fullwordon short strings, and the right encoding (ascii,wide, or both);- At least four distinctive fixed bytes per pattern, with bounded jumps and regular expressions;
- Expensive functions at the end of the condition, and loops only over module data;
- Metadata with author, description, date, reference, and sample hashes;
- Tested against goodware and against samples not used to write the rule;
- Compiles on YARA and YARA-X without warnings.
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!