The intrusion chain on Linux

Initial access to lateral movement, and its traces.

Advanced16 min · lesson 2 of 15

The last lesson mapped the surface an intruder lands on. This one follows one intruder across it, stage by stage, and reads the trace each stage leaves. An intrusion is regular enough to name its steps: initial access (the first foothold, usually a weak account), reconnaissance (learning the host and what it reaches), credential abuse (using a valid account's rights, often to become root), and lateral movement (using SSH to reach the next account or host). Naming the stages gives you a map you can use twice, once to catch an intrusion while it runs and again to reconstruct it afterward. By the end you can generate each stage's trace safely and recognise the exact log lines a default Ubuntu 26.04 server writes, from the sudo record to the sshd-session login lines of its OpenSSH 10.2.

The intrusion chain on a Linux host
1Initial access
a foothold, usually a weak service account
2Reconnaissance
who am I, what is this host, what can I reach
3Credential abuse
use a valid account to act, often as root
4Lateral movement
reuse SSH keys to reach the next account or host
5Actions on objectives
steal, spread, or destroy
Every stage touches the host in its own way, and every stage leaves a mark a defender can read.

Initial access and reconnaissance

A thief who climbed through a window stops and listens before moving. The Linux version is a burst of read-only commands that answer who am I, what is this machine, and what can I reach. Create a stand-in service account for the foothold (a throwaway, removed at the end of the lesson) and run the commands as it, so the output is what the intruder actually sees.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo useradd --system --no-create-home --shell /usr/sbin/nologin kc-www
$ sudo -u kc-www sh -c "id; uname -srm"
uid=999(kc-www) gid=987(kc-www) groups=987(kc-www) Linux 7.0.0-34-generic aarch64
$ sudo -u kc-www ss -tlnp
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess LISTEN 0 4096 127.0.0.54:53 0.0.0.0:* LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* LISTEN 0 4096 [::]:22 [::]:*

Four facts land in seconds. The account is kc-www, unprivileged, in no useful groups. The kernel is 7.0.0-34-generic on aarch64, a version the intruder can match against public exploit lists (an x86_64 server would print x86_64 here, which changes which prebuilt exploits apply). The listeners show port 22 and the resolver. One detail is a gift to the defender: the Process column in ss is blank, because a non-root account cannot see the processes behind sockets it does not own, so the blank column is itself a sign the command ran as a low-privilege user. A web service account that suddenly runs id, uname and ss back to back, with no human pauses, is doing something it was never built to do; no single command is the tell, the burst is. Reconnaissance maps to the Discovery tactic in MITRE ATT&CK: id is System Owner/User Discovery (T1033) and uname is System Information Discovery (T1082).

Credential abuse: a valid account that is already root

The easiest escalation is no escalation. If the intruder can reach an account that already holds sudo, becoming root is one command, and it is authorised. That is why stolen or reused credentials for a valid account (MITRE ATT&CK calls this Valid Accounts, T1078) are worth more than any exotic bug. Assume the intruder then stole deploy's SSH key or hijacked one of its sessions. deploy holds broad sudo rights, and the intruder uses them.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo id
uid=0(root) gid=0(root) groups=0(root)

uid=0(root): the machine is theirs. The important half is that sudo does not do this quietly. sudo-rs, the sudo on Ubuntu 26.04, writes every invocation to syslog.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo journalctl -t sudo -n 40 -o short-iso --no-hostname 2>/dev/null | grep "COMMAND=/usr/bin/id" | tail -1
2026-09-27T08:38:17+00:00 sudo[65538]: deploy : PWD=/home/deploy ; USER=root ; COMMAND=/usr/bin/id

One line names the invoking account deploy, the target USER=root, the working directory and the exact command run as root. Read as detection, an account whose baseline never elevates at that hour, or from that directory, doing so is worth an alert; deploy and CI accounts that elevate as part of their normal work need a baseline of their own, or the alert fires all day.

Know the record's limits too. The line records the elevation, not what happened afterwards: sudo -i or sudo bash writes one line naming the shell, and nothing typed in that root shell reaches the sudo log. sudo-rs on Ubuntu 26.04 has no session I/O logging (its sudoers(5) has no log_output), while classic sudo on RHEL does. What a root shell does is the audit pipeline's job (execve records keyed on the login identity, linux-det/auditpipe). And an account with NOPASSWD: ALL can rewrite /var/log/auth.log and vacuum the journal with its first root command, so the line only counts as evidence once it has left the host.

This is Abuse Elevation Control Mechanism: Sudo and Sudo Caching (T1548.003), and the sudo log is where it surfaces. The privilege-escalation lesson (linux-det/peloc) covers the sudo rules that turn a narrow grant into a full root shell; here the point is simpler, that using a legitimate credential still leaves a record tying root back to a human account.

SSH abuse and lateral movement

Having root on one host, an intruder spreads. SSH is the usual road, because reusing a key looks exactly like normal administration. A public key in an account's authorized_keys file lets the matching private key log in with no password and no further exploit, so a key the owner never added is both a way back in and a way onward. To produce that trace safely, the lab plays both sides with a second local account, kc-mover: it creates the account, generates a key pair as deploy (standing in for a key the intruder copied), installs the public half in kc-mover's authorized_keys (standing in for a key the intruder added with root), and records localhost's host key in a private known_hosts file so the login can verify it.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo useradd -m -s /bin/bash kc-mover sudo install -d -m700 -o kc-mover -g kc-mover /home/kc-mover/.ssh mkdir /tmp/kc-stage ssh-keygen -t ed25519 -N "" -f /tmp/kc-stage/key -q -C stolen sudo install -m600 -o kc-mover -g kc-mover /tmp/kc-stage/key.pub /home/kc-mover/.ssh/authorized_keys ssh-keyscan -H -t ed25519 localhost >> /tmp/kc-stage/known_hosts 2>/dev/null

Now read the target account's authorized keys the way a defender should.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo cat /home/kc-mover/.ssh/authorized_keys
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILnfZ4MhCK3NkuHBmqUijzyQZslkGVpBNBIJqVyGEIp7 stolen

That is an ed25519 key with no options in front of it. Options such as from="10.0.0.0/8" or restrict limit where a key can be used from and what it can do, and they are good hygiene, but most legitimate keys in real fleets carry none, so "no options" is not the signal. The signal is a key your source of truth did not put there: configuration management, an identity provider behind AuthorizedKeysCommand, or a reviewed inventory. A key that is not in that inventory, with or without options, is a persistence and lateral-movement marker (Account Manipulation: SSH Authorized Keys, T1098.004). Check where sshd really reads keys from before you trust a sweep of home directories, because an intruder with root prefers the places a sweep forgets: root's own keys, an AuthorizedKeysFile override in /etc/ssh/sshd_config.d/, or a key command.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo sshd -T | grep -iE "^authorizedkeys(file|command) "
authorizedkeyscommand none authorizedkeysfile .ssh/authorized_keys .ssh/authorized_keys2

sshd -T prints the effective configuration after every drop-in is applied. Here keys come from each user's .ssh/authorized_keys and .ssh/authorized_keys2 (the second name is an old default that is still read), and no key command is set. A changed line here is as important as a new key. Now watch the login the planted key enables.

deploy@web01 · Ubuntu 26.04 LTS
$ ssh -i /tmp/kc-stage/key -o UserKnownHostsFile=/tmp/kc-stage/known_hosts -o StrictHostKeyChecking=yes kc-mover@localhost id
uid=1002(kc-mover) gid=1002(kc-mover) groups=1002(kc-mover)

The intruder is now kc-mover. Lateral movement over SSH is Remote Services: SSH (T1021.004), and OpenSSH (10.2 on Ubuntu 26.04) records it in detail. The session and authentication events no longer come from a process called sshd. On Ubuntu, ssh.socket holds port 22 and starts the sshd listener (ssh.service) once, on the first connection (linux-det/threatmodel showed this). The listener then hands each connection to its own sshd-session process, and that is the name in the log.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo journalctl -t sshd-session -n 60 -o short-iso --no-hostname 2>/dev/null | grep -E "kc-mover" | grep -E "Accepted publickey|session opened" | tail -2
2026-09-27T08:38:18+00:00 sshd-session[65707]: Accepted publickey for kc-mover from 127.0.0.1 port 37448 ssh2: ED25519 SHA256:Ano9QTZqM7MuNuUVvl11gdBWgor6Eyro4bIvsDl1yHY 2026-09-27T08:38:18+00:00 sshd-session[65707]: pam_unix(sshd:session): session opened for user kc-mover(uid=1002) by kc-mover(uid=0)

Accepted publickey for kc-mover ... SHA256:... records the exact key fingerprint that authenticated, so you can tell which key was used, and session opened for user kc-mover marks the login. The by kc-mover(uid=0) part is PAM naming the login and the user ID of the process that opened the session, which is the root-owned sshd-session monitor; it does not mean root logged in. The fingerprint is worth capturing: it lets you match a login to a specific key you found in authorized_keys, which is how you connect "a key was planted" to "the key was used". One more artifact travels with the intruder: the account's known_hosts file, which records the hosts it connected to.

Since OpenSSH 9.8, logins are logged by sshd-session, not sshd
Until OpenSSH 9.8 the listening sshd forked a copy of itself for each connection, and those lines were tagged sshd[pid]. From 9.8 on the listener starts a separate sshd-session program per connection (a monitor that runs as root, plus a child that drops to the logged-in user), so Accepted publickey, session opened and Disconnected now carry the tag sshd-session[pid]. Ubuntu 26.04 ships OpenSSH 10.2, so any detection, log filter or SIEM parser written against sshd[ silently stops matching real logins. Search for sshd-session (or match both), and re-test your login alerts after any OpenSSH major upgrade.
deploy@web01 · Ubuntu 26.04 LTS
$ ssh-keygen -l -f /tmp/kc-stage/known_hosts
256 SHA256:DQrditqlUW6Vb1JvrIbmmDuPnLVUZJQl/AX3jxsdoKo |1|uWtm/PobWx4bO0JvaSM6WpqKf1Y=|EuPziF8+JajKHaAtyxh7OYeu5Ys= (ED25519)

known_hosts is a map of where an account has been. Ubuntu hashes the hostnames by default (HashKnownHosts yes in Debian's and Ubuntu's /etc/ssh/ssh_config, the |1|... form), so you cannot read the destinations straight off, but ssh-keygen -l -f still counts the entries and ssh-keygen -F host tests whether a specific host is in it. RHEL does not set that option, so there the names are plain text. On a compromised account it is a lead list of hosts reached from here, never a complete one: a client records only what it chooses to, and -o UserKnownHostsFile=/dev/null (or a throwaway file, as this lab used) leaves nothing in the account's own file. An empty known_hosts is not evidence of no lateral movement.

Two related tricks belong to the same stage. Agent forwarding (ssh -A) lets a login on one host use the keys held by your local agent to authenticate onward, so a compromised intermediate host can hijack the agent and move as you. Reused keys, one private key accepted on many hosts, turn a single stolen key into fleet-wide access. Both are why key hygiene, per-host keys and restrict options, is a lateral-movement control, not just tidiness.

Catch one link and the chain breaks

The stages run in order, and that is the defender's advantage: the attacker has to pass every stage cleanly, and you only have to catch one. You did not need to see the original exploit to arrive here. You could have flagged the recon burst, the sudo elevation, the new key in authorized_keys, or the sshd-session login from an unusual source, and any one of them pulls the whole thread loose and hands you an account, a time and a file to reconstruct the rest from. The lessons ahead build the durable version of each catch: the audit pipeline (linux-det/auditpipe) for the elevation and the key write, forensic artefacts (linux-det/artifacts) for the login records, and detections that survive contact (linux-det/detections) for keying on the move rather than the name.

Try this

On a lab host, remove this lesson's accounts first (sudo userdel -r kc-mover, sudo userdel kc-www, rm -r /tmp/kc-stage). Then create a throwaway user, generate an ed25519 key for it, append the public key to its own ~/.ssh/authorized_keys, and ssh to localhost as that user with the key. If you applied the AllowGroups baseline from linux-hard/ssh, add the user to your allowed group first, or sshd refuses the login. Then find your own trace: journalctl -t sshd-session --since "-5 min" should show Accepted publickey and session opened for that user, with the key fingerprint. Confirm the login came from sshd-session and not sshd. Remove the user and its home to clean up. You have generated and then read one link of the chain, which is exactly how you prove a detection fires before you need it.

Takeaway

Learn the trace each stage leaves and where it lands: sudo elevation in journalctl -t sudo, SSH logins in sshd-session records, and new keys in authorized_keys. You only have to catch one link, so instrument the stage the attacker cannot skip.

Quick check
01Your search for suspicious SSH logins on an Ubuntu 26.04 server greps the journal for lines tagged sshd[, the way it always has, and finds nothing during a window you know had logins. What is wrong?
Correct — The listener hands each connection to sshd-session, and Accepted publickey and session opened carry that tag, so a sshd[ filter misses them.
Incorrect — Persistent journald changes retention across reboots, not whether current logins are recorded; the events exist under a different process tag.
Incorrect — Public-key logins are logged, including the key fingerprint at the default log level; the miss is the process tag, not the auth method.
Incorrect — sudo-rs logs sudo, not SSH; the two are separate, and SSH sessions are logged by sshd-session.
02Investigating a suspected pivot, you find a compromised account's ~/.ssh/known_hosts full of hashed entries. How should you treat it?
Incorrect — known_hosts records hosts whose key was accepted on connection, successful or not by auth; it is not a record of brute-force attempts.
Incorrect — Hashing hides the plaintext names but you can still count entries and test specific candidate hosts with ssh-keygen -F.
Incorrect — The file is a plain, user-writable text file with no signature; it is a lead, not tamper-proof evidence.
Correct — It is a map of where the account has connected; hashing blocks a direct read, but you can size it and test candidate hosts, then pivot to those. It is a floor, not a full list, because a client can skip recording hosts.
03A deploy service account has (ALL) NOPASSWD: ALL and you see journalctl -t sudo record deploy : ... USER=root ; COMMAND=/usr/bin/id at 03:00. Why is that log line valuable even though sudo "worked normally"?
Incorrect — No exploit is involved: the account was granted NOPASSWD: ALL, so sudo to root is authorised, which is exactly why the log, not a crash, is the evidence.
Correct — Valid-account abuse looks authorised, so the sudo record is often the earliest signal: it names who elevated, to what, when and running what.
Incorrect — The id ran as root; sudo-rs allowed it under the rule, so this is a record of use, not a refusal.
Incorrect — sudo is local; the log records a local elevation and has nothing to do with an inbound network connection.

Related