Detection & Triage: Finding Indicators of Compromise
- 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
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.
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
kworkerdeating CPU from/tmp. That single indicator is a thread:- Process → Network: what port is it holding?
ss -tlnpties the PID to a listening socket and any outbound C2. - Network → Files: where does its binary live?
/proc/PID/exegives the real path on disk. - Files → Logs: when did that file appear? Its
ctimeis 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/kworkerdFile: /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
statis 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 withtouch, butctimeis 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?
- Process → Network: what port is it holding?
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-sprayjournalctl//var/log/auth.log— the detailed auth events behind those summaries
last -a | headwebadmin 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. webadminnormally logs in around 9 a.m. from the internal10.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 filectimefrom the last step: the login and the dropped binary line up.sudo lastb | headadmin 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 same198.51.100.23that succeeded aswebadminat 02:11 — so you've now got the attack's shape: spray at 02:08–02:09, success at 02:11. The emptylastbyou'll often see on a clean box is itself a useful (negative) result.journalctl -u ssh --since "2026-06-23 02:00" --no-pager | headJun 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 webadminat 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?
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
supportuser with0in the third field of/etc/passwdis a root account hiding behind an innocent name.awk -F: '$3==0{print $1}' /etc/passwdroot 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 — heretoolkit— 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/shadowroot 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. Seeingtoolkithere 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?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
ctimewas 02:14, and the login was 02:11. So the question becomes: what was created or modified between 02:00 and 02:30?findanswers 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— thetoolkitbackdoor account./etc/cron.d/.sysupdateand root's crontab — scheduled persistence (a dot-prefixed cron file trying to hide). And thekworkerdpayload 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 T2part?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?
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 thelast/lastbreads above) is where the answer lives. The rest of the sweep runs in full.last -a | headLogs pillar: recent successful logins. Look for odd hours, unfamiliar source IPs, or accounts that don't usually log in interactively. sudo lastb | headLogs 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/passwdAccounts 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-pagerLogs 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 -20Files 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.
- 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 |
|---|---|---|
| Logs | Who logged in, who failed, the initial access | last -a, lastb, journalctl/auth.log |
| Accounts | New accounts and any UID-0 (root-equivalent) login | awk -F: '$3==0' /etc/passwd, /etc/shadow |
| Processes | What's running that shouldn't be | ps auxf (see Process & Network Forensics) |
| Network | What's listening or beaconing out | ss -tlnp, lsof -i (see Process & Network Forensics) |
| Files | What changed, and when — the timeline | stat, find -newermt T1 ! -newermt T2 |
