Signal9Signal9 — home
online
all lessons

Linux Logging Architecture & journald Investigation

Intermediate40 min8 steps
What you'll learn
  • Map where Linux logs actually come from: the kernel ring buffer, classic syslog/rsyslog text files, and the systemd journal
  • Profile an unfamiliar host from its logs and explain how the journal and rsyslog coexist
  • Drive journalctl as an investigation tool — boot, unit, priority, time-window, and field filters that matter in IR
  • Tell a volatile journal from a persistent one and recognize when evidence was rotated, cleared, or wiped by a reboot
  1. Why logging is an architecture, not a file

    Frame the three log sources a responder must know before touching a single command.

    When you walk onto a Linux host you're authorized to review, the first instinct is to cat /var/log/something. That instinct is half-wrong, and on a modern box it can make you miss the evidence entirely. Linux logging is not one file — it's a small pipeline with three distinct sources, and where a message lands depends on who produced it and how the host is configured.

    The three sources you must hold in your head:

    • The kernel ring buffer — a fixed-size in-memory buffer the kernel writes to directly (disk errors, OOM kills, USB events, network-interface state). You read it with dmesg.
    • Classic syslog / rsyslog text logs — plain-text files under /var/log/: /var/log/messages or /var/log/syslog for general system messages, /var/log/secure or /var/log/auth.log for authentication. Written by a daemon, usually rsyslog.
    • The systemd journal — a binary, structured, indexed store managed by systemd-journald. Every unit's stdout/stderr, every syslog() call, and the kernel buffer all flow into it. You query it with journalctl.

    These coexist. On most modern distros rsyslog reads from the journal and writes the familiar text files for compatibility — so the same event can exist in two places at once.

    The analogy that makes this stick: the journal is a structured database — every record carries typed, queryable fields (which unit, which PID, which user, what priority, exact timestamp), so you can ask precise questions and get precise answers. The rsyslog text files are a stack of paper printouts — human-readable, append-only, easy to grep, but flat: to filter them you're reaching for grep, awk, and your eyes, not a query language. A good responder uses both: the database to ask questions, the printouts to cross-check what the database says.

  2. The kernel ring buffer: where the lowest-level truth lives

    Read the kernel's own buffer and understand why it's separate from everything else.

    The kernel can't depend on a logging daemon to be alive — it boots before userspace and must record hardware faults, out-of-memory kills, and driver events even if journald never starts. So it writes to its own fixed-size ring buffer in memory. When the buffer fills, the oldest messages are overwritten — which is exactly why you copy what matters out before it scrolls away.

    In an investigation the ring buffer answers questions nothing else can: Did the OOM killer reap a process? Was a USB device plugged in? Did a network interface flap or go promiscuous (a sniffer's fingerprint)?

    dmesg -T --level=err,warn | tail -n 6
    [Tue Jun 23 11:48:02 2026] usb 1-1: new high-speed USB device number 4 using xhci_hcd
    [Tue Jun 23 11:48:02 2026] usb-storage 1-1:1.0: USB Mass Storage device detected
    [Tue Jun 23 11:50:14 2026] device eth0 entered promiscuous mode
    [Tue Jun 23 12:03:41 2026] Out of memory: Killed process 2207 (kworkerd) total-vm:712044kB
    [Tue Jun 23 12:03:41 2026] oom_reaper: reaped process 2207 (kworkerd)
    [Tue Jun 23 12:11:09 2026] TCP: request_sock_TCP: Possible SYN flooding on port 31337
    -T
    Human-readable timestamps. Raw dmesg prints seconds-since-boot, which is useless for correlating against wall-clock evidence.
    --level=err,warn
    Show only error- and warning-priority kernel messages, cutting the boot-time noise.
    Three findings a responder would flag: a USB mass-storage device was inserted at 11:48 (possible data exfil or a dropped tool), `eth0 entered promiscuous mode` at 11:50 (a packet sniffer was started), and the OOM killer reaped PID 2207 `kworkerd` at 12:03 — the same suspicious name from process-forensics work. The kernel recorded all of this independently of any userspace logger.

    Check yourself

    The live kernel ring buffer is lost on reboot. Which journalctl flag retrieves kernel messages specifically?

  3. The classic text logs: profiling a host from /var/log

    Profile an unfamiliar host from its rsyslog text files and know which file answers which question.

    Before journald, all of this lived in flat text under /var/log/, written by a syslog daemon (today almost always rsyslog). These files are still present on most distros, and they're the fastest way to profile a host you've never seen: one ls tells you what services have run, because services create their own log files and directories.

    The naming splits by distro family, and knowing which family you're on is itself a finding:

    • Debian/Ubuntu: general messages in /var/log/syslog, authentication in /var/log/auth.log.
    • RHEL/CentOS/Fedora: general messages in /var/log/messages, authentication in /var/log/secure.
    ls -la /var/log/
    drwxr-xr-x  2 root   root    4096 Jun 23 12:00 .
    drwxr-xr-x 14 root   root    4096 Jun 01 09:14 ..
    -rw-r-----  1 syslog adm    18204 Jun 23 12:11 auth.log
    -rw-r-----  1 syslog adm     2048 Jun 22 00:00 auth.log.1
    drwxr-x---  2 root   adm     4096 Jun 23 11:30 nginx
    -rw-r-----  1 syslog adm    91233 Jun 23 12:11 syslog
    drwxr-sr-x+ 3 root   systemd-journal 4096 Jun 23 12:00 journal
    -rw-rw-r--  1 root   utmp    9216 Jun 23 12:09 wtmp
    -l
    Long listing — owner, group, size, and the modification time that tells you which logs are still being written.
    -a
    Include dotfiles and the ./.. entries; some rotated/hidden logs only show with -a.
    This one listing profiles the host. auth.log + syslog (not secure/messages) says Debian/Ubuntu. An nginx/ directory means a web server ran here. The presence of journal/ under /var/log/ is the headline finding for the next step: this host has a persistent journal. And auth.log.1 is a rotated older copy — logs roll over, so yesterday's authentication record is in auth.log.1, not auth.log.
    grep -a "Failed password" /var/log/auth.log | tail -n 4
    Jun 23 12:08:51 web01 sshd[4471]: Failed password for invalid user admin from 198.51.100.77 port 51202 ssh2
    Jun 23 12:08:53 web01 sshd[4471]: Failed password for invalid user admin from 198.51.100.77 port 51208 ssh2
    Jun 23 12:08:55 web01 sshd[4473]: Failed password for root from 198.51.100.77 port 51230 ssh2
    Jun 23 12:09:02 web01 sshd[4473]: Accepted password for root from 198.51.100.77 port 51244 ssh2
    -a
    Treat the file as text even if grep sees a stray binary byte — rotated/compressed-adjacent logs sometimes trip grep into "binary file matches".
    Flat text is where grep shines. The pattern is unmistakable: rapid `Failed password` attempts against admin and root from 198.51.100.77, then an `Accepted password for root` from the same IP — a brute-force that succeeded. The text log reads like a printout; you scanned it with your eyes and grep, exactly as the analogy predicts.

    Check yourself

    You're on a host with /var/log/secure and /var/log/messages but no auth.log. Which distro family is this?

  4. Rotated & compressed: search the whole log set, not just the live file

    Search across rotated and gzip-compressed logs in one pass — the evidence a single-file grep silently skips.

    Step 3 noted that auth.log.1 is a rotated older copy. That's only half the picture, and the missing half is where investigations quietly go wrong. Text logs don't grow forever — they rotate, and old generations get compressed. On a real host, logrotate renames the live file to auth.log.1, then gzips the older generations, so /var/log ends up holding a mixed set side by side:

    • auth.log — the live file, plain text.
    • auth.log.1 — the most recent rotation, usually still plain (logrotate's delaycompress postpones compressing it one cycle).
    • auth.log.2.gz, auth.log.3.gz, … — older generations, gzip-compressed (logrotate's compress default extension is .gz).

    Here is the trap. Every grep you've seen so far — grep 'Failed password' /var/log/auth.log — reads only the live file. If the break-in you're hunting happened yesterday and has already rotated into auth.log.2.gz, that command returns zero hits, no error, exit 0. A confident, empty, wrong answer. That silent-empty result — not a crash — is the failure mode this whole step exists to prevent.

    Example — not runnable on your lab box

    ls -la /var/log/auth.log*
    -rw-r----- 1 syslog adm  18204 Jun 23 12:11 /var/log/auth.log
    -rw-r----- 1 syslog adm   9990 Jun 23 00:00 /var/log/auth.log.1
    -rw-r----- 1 syslog adm   2130 Jun 22 00:00 /var/log/auth.log.2.gz
    -rw-r----- 1 syslog adm   2044 Jun 21 00:00 /var/log/auth.log.3.gz
    -rw-r----- 1 syslog adm   1998 Jun 20 00:00 /var/log/auth.log.4.gz
    -l
    Long listing — the .gz extension is how you spot which generations are compressed.
    -a
    Include dotfiles; harmless here but the habit that catches hidden rotated logs.
    This is exactly the shape logrotate produces: one live file, one plain rotation, and the rest gzipped. Four days of authentication history, and a plain grep /var/log/auth.log can see only the first line of it.

    Example — not runnable on your lab box

    zgrep "Failed password" /var/log/auth.log*
    /var/log/auth.log:Jun 23 12:08:51 web01 sshd[4471]: Failed password for invalid user admin from 198.51.100.77 port 51202 ssh2
    /var/log/auth.log.2.gz:Jun 22 03:14:07 web01 sshd[2210]: Failed password for root from 45.135.232.9 port 40662 ssh2
    /var/log/auth.log.3.gz:Jun 21 22:51:40 web01 sshd[1980]: Failed password for root from 45.135.232.9 port 39114 ssh2
    zgrep is grep that transparently decompresses first. Point it at the glob /var/log/auth.log* and it reads the whole set in one invocation: plain files (auth.log, auth.log.1) pass straight through, .gz files are decompressed on the way to grep. Same pattern syntax, same exit status (0=match, 1=no match, 2=trouble) — so it drops into the pipelines you already know (zgrep … | awk, zgrep -c …). The filename: prefix tells you which generation each hit came from — here the 45.135.232.9 brute-force lives only in the compressed generations, invisible to a live-file grep. zcat/zless are the same idea for reading rather than matching.

    Check yourself

    You ran grep 'Failed password' /var/log/auth.log and found 3 hits. A colleague searched the same host, got no error, and found 340. What did they search that you didn't?

  5. journald as an investigation tool: the filters that matter

    Drive journalctl with the boot, unit, priority, time-window, and field filters used in real IR.

    Now the database. Because the journal stores structured fields on every record, journalctl lets you query rather than grep. The investigative power is in combining filters so you go from millions of lines to the handful that matter. The filters worth memorizing:

    • `-b` this boot, `-b -1` the previous boot — scope to the incident's reboot window.
    • `-u <unit>` one service's logs (-u sshd).
    • `-p err` priority floor (emerg,alert,crit,err,warning,notice,info,debug).
    • `--since` / `--until` a wall-clock time window.
    • `_PID=`, `_UID=`, `_COMM=` exact structured-field matches — these are the database columns.
    • `-o verbose` / `-o json` to dump every field on a record.
    • `-k` kernel messages only.
    journalctl -u sshd -b --since "2026-06-23 12:00" --no-pager
    Jun 23 12:08:51 web01 sshd[4471]: Failed password for invalid user admin from 198.51.100.77 port 51202 ssh2
    Jun 23 12:08:55 web01 sshd[4473]: Failed password for root from 198.51.100.77 port 51230 ssh2
    Jun 23 12:09:02 web01 sshd[4473]: Accepted password for root from 198.51.100.77 port 51244 ssh2
    Jun 23 12:09:05 web01 sshd[4473]: pam_unix(sshd:session): session opened for user root by (uid=0)
    -u sshd
    Restrict to the sshd service unit. The journal knows which unit emitted each line, so no grep needed.
    -b
    Only this boot. Without it you'd see every boot the persistent journal retains — often weeks of noise.
    --since "2026-06-23 12:00"
    Wall-clock lower bound. Pairs with --until to bracket an incident window exactly.
    --no-pager
    Send output straight to stdout instead of opening less — essential when scripting or piping.
    Four filters turned the entire host log into the four lines of the SSH compromise: the brute-force, the success, and the `session opened for user root` that confirms the attacker got an interactive root session. This is the same event from auth.log in step 3 — but here you queried it by unit and time instead of grepping a file.
    journalctl _COMM=sudo -p notice -o verbose --no-pager | head -n 14
    Tue 2026-06-23 12:09:40.118 UTC [s=a1f...;i=2c41;b=9d2...]
        _UID=0
        _GID=0
        _PID=4502
        _COMM=sudo
        _CMDLINE=sudo systemctl stop rsyslog
        PRIORITY=5
        SYSLOG_IDENTIFIER=sudo
        MESSAGE=  root : TTY=pts/0 ; PWD=/root ; USER=root ; COMMAND=/usr/bin/systemctl stop rsyslog
        _SYSTEMD_UNIT=session-3.scope
        _HOSTNAME=web01
        _SOURCE_REALTIME_TIMESTAMP=1750681780118000
    _COMM=sudo
    Match the structured field _COMM (the executable's name) exactly. Field matches are AND-ed with other filters.
    -p notice
    Priority floor of notice and above — sudo logs invocations at notice.
    -o verbose
    Print every stored field on each record, not just the message — the full database row.
    -o verbose exposes what the flat text logs cannot: structured columns. We can read _UID=0, _PID=4502, the full _CMDLINE, and the headline — `sudo systemctl stop rsyslog`. The intruder is trying to silence the text-log pipeline. Crucially, stopping rsyslog does not stop journald, so this very act was still recorded — by the journal.

    Check yourself

    Which journalctl option limits output to only the messages from the current boot?

  6. Persistence: volatile vs persistent, and detecting a wipe

    Determine whether the journal survives reboot and recognize cleared or rotated-away evidence.

    Here is the single fact that decides whether your journal evidence even exists: is the journal volatile or persistent?

    • Volatile — stored under `/run/log/journal/`, which is a tmpfs (RAM). It is wiped on every reboot. This is the default when /var/log/journal/ does not exist.
    • Persistent — stored under `/var/log/journal/`. Survives reboots. Enabled by creating that directory (or Storage=persistent in journald.conf).

    Think back to the analogy: a volatile journal is a database held entirely in RAM with no save-to-disk — pull the power and the whole database is gone. For a responder this is decisive: on a volatile-journal host, a reboot has already destroyed your evidence, and an attacker who knows this only needs the box to restart.

    ls -d /var/log/journal 2>/dev/null && echo PERSISTENT || echo VOLATILE
    /var/log/journal
    PERSISTENT
    The fastest first check. If /var/log/journal doesn't exist, ls fails silently (stderr hidden by 2>/dev/null) and the || branch prints VOLATILE: the journal lives in /run, and any reboot since the incident wiped it. If it exists, ls prints it and the && branch echoes PERSISTENT, but that only means persistence is possible. It can still be false. Storage=volatile (or none) in /etc/systemd/journald.conf makes journald ignore the directory, and on minimal images and containers journald may not be running at all. Either way the directory exists but stays empty, and an empty directory proves nothing: the evidence is the *.journal files inside it. So confirm with the next command: a non-zero --disk-usage and more than one boot in --list-boots are what tell you -b -1 will actually return something. Run this pair first on every host.
    journalctl --disk-usage; journalctl --list-boots --no-pager | tail -n 4
    Archived and active journals take up 184.0M in the file system.
     -3 5b8c... Mon 2026-06-22 09:14:02 UTC—Mon 2026-06-22 23:59:58 UTC
     -2 7a1e... Tue 2026-06-23 06:02:11 UTC—Tue 2026-06-23 08:40:55 UTC
     -1 9d2f... Tue 2026-06-23 08:41:30 UTC—Tue 2026-06-23 11:47:10 UTC
      0 c3a4... Tue 2026-06-23 11:48:03 UTC—Tue 2026-06-23 12:13:22 UTC
    --disk-usage
    Total bytes the journal occupies. A suspiciously tiny figure on a long-lived host hints the journal was rotated or vacuumed away.
    --list-boots
    Every boot the persistent journal retains, with its index and time span — the timeline of restarts.
    --list-boots is a reboot timeline. The gap is the tell: boot -1 ends at 11:47:10 and boot 0 begins at 11:48:03 — a reboot landed right in the incident window (matching the USB-insert and promiscuous-mode events from the kernel buffer). Boot indices are also how you reach pre-reboot evidence: journalctl -b -1 reads everything before that restart. A --disk-usage of 184M is normal; a host up for months showing only a few MB would suggest the journal was vacuumed.

    Check yourself

    A host stores its journal only under /run/log/journal. What single event destroys all of that evidence?

  7. Correlating across sources

    Cross-check the journal against rsyslog text to catch tampering and confirm findings.

    No single source is gospel. The strongest conclusions come from agreement across sources — or from a disagreement that itself reveals tampering. Because rsyslog usually reads from the journal and writes the text files, the same event normally appears in both. When it appears in one but not the other, that delta is a finding.

    In this incident the attacker ran sudo systemctl stop rsyslog (seen in step 4). That means after 12:09:40, rsyslog stopped writing the text logs while journald kept recording. The proof is a comparison.

    tail -n 1 /var/log/auth.log; echo '---'; journalctl -u sshd -n 1 --no-pager -o short
    Jun 23 12:09:31 web01 sshd[4480]: Disconnected from 198.51.100.77 port 51244
    ---
    Jun 23 12:12:58 web01 sshd[4602]: Accepted publickey for root from 198.51.100.55 port 40110 ssh2
    -n 1
    Last single entry — here used to compare the newest record each source holds.
    -o short
    Compact one-line-per-entry format, matching the syslog text layout for an apples-to-apples compare.
    The text log auth.log stops at 12:09:31 — frozen the moment rsyslog was stopped. But the journal kept going and holds a later sshd event at 12:12:58: an Accepted publickey for root from 198.51.100.55, a second intruder IP using a planted key. A responder trusting only the text logs would have missed the persistence mechanism entirely. The journal's survival past the rsyslog cutoff is both the missing evidence and proof the text pipeline was tampered with.
  8. Try it in your live terminal

    Run the read-only logging-triage sweep yourself on the live sandbox.

    You have a real shell to your right. Every command below is read-only and safe — it inspects logs, it never clears or stops anything. Run them in order: this is the exact opening sweep for a "reconstruct what happened on this host" call. There may be nothing malicious here; the goal is to make the moves reflexive.

    One honest note before you start: this lab host logs to flat files in `/var/log`, not to systemd's journal, so the journalctl commands below may come back empty. Run them anyway to make the syntax reflexive — then treat an empty journal as the signal to pivot to /var/log (the last command), which is exactly the fallback a real responder reaches for on a host that doesn't use journald. Watch the persistence check in particular. Here /var/log/journal exists, so it prints persistent, yet journalctl --disk-usage reports 0B and journalctl says No journal files were found. That is step 6's caveat in practice: the directory is only the precondition, and the journal files inside it are the evidence.

    journalctl --no-pager -n 5
    Show the journal's most recent entries. On a Signal9 lab box you'll often see “No journal files were found” or nothing at all — this host doesn't run systemd's journald as PID 1, so its logs live as flat files under /var/log (block 6 lists them) rather than in the binary journal. That's a real, common state, not an error: an empty journal is empty for everyone, so the reflex is to pivot to /var/log — not to reach for sudo or the systemd-journal group, which only matter once a journal exists.
    ls -d /var/log/journal 2>/dev/null && echo persistent || echo volatile
    The persistence verdict. persistent means -b -1 will reach pre-reboot evidence; volatile means a reboot since the incident has already wiped the journal.
    journalctl --disk-usage
    How much journal there is to work with. A tiny figure on a long-lived host is a hint the journal may have been vacuumed.
    journalctl -p err -b --no-pager | tail
    Error-priority events from this boot only — the fastest path to what's actually broken or suspicious, with -b keeping a persistent journal's history from burying it.
    journalctl -u ssh -u sshd --no-pager 2>/dev/null | tail
    SSH activity, covering both common unit names (ssh on Debian, sshd on RHEL). Scan the tail for Failed/Accepted password lines.
    ls /var/log/ | head
    Profile the host from its text logs: the filenames reveal the distro family (syslog/auth.log vs messages/secure) and which services have run.
Recap
  • Linux logging is three sources, not one file: the kernel ring buffer (dmesg), rsyslog text files under /var/log/, and the systemd journal. Know all three before concluding anything.
  • The journal is a structured, queryable database; the rsyslog text files are flat printouts. Use the journal to ask precise questions, the text files to cross-check.
  • Scope first in journalctl: -b/--since, then -u/-p/field matches. Forgetting to scope drowns the incident in history.
  • Text logs rotate and compress: grep on the live file alone silently misses everything already in auth.log.*.gz. Search the whole set with zgrep pattern /var/log/auth.log* (zcat/zless to read) — an empty single-file result is the trap, not the answer.
  • Persistence decides whether evidence exists: /var/log/journal = persistent (survives reboot), /run/log/journal = volatile (wiped on reboot). Check it first.
  • Cross-source disagreement is itself a finding: a frozen text log beside a still-advancing journal means the text pipeline was tampered with.
Log source → where it lives → how to query
SourceWhere it livesHow to query
Kernel ring bufferIn-memory (wiped on reboot)dmesg -T, or journalctl -k
rsyslog: general/var/log/syslog (Debian) / messages (RHEL)grep, tail, less
rsyslog: auth/var/log/auth.log (Debian) / secure (RHEL)grep "Failed password"
Rotated / compressed logsauth.log.1 (plain), auth.log.*.gz (gzip)zgrep pat /var/log/auth.log*, zcat, zless
systemd journal/var/log/journal (persistent) / /run/log/journal (volatile)journalctl -b -u <unit> -p err --since …
Persistence checkdirectory /var/log/journal exists?ls -d /var/log/journal && echo persistent
Reboot timelinejournal boot indexjournalctl --list-boots, then -b -1