Signal9Signal9 — home
online
all lessons

Detection & Triage: Finding Indicators of Compromise

Intermediate40 min7 steps
What you'll learn
  • Run a systematic four-pillar sweep — logs, processes, network, files — instead of poking at random
  • Pivot from one indicator to the others (a strange process points you to its port, its files, and the log of when it started)
  • Read auth and log signals fast: who logged in, who failed, and whether a new or UID-0 account appeared
  • Build a timeline by anchoring on one known-bad timestamp and pulling everything changed in that window
  • Prioritise by impact and spread, and document an IoC list other responders can act on
  1. Why triage, and the four pillars

    Frame detection as a fast, systematic first pass that decides where to dig deeper.

    You get the call: "this box is acting weird, can you look?" You don't yet know if it's a misconfiguration, a noisy neighbour, or a live intruder. Triage is the fast first pass that answers one question — is something wrong here, and if so, where do I dig?

    Think like an ER doctor. A patient rolls in and you don't start with their family history; you check vitals in a fixed order, treat the life-threatening thing first, and chart as you go so the next clinician can pick up where you left off. Detection works the same way: a fixed sweep, worst-first treatment, and notes the next responder can act on.

    The sweep has four pillars. Check each, every time:

    • Logs — who logged in, who failed, what the system itself reported
    • Processes — what is running that shouldn't be
    • Network — what is listening or phoning home
    • Files — what was created or changed, and when

    The pillars are not four separate investigations. They are four windows onto one event.

  2. The pivot: one indicator points to the others

    See how a single finding in one pillar drags the whole picture out of the other three.

    The reason the four pillars work as a system is that they are linked by the same underlying activity. A real intrusion leaves a thread you can pull, and pulling it in one pillar exposes the others.

    Say the process pillar turns up something odd — a process called kworkerd eating CPU from /tmp. That single indicator is a thread:

    • Process → Network: what port is it holding? ss -tlnp ties the PID to a listening socket and any outbound C2.
    • Network → Files: where does its binary live? /proc/PID/exe gives the real path on disk.
    • Files → Logs: when did that file appear? Its ctime is a timestamp — and that timestamp tells you which log lines to read to find the initial access.

    One suspicious process just handed you a port, a file, and a moment in time. That moment is the anchor for the timeline you'll build in step 5.

    Example — not runnable on your lab box

    stat /tmp/.cache/kworkerd
      File: /tmp/.cache/kworkerd
      Size: 4194304     Blocks: 8200       IO Block: 4096   regular file
    Device: fd00h/64768d  Inode: 786441      Links: 1
    Access: (0755/-rwxr-xr-x)  Uid: (    0/    root)   Gid: (    0/    root)
    Access: 2026-06-23 11:48:02.000000000 +0000
    Modify: 2026-06-23 02:14:51.000000000 +0000
    Change: 2026-06-23 02:14:51.000000000 +0000
    stat is the pivot between the file pillar and the log pillar. The Change time (ctime) — 02:14:51 — is when this file's metadata was last written, i.e. roughly when it landed on disk. An attacker can backdate the Modify time with touch, but ctime is set by the kernel and is far harder to forge. That 02:14 timestamp is now your anchor: it tells you exactly where in the logs to look for the initial access.

    Check yourself

    You find a suspicious binary on disk. Which timestamp is hardest for an attacker to forge and best for anchoring a timeline — atime, mtime, or ctime?

  3. The logs pillar: who got in

    Read auth and system logs fast to spot the initial access and any failed attempts.

    Logs are where access leaves its first fingerprint. Before an attacker can run a process or open a port, they usually have to get in — and getting in is logged. Three quick reads cover most of it:

    • last — successful logins (who, from where, when)
    • lastb — failed logins, the signature of a brute-force or password-spray
    • journalctl / /var/log/auth.log — the detailed auth events behind those summaries
    last -a | head
    webadmin pts/1        Wed Jun 23 02:11   still logged in     198.51.100.23
    webadmin pts/0        Tue Jun 22 09:02 - 17:40  (08:38)    10.0.0.42
    reboot   system boot  Tue Jun 22 08:55   still running       
    webadmin pts/0        Mon Jun 21 09:14 - 18:01  (08:47)    10.0.0.42
    -a
    Put the remote host in the last column so long IPs don't get truncated.
    Read this for what breaks the pattern. webadmin normally logs in around 9 a.m. from the internal 10.0.0.42. The top line is a login at 02:11 from an external IP, 198.51.100.23 — odd hour, unfamiliar source, and still connected. That 02:11 sits right before the 02:14 file ctime from the last step: the login and the dropped binary line up.
    sudo lastb | head
    admin    ssh:notty    198.51.100.23    Wed Jun 23 02:09 - 02:09  (00:00)
    root     ssh:notty    198.51.100.23    Wed Jun 23 02:09 - 02:09  (00:00)
    webadmin ssh:notty    198.51.100.23    Wed Jun 23 02:08 - 02:08  (00:00)
    admin    ssh:notty    198.51.100.23    Wed Jun 23 02:08 - 02:08  (00:00)
    (bare)
    lastb mirrors last but reads /var/log/btmp — the bad/failed login record. Needs root.
    A burst of failures across multiple usernames (admin, root, webadmin) from the same IP in the same minute is a brute-force or password-spray. It's the same 198.51.100.23 that succeeded as webadmin at 02:11 — so you've now got the attack's shape: spray at 02:08–02:09, success at 02:11. The empty lastb you'll often see on a clean box is itself a useful (negative) result.
    journalctl -u ssh --since "2026-06-23 02:00" --no-pager | head
    Jun 23 02:08:55 web01 sshd[4411]: Failed password for invalid user admin from 198.51.100.23 port 51002 ssh2
    Jun 23 02:09:14 web01 sshd[4418]: Failed password for root from 198.51.100.23 port 51110 ssh2
    Jun 23 02:11:03 web01 sshd[4502]: Accepted password for webadmin from 198.51.100.23 port 51990 ssh2
    Jun 23 02:11:03 web01 sshd[4502]: pam_unix(sshd:session): session opened for user webadmin by (uid=0)
    -u ssh
    Only the ssh service's log lines (use auth.log on Debian/Ubuntu if journald isn't the auth sink).
    --since
    Window the log to the time of interest — here, around the 02:xx anchor.
    --no-pager
    Dump straight to stdout so you can pipe to head/grep instead of landing in a pager.
    This is the detail behind the summaries. Accepted password for webadmin at 02:11:03 is the initial access, confirmed — password auth (not a key), from the brute-force IP, immediately after failed attempts. You now know the how (weak password) and the when (02:11), which scopes everything else.

    Check yourself

    Which command shows you FAILED login attempts — the signature of a brute-force — rather than successful ones?

  4. The accounts check: did they leave a key behind

    Spot the persistence trick of adding a new or UID-0 account.

    An attacker who only has a stolen password is one password-reset away from being locked out. So a common next move is persistence: create a new account, or quietly hand an existing one root. Two files hold the answer, and both are plain text you can read in seconds.

    The rule that matters: on Linux, root is not a name, it's a number — UID 0. Any account with UID 0 is root, whatever it's called. So an attacker's support user with 0 in the third field of /etc/passwd is a root account hiding behind an innocent name.

    awk -F: '$3==0{print $1}' /etc/passwd
    root
    toolkit
    -F:
    Split each line on the colon — /etc/passwd is colon-separated.
    $3==0
    The third field is the UID; match only accounts whose UID is exactly 0 (root-equivalent).
    There should be exactly one UID-0 account: root. A second name — here toolkit — is a backdoor root account. Whatever it's called, UID 0 means it can do everything root can. This single line is one of the highest-value checks in the whole sweep.
    sudo awk -F: '($2!="*" && $2!="!" && $2!="!!"){print $1}' /etc/shadow
    root
    webadmin
    toolkit
    -F:
    /etc/shadow is also colon-separated; the 2nd field is the password hash.
    $2!=...
    Skip accounts that are locked (! / !!) or have no usable password (*); print only those that can log in with a password.
    /etc/shadow (root-only) holds the actual password hashes. This lists accounts that can log in with a password. Seeing toolkit here as well as in the UID-0 check confirms it's not a dormant system account — it has a working password and root powers. That's a login-capable backdoor, and a high-priority finding.

    Check yourself

    An /etc/passwd entry reads support:x:0:0::/home/support:/bin/bash. Why is this account a red flag even though it isn't named root?

  5. The timeline: anchor on one timestamp

    Reconstruct the attack's progression by pulling everything changed in one window.

    Individual indicators are dots. A timeline connects them into a story: spray, login, drop binary, add account, beacon out. You build it by anchoring on one known-bad timestamp and asking the filesystem what else changed around then.

    You already have the anchor: the binary's ctime was 02:14, and the login was 02:11. So the question becomes: what was created or modified between 02:00 and 02:30? find answers it directly with -newermt (newer than a time) bracketed by ! -newermt (but not newer than a later time).

    sudo find / -xdev -newermt "2026-06-23 02:00" ! -newermt "2026-06-23 02:30" -type f 2>/dev/null
    /tmp/.cache/kworkerd
    /home/webadmin/.ssh/authorized_keys
    /etc/passwd
    /etc/shadow
    /etc/cron.d/.sysupdate
    /var/spool/cron/crontabs/root
    -xdev
    Stay on one filesystem — don't descend into /proc, /sys, or network mounts that would flood the results.
    -newermt "..."
    Match files whose modification time is newer than the given time — the start of your window.
    ! -newermt "..."
    Negate a second time bound to close the window — newer than 02:00 but NOT newer than 02:30.
    2>/dev/null
    Discard the permission-denied noise on directories you can't traverse, keeping the signal clean.
    Six files changed in a 30-minute window, and together they are the attack story. authorized_keys — an SSH key planted for re-entry. /etc/passwd + /etc/shadow — the toolkit backdoor account. /etc/cron.d/.sysupdate and root's crontab — scheduled persistence (a dot-prefixed cron file trying to hide). And the kworkerd payload itself. One timestamp turned scattered findings into a single, ordered narrative.

    Check yourself

    In find / -newermt T1 ! -newermt T2, what is the role of the ! -newermt T2 part?

  6. Prioritise and document

    Triage findings by impact and spread, and write an IoC list others can act on.

    Triage isn't just finding things — it's deciding what to handle first. Back to the ER: you treat the arterial bleed before the sprained wrist, even if the wrist patient is shouting louder. Rank findings by two questions:

    • Impact — how bad is this if left alone? (active root C2 > dormant file)
    • Spread potential — can it reach other hosts? (lateral movement, planted SSH keys, shared credentials > a local-only artifact)

    For our box: the live C2 connection and the UID-0 `toolkit` account come first — active control and root-level persistence. The cron jobs and authorized_keys are next — persistence that re-arms the threat. The cryptominer's CPU burn, annoying as it is, is last: it's loud but contained.

    A usable IoC list from this investigation looks like:

    • IP 198.51.100.23 — brute-force source + successful login (block at firewall)
    • IP 192.0.2.66 — outbound C2 destination (block egress)
    • File /tmp/.cache/kworkerd — cryptominer payload (preserve, then remove)
    • File /etc/cron.d/.sysupdate, /var/spool/cron/crontabs/root — persistence (remove)
    • File /home/webadmin/.ssh/authorized_keys — planted key (audit, remove attacker entry)
    • Account toolkit (UID 0) — backdoor root account (disable, then remove)
    • Timeline 02:08 spray → 02:11 login (webadmin) → 02:14 payload → persistence + C2

    That list is the deliverable. Anyone can act on it without re-deriving it.

    Check yourself

    Two findings: (a) a cryptominer pegging the CPU, (b) an ESTABLISHED outbound connection to an unknown IP from a root process. Which do you treat first — answer with the one word for what (b) represents?

  7. Try it in your live terminal

    Run the four-pillar sweep yourself on the real sandbox to your right.

    You have a real root shell. Run this sweep in order — it's the exact first-pass triage you'd do on a "this box feels off" call. There may be nothing malicious here; the goal is to make the moves reflexive so they're automatic when it counts. Note anything that breaks the pattern as you go — that's your IoC list starting.

    One caveat as you run these: `journalctl` may come back empty on this box — it logs to flat files under /var/log, not systemd's journal. That's not a miss, it's the signal that this host doesn't use journald, so /var/log (and the last/lastb reads above) is where the answer lives. The rest of the sweep runs in full.

    last -a | head
    Logs pillar: recent successful logins. Look for odd hours, unfamiliar source IPs, or accounts that don't usually log in interactively.
    sudo lastb | head
    Logs pillar: failed logins. A burst from one IP across many usernames is a brute-force. An empty result is a clean (good) sign.
    awk -F: '$3==0{print $1}' /etc/passwd
    Accounts check: every UID-0 account. There should be exactly one — root. Any second name is a backdoor root account.
    journalctl -p err -n 30 --no-pager
    Logs pillar: the last 30 error-priority log lines. Service crashes, auth failures, and kernel complaints often surface the first sign of trouble.
    sudo find / -xdev -newermt "-30 minutes" -type f 2>/dev/null | grep -vE '^/(proc|sys|run)' | head -20
    Files pillar: everything modified in the last 30 minutes. Swap the window to bracket a known-bad timestamp once you have an anchor. Watch for writes in /tmp, /etc, cron dirs, and .ssh.
Recap
  • Triage like an ER: a fixed sweep of all four pillars every time, worst-first treatment, and notes as you go — don't run on intuition.
  • The pillars are one event seen four ways. Find a thread in any pillar and pivot: process → port → file → the log of when it started.
  • Anchor a timeline on one trustworthy timestamp (a file's ctime, an auth log line) and pull everything changed in that window to reconstruct the attack.
  • Prioritise by impact and spread — active C2 and UID-0 accounts before a loud-but-contained miner — and keep a running IoC list as the deliverable.
  • Go deep with the pillar-specific lessons (Process & Network Forensics, log analysis, file timelines); this lesson is the methodology that connects them.
Pillar → what to check → tool
PillarWhat to checkTool
LogsWho logged in, who failed, the initial accesslast -a, lastb, journalctl/auth.log
AccountsNew accounts and any UID-0 (root-equivalent) loginawk -F: '$3==0' /etc/passwd, /etc/shadow
ProcessesWhat's running that shouldn't beps auxf (see Process & Network Forensics)
NetworkWhat's listening or beaconing outss -tlnp, lsof -i (see Process & Network Forensics)
FilesWhat changed, and when — the timelinestat, find -newermt T1 ! -newermt T2