The Investigation Playbook: How to Hunt a Compromised Linux Host
- Run an incident as a hypothesis-driven loop — observe, form a theory, test it against ground truth, pivot — instead of a blind checklist
- Trust ground truth (the live process table, /proc, the on-disk binary, package ownership) over what a tool's log or a process's name claims
- Reason about telemetry gaps: explain why a security agent can log almost nothing while the network shows a flood of traffic
- Contain a threat precisely — kill the malicious process without taking down the legitimate service beside it
- Eradicate persistence so the threat can't simply respawn after you leave
The mindset: an investigation is a loop, not a checklist
Adopt the hypothesis-driven loop that every good responder runs, before touching a single command.
A pager goes off: "this server is making thousands of connections to an IP nobody recognizes." The instinct of a beginner is to reach for a checklist — run these ten commands, paste the output, hope the answer falls out. That's not how a responder thinks, and it's the single biggest thing separating someone who finds the intruder from someone who just collects logs.
Here's the model to hang everything on. An investigation is a loop:
1. Observe — what does the evidence in front of you actually say?
2. Hypothesize — what's the simplest story that would explain it? ("A process on this box is beaconing to a command-and-control server.")
3. Test — go find evidence that would prove or kill that theory. Not evidence that flatters it — evidence that could destroy it.
4. Pivot — the test points you somewhere new. Follow it. Loop.
You exit the loop when you can tell a single, evidence-backed story from first signal to root cause — and only then do you contain.
One rule sits above the loop and governs every step of it: prefer ground truth over telemetry. Telemetry is what a tool reports — an EDR log line, a process's name, a dashboard. Ground truth is what's actually on the machine — the live process, the bytes of the binary on disk, the socket the kernel is holding open. Tools lie, by omission or by being fooled. The kernel doesn't. When they disagree, ground truth wins, every time. Keep that sentence in your head for the rest of this lesson.
Ground truth #1: interrogate every process, don't trust its name
Enumerate processes and prove what each suspicious one really is — using the on-disk binary and package ownership, not the name it advertises.
Your first hypothesis is almost always "some process is doing this." So list them all — everything, with full command lines, because the arguments are where the truth usually hides.
ps -efwwroot 842 811 0 02:14 ? 00:00:00 bash /usr/local/sbin/kworkerd --c2 198.51.100.7:443 root 900 1 0 02:10 ? 00:00:01 bash /usr/local/bin/webapp
- -e
- every process on the system, not just yours
- -f
- full format — includes the parent PID (PPID) and the command with its arguments
- -ww
- unlimited width — never truncate the command line (that --c2 argument is the whole case; a truncated line would hide it)
psis your census of running processes. The flags matter: without them you get a truncated, current-terminal-only view that hides exactly the process you're hunting.Two processes stand out.
kworkerdlooks like a kernel worker thread — the kernel really does run things calledkworker. That's the disguise. But here is the tell, and it's a rule you can rely on: real kernel threads have no program on disk. They live entirely inside the kernel and show up inpswrapped in square brackets, like[kworker/0:1]. Anything with a real path like/usr/local/sbin/kworkerdand a parent shell is a user-space program wearing a kernel's name. Impostor.Don't take my word for it — or the process's. Ask the package manager who owns that file. Every legitimate system binary was installed by a package; a dropped payload was not.
dpkg -S /usr/local/sbin/kworkerddpkg-query: no path found matching pattern /usr/local/sbin/kworkerd
- -S
- search installed packages for the one that owns the given path
dpkg -S(search) answers one question: which installed package owns this file? A real tool like/usr/bin/sshanswersopenssh-client. This file answers "no path found" — no package owns it. It was dropped onto the box, not installed. On Red Hat–family systems the same question isrpm -qf <path>. This is your lie detector: name says kernel, disk says unpackaged stranger.Check yourself
You see a process named
kworkerdinps. What's the fastest single check that tells you whether it's a real kernel thread or an impostor?Ground truth #2: attribute the destination from more than one source
Pin down the command-and-control endpoint by making independent sources agree, instead of trusting a single line.
You suspect this process is beaconing to a command-and-control (C2) server. To prove where, don't lean on one artifact — a single log line can be forged, misread, or stale. A destination you can see from three independent angles is a destination you can write in the report with confidence. Look for agreement.
Source one, the process's own arguments — you already have them from
ps:--c2 198.51.100.7:443. Source two, the binary itself. Read it:Example — not runnable on your lab box
cat /usr/local/sbin/kworkerd#!/bin/bash # c2=198.51.100.7:443 C2="${2:-198.51.100.7:443}" ...loops a connection to $C2 every few seconds...The payload is a script, so its logic is right there in plaintext — the same IP, hard-coded as a default. Two sources agree. (If it were a compiled binary, strings /usr/local/sbin/kworkerd | grep -E '[0-9]+\.[0-9]+'would pull readable IPs and URLs out of it.)Source three, the network record. This host runs a flow logger, and its log is the third witness:
Example — not runnable on your lab box
grep -c '198.51.100.7' /var/log/netwatch/flows.log20000
- -c
- count matching lines instead of printing them — here, how many flows hit that IP
Twenty thousand outbound flow records to the same IP. That's not a stray connection — that's a beacon hammering its C2. Three independent sources (argv, binary, netflow) all name 198.51.100.7. Now you can attribute it.The telemetry gap: why the security tool saw almost nothing
Explain how an EDR can log ~3 events while the network shows ~20,000 flows — the reasoning skill that defines a responder.
Here's the part that trips people up, and it's the most important idea in this lesson. The endpoint security agent (EDR) on this box logged about three events all night. The network sensor logged about twenty thousand flows to the C2. Same activity, same hours. How can two tools on the same machine disagree by four orders of magnitude?
Example — not runnable on your lab box
wc -l /var/log/edr/agent.log /var/log/netwatch/flows.log3 /var/log/edr/agent.log 20001 /var/log/netwatch/flows.log 20004 total
- -l
- count lines in each file — a quick way to size two logs against each other
The gap is real and enormous. A beginner sees the near-empty EDR log and concludes 'nothing happened.' A responder sees it and asks 'what is this tool structurally unable to see?' The answer is in how each tool watches. This EDR hooks process creation — every time the kernel runs a new program (an
execvesystem call), the agent records one event. That's a cheap, common way to build endpoint telemetry. Now look at the beacon: it's one long-lived process that opens a socket, fails, sleeps, and opens another socket — in a loop, forever. It never launches a new program to do it. So theexecvehook fired once, when the beacon started, and then went silent for the entire attack. Three events: the launch and a couple of neighbors.The network sensor, meanwhile, watches at a completely different layer — it counts flows on the wire. It doesn't care how many processes exist; every connection attempt is one record. Twenty thousand beacons, twenty thousand rows. Neither tool is broken. They're watching different things, and the attacker's technique — reuse one process, reconnect in a loop — happens to fall squarely in the EDR's blind spot while lighting up the network log.
Check yourself
An EDR that logs process-creation (execve) events shows almost nothing, yet netflow shows 20,000 connections. What does the beacon's design most likely look like?
Containment discipline: kill the threat, not the business
Stop the beacon precisely — and prove the legitimate service survived, because taking down production is its own incident.
You've found it, proven it, and understood it. Now stop it — carefully. There's a legitimate service running right next to the beacon:
/usr/local/bin/webapp, the thing customers are actually using. Containment that kills the beacon and the storefront isn't a win; you've just traded a security incident for an outage. Precision is the whole job here.The tempting move is a broad
pkill. Resist it. Watch what a lazy pattern does:pkill -f '/usr/local/sbin/kworkerd'- -f
- match the pattern against the entire command line, not just the process name
Silent on success (that's normal for pkill).-fmatches against the full command line; the full path/usr/local/sbin/kworkerdis unique to the beacon, so nothing legitimate is caught in the blast. If you wanted to confirm the target list first,pgrep -af '/usr/local/sbin/kworkerd'prints exactly what would be killed — look before you leap.Then — always — verify both halves of the outcome. The threat is gone and the business is still up:
pgrep -af '/usr/local/sbin/kworkerd'; echo '---'; pgrep -af '/usr/local/bin/webapp'--- 900 bash /usr/local/bin/webapp
- -a
- print the full command line of each match (not just the PID) so you can see exactly what's still running
The first pgrepprints nothing — the beacon is dead. After the---separator, the second still shows the webapp alive. That's the definition of clean containment: threat down, service up. If the webapp line were missing, you'd have caused an outage and would need to restart it immediately.Eradicate persistence: assume it's wired to come back
Find and remove every mechanism that would respawn the beacon, so a reboot doesn't undo your work.
Killing the process is not the end. Any attacker worth worrying about installs persistence — a mechanism that relaunches the payload after a crash, a logout, or a reboot. Kill the beacon, walk away, and if you left a cron job or a systemd unit behind, it's beaconing again before you've closed your laptop. So hunt the respawn mechanisms the same way you hunt everything: search for the one string you know appears in all of them — the beacon's name.
grep -rs 'kworkerd' /etc/cron.d /etc/systemd/system/etc/cron.d/system-health:*/2 * * * * root /usr/local/sbin/kworkerd --c2 198.51.100.7:443 /etc/systemd/system/kworkerd.service:ExecStart=/usr/local/sbin/kworkerd --c2 198.51.100.7:443
- -r
- recurse into the given directories
- -s
- suppress error noise for unreadable/missing files, so real hits aren't buried
Two footholds. A cron job that re-runs the beacon every 2 minutes, and a systemd unit set to launch and restart it. These are the two most common Linux persistence spots; /etc/rc.local, shell rc files (~/.bashrc), and~/.config/systemd/user/are worth a look too.rm -f /etc/cron.d/system-health /etc/systemd/system/kworkerd.service /etc/systemd/system/multi-user.target.wants/kworkerd.service- -f
- force — don't error on files that are already gone, so the command is safe to re-run
Remove the cron file, the unit file, and the multi-user.target.wants/symlink that 'enabled' the unit — miss the symlink and asystemctlreload can still reference it. Then re-run thegrepabove: a clean, empty result is your proof that no respawn path remains.Now do it — run the Beacon Hunt in your live terminal
Take the whole loop into a real box: find the beacon, attribute the C2, explain the gap, contain, and eradicate — without breaking the storefront.
Reading the method is not the same as running it under a little pressure. Open the Beacon Hunt lab (
ir-beacon-hunt) — it's the exact scenario you just walked through, seeded live on a real host, waiting for you to work it end to end. Don't copy the commands above blindly; run the loop. Observe, hypothesize, test, pivot.Your objectives in the lab, in order:
ps -efww | grep -v '\['Start here: census the processes, drop the bracketed kernel threads, and find the impostor. Then interrogate it with dpkg -Sand read its arguments. (This is a practice step — your terminal supplies the output; that's the point.)Example — not runnable on your lab box
grep -c '<the C2 IP>' /var/log/netwatch/flows.logAttribute the C2 from argv, the binary, and this flow log — make all three agree — then record your findings to /root/case/findings.txtand the IP to/root/case/c2.txt, and write down why the EDR was nearly blind.pkill -f '<full path to the beacon>' # then verify the webapp still livesContain precisely and confirm the storefront survived, then grep -rsthe persistence spots and remove every mechanism. When the grader is green, you've run a real investigation — that's the wizard move, and you just made it.
- Run the loop: observe → hypothesize → test → pivot. Seek evidence that could disprove your theory, not just confirm it, and only contain once you can tell one evidence-backed story.
- Ground truth beats telemetry and beats names. A process's name is a claim; the on-disk binary, its package ownership (
dpkg -S/rpm -qf), and the live kernel state are facts. Real kernel threads have no file on disk. - Attribute from multiple independent sources — argv, the binary's contents, and network logs. When three witnesses agree, you can write it in the report.
- A quiet security tool is data, not comfort. Every sensor has a blind spot defined by what it hooks; a silent EDR beside a screaming netflow log tells you which technique the attacker used.
- Contain with precision — match the malicious process by a unique full path, never a loose substring — and always verify the legitimate service survived.
- Kill is not done. Hunt and remove every persistence mechanism (cron, systemd units and their wants/ symlinks, rc files) or the threat respawns the moment you leave.
| Question | Command | Ground truth it gives you |
|---|---|---|
| What's running? | ps -efww | Every process, full command line + parent — arguments and all |
| Is this binary legit? | dpkg -S <path> / rpm -qf <path> | Which package owns the file — or that none does (a dropped payload) |
| What does it hold open? | lsof -p <pid> | The files and sockets a specific process has open right now |
| Where is it calling? | grep -c <ip> netflow + argv + the binary | A destination confirmed by independent sources |
| Kill it cleanly | pkill -f '<full path>' then pgrep -af | Threat gone, and proof the legit service is still up |
| Will it come back? | grep -rs <name> /etc/cron.d /etc/systemd/system | Every respawn mechanism — remove them all, then re-grep for empty |
