In this section

0.1 Your First Scan, and What It Does Not Tell You

Module 0

YARA is the tool that turns one sample into coverage. You have a file, and the question that follows is never "what is this" but "what else out there is the same thing", across a disk image, a fleet of endpoints, or a directory of quarantined attachments. A rule answers that, and writing one that answers it correctly is a skill with a great deal more to it than the syntax suggests. This course builds that skill across seven modules: writing rules and verifying them, running scans and reading what comes back, choosing what to match, using the modules that expose a file's structure, handling documents and scripts, testing and tuning, and deploying into hunts. This first section installs the tool and runs a rule, and spends most of its length on one thing the tool does that surprises everybody: when it finds nothing, it says nothing at all.

Scenario

An advisory arrives naming a loader. You extract three indicators, write a rule, and run it across the collected evidence from forty hosts. It returns nothing. You report that the estate is clean, and six weeks later the same loader is found on one of the forty. The rule was never broken and the scan never failed. It also never reached most of those hosts, because a path was wrong and the tool has no way to tell you that: a scan that matches nothing and a scan that examined nothing produce identical output, which is no output at all.

YARA-X is the current engine. Version 1.0.0 shipped in June 2025 and the original YARA entered maintenance mode the same day, taking bug fixes and no new features, with 4.5.5 in October 2025 the last of that line. The two are not interchangeable, which matters more than a version note suggests and is why Module 7 exists: rule-level compatibility is around ninety-nine percent, and the scanners most organizations actually run still embed the older engine. You will author on one and deploy to another. This section installs both, because the difference starts mattering long before Module 7.

Installing the tool

The YARA-X binary is yr, not yara, and the rename is deliberate: the two coexist on one machine without either shadowing the other, which is exactly what you want when your authoring engine and your scanning engine differ. It ships as a release archive rather than a package, so installation is a download and a move.

curl -sL -o yr.tar.gz \
  https://github.com/VirusTotal/yara-x/releases/download/v1.19.0/yara-x-v1.19.0-x86_64-unknown-linux-gnu.tar.gz
tar xzf yr.tar.gz && mkdir -p ~/.local/bin && mv yr ~/.local/bin/
yr --version
yara-x-cli 1.19.0

The archive holds a single statically linked binary with no dependencies. It runs from a USB stick, on a forensic workstation with no package manager, or on a machine you are not allowed to install software on, and that portability is why YARA turns up in incident response as often as it does. The version string says yara-x-cli rather than yara-x because the command-line tool and the library are versioned together and the binary reports its own name, which matters when somebody asks which engine produced a result and the honest answer needs to distinguish the two.

You also want the older engine available, and the simplest route is the Python bindings rather than building the C library. pip install yara-python yara-x gives you 4.5.4 and YARA-X both callable from a script, which is what Module 7 uses to check a rule against both engines before it leaves your machine. Install it now. Discovering at deployment time that a rule you have refined for a week does not compile on the scanner that has to run it is a bad afternoon, and it is entirely avoidable.

Running a rule

A rule is a name, some patterns, and a condition deciding what those patterns have to do for the rule to match. That is the whole shape, and Module 1 takes each part apart properly.

rule First_Rule
{
    strings:
        $mutex = "Global\\{8f2c41d9"
    condition:
        $mutex
}

Running it takes the rules first and the target second, and the output is one line per match giving the rule name and the path.

yr scan first.yar fam/
First_Rule fam/fam2
First_Rule fam/fam3
First_Rule fam/fam1

Read what is not in that output, because it is the more instructive half. There is no count of files examined, no summary line, no indication of how long the scan took, and no statement that the scan completed. Three files matched and you cannot tell from this whether three files were examined or three hundred. The order is not alphabetical either, which is your first hint about how the tool works: directory scanning is threaded, results arrive as workers finish, and anything you do downstream of a scan should sort rather than assume.

The argument order deserves one warning because the failure is genuinely confusing. yr scan accepts several rule files and exactly one target, so passing three files to scan does not scan three files. The second and third are read as additional rule files, the compiler tries to parse a binary as YARA source, and you get error[E032]: invalid UTF-8 with a caret pointing at a control character. Nothing in that message mentions arguments. To scan several files, give it a directory.

Why silence is the problem

Now the thing this section exists for. Run a rule that matches nothing:

yr scan nothing.yar fam/
echo "exit code: $?"
exit code: 0

Nothing printed and a clean exit. That is correct behavior, and the first thing to establish is what the exit code is worth here, because a great deal of automation leans on it.

yr scan first.yar fam/          >/dev/null 2>&1; echo "match:      $?"
yr scan nothing.yar fam/        >/dev/null 2>&1; echo "no match:   $?"
yr scan first.yar /nonexistent/ >/dev/null 2>&1; echo "bad target: $?"
match:      0
no match:   0
bad target: 0

All three are zero. A successful hunt, an empty hunt, and a hunt pointed at a path that does not exist are indistinguishable to anything checking the exit status, which rules out the check most people reach for first.

There is one thing that separates the third case, and it is easy to lose. A bad path does produce a message, on standard error rather than standard output:

yr scan first.yar /nonexistent/
error: can't open `/nonexistent/`: No such file or directory (os error 2)

Interactively you would see that. In a script it is a different matter, because the common idioms discard it: a pipeline into wc -l counts stdout only, and 2>/dev/null is written reflexively. The error is there and the shape of most automation loses it.

So the honest picture is a scale rather than a single trap. A mistyped path is recoverable if you are reading stderr and invisible if you are not. And two situations remain genuinely indistinguishable no matter what you check: a scan of a directory that was read and contained nothing matching, and a scan of a directory that was read and contained nothing at all, produce identical output, identical exit codes and identical silence on stderr. Nothing in the tool separates those, and only knowing what should have been in the directory does.

This is the failure in the scenario at the top of this section, and it is worth being precise about why it is so hard to catch. Every other tool failure announces itself: a rule with a syntax error will not compile, a missing file produces an error, a permission problem produces an error. This one produces success. There is no degraded state to notice, no warning to read, and no difference at all between the output of a scan that worked and found nothing and the output of a scan that never happened. Most of the time the estate genuinely is clean, so the habit of trusting an empty result is reinforced constantly and fails rarely, which is the worst possible reinforcement schedule for a habit you need to break.

The consequence for how you report is direct. "The hunt returned no results" and "the estate is clean" are different statements, and only one of them is supported by an empty scan. The first is a fact about a command you ran. The second is a claim about forty machines, and getting from one to the other requires evidence the scan did not give you.

Four situations, one output path was wrong error on stderr, exit 0 directory was empty nothing to examine rule describes nothing compiles, cannot fire estate genuinely clean every file examined yr scan no output, exit 0 "the estate is clean" supported by only one of the four yr scan -n separates them lists every file examined, so silence becomes a count

The top two produce no output because nothing was read. The bottom two produce no output because nothing matched. Only the negation tells them apart.

The control, and the flag that provides it

The answer is to make the scan tell you what it examined, which is what --negate does. It inverts the result, reporting every file that did not match, and on a rule matching nothing that means every file it looked at.

yr scan -n nothing.yar fam/
Nothing fam/fam2
Nothing fam/fam3
Nothing fam/fam1

Three files were examined and none matched. That is a different and far stronger statement than the empty output above, and it took one flag and no additional thought. Where the ordinary scan left four possibilities open, this closes three of them: the path resolved, files were found, and the rule ran against them.

The habit worth building from your first scan is to run the negation whenever an empty result is going to become a sentence somebody acts on. It costs seconds. On a large target you do not need the full listing, and --count gives you the same assurance more compactly, printing a per-file match count so a directory of zeros still proves the directory was read.

There is a second form of the same discipline, and it answers a question --negate cannot. Negation proves the scan reached the files; it does not prove the rule is capable of matching anything at all. A rule with a subtly wrong pattern will report every file as a non-match, correctly, forever. So the complete control is two-sided: run the negation to prove the scan reached the population, and run the rule against a file you know contains the thing, to prove the rule can fire. If the second returns nothing, the problem is the rule and no amount of scanning will surface it. Module 6 builds this into a testing routine; for now it is enough to know that an empty result is a question rather than an answer.

What the tool tells you before you scan

One more command belongs in your first hour, because it catches problems earlier than any scan can. yr check compiles rules without scanning anything and reports what it finds against the source, with a caret under the offending line.

yr check beginner.yar
warning[slow_pattern]: slow pattern
 --> beginner.yar:5:14
  |
5 |         $b = { 00 00 }
  |              --------- this pattern may slow down the scan
[ WARN ] beginner.yar

The tool has told you that a pattern is bad before you ran a single scan, and the reason is worth understanding rather than simply obeying. Two null bytes occur millions of times in any binary, so the engine has no distinctive sequence to search for and every occurrence in every file becomes a candidate it must check. That is a performance problem rather than a correctness one, and on a fleet scan it is the difference between an answer this afternoon and an answer tomorrow.

Notice what it did not warn about, because the boundary is the useful part. The same rule contained $a = "MZ", two characters present in every Windows executable ever compiled, and the linter said nothing. That pattern will not slow your scan; it will simply match everything, which is a detection problem the tool has no opinion about. yr check tells you whether a pattern will make the scan slow. Whether it will make the scan useful is your job, and it is what Module 3 is about.

Run it on every rule you write, before the first scan rather than after a confusing result. It moves an entire class of problem from "discovered during a fleet hunt" to "discovered while typing".

Scan guide: prove your first scan reached something

Install the tool and establish the control habit on a rule you know the answer to, so that the first time an empty result matters you have already met it.

Step 1, install and confirm. Download the release archive as above, move yr onto your PATH, and run yr --version. Add the Python bindings with pip install yara-python yara-x so both engines are available from Module 7 onward.

Step 2, write a rule that matches. Any string you know is in a file you have. Run yr check on it first, then yr scan it against that file and confirm you get a match line. This proves the rule can fire, which is half the control.

Step 3, run it against a directory where it should find nothing. You will get no output and exit code zero. Sit with that for a moment: this is the state you must never report as a clean estate.

Step 4, run the same scan with -n. Every file examined is now listed. Compare the two outputs, and note that the difference between them is entirely in what you know rather than in what the tool did.

Step 5, break it deliberately. Point the same scan at a path that does not exist. The output is identical to Step 3, which is the whole argument of this section demonstrated in one command.

✓ Verify

The rule can fire: Step 2 produced a match line naming your rule and the file, proving the pattern is present and the rule compiles into something that works.

Silence is ambiguous: Steps 3 and 5 produced byte-identical output, one from a real directory and one from a path that does not exist, which is the whole argument of this section reproduced on your own machine.

The negation resolves it: Step 4 listed every file examined, so you can now tell a scan that found nothing from a scan that read nothing, and you have the command to do it in front of you.

Section 0.2 covers the specimens you will build and use throughout the course, all of them from software you already have.