Signal9Signal9 — home
online
all lessons

The Investigation Playbook: How to Hunt a Compromised Linux Host

Intermediate45 min7 steps
What you'll learn
  • 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
  1. 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.

  2. 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 -efww
    root  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)
    ps is 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. kworkerd looks like a kernel worker thread — the kernel really does run things called kworker. 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 in ps wrapped in square brackets, like [kworker/0:1]. Anything with a real path like /usr/local/sbin/kworkerd and 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/kworkerd
    dpkg-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/ssh answers openssh-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 is rpm -qf <path>. This is your lie detector: name says kernel, disk says unpackaged stranger.

    Check yourself

    You see a process named kworkerd in ps. What's the fastest single check that tells you whether it's a real kernel thread or an impostor?

  3. 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.log
    20000
    -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.
  4. 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.log
         3 /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 execve system 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 the execve hook 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?

  5. 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). -f matches against the full command line; the full path /usr/local/sbin/kworkerd is 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 pgrep prints 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.
  6. 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 a systemctl reload can still reference it. Then re-run the grep above: a clean, empty result is your proof that no respawn path remains.
  7. 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 -S and 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.log
    Attribute the C2 from argv, the binary, and this flow log — make all three agree — then record your findings to /root/case/findings.txt and 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 lives
    Contain precisely and confirm the storefront survived, then grep -rs the 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.
Recap
  • 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.
The host-truth toolkit — what each command actually proves
QuestionCommandGround truth it gives you
What's running?ps -efwwEvery 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 binaryA destination confirmed by independent sources
Kill it cleanlypkill -f '<full path>' then pgrep -afThreat gone, and proof the legit service is still up
Will it come back?grep -rs <name> /etc/cron.d /etc/systemd/systemEvery respawn mechanism — remove them all, then re-grep for empty