Skip to main content

persistence/
lib.rs

1//! Where Linux and Unix attackers keep access to a host: crontabs, `at`
2//! jobs, systemd units, init scripts, SSH authorized keys and the SSH
3//! server's configuration, `rc.local` and shell start-up files,
4//! `/etc/ld.so.preload`, sudoers, PAM, udev rules, XDG autostart entries
5//! and kernel modules, read from the host files of a triage collection.
6//!
7//! [`detect`] tells a file's [`Kind`] from its path on the host
8//! (`etc/crontab`, `home/alice/.ssh/authorized_keys`, `[root]/etc/sudoers`,
9//! …), and [`parse`] reads it into [`Entry`]s: one per job, setting, key,
10//! command line, library or rule, with the line it's on, the account it
11//! runs as or belongs to, what it runs and when. [`flags`] lists the traits
12//! that look like an attacker's ([`Flag`]).
13//!
14//! Lines that can't be read are reported in `problems`, never fatal; no
15//! input makes these functions panic.
16//!
17//! ```
18//! use persistence::{detect, flags, parse, Flag};
19//!
20//! let path = "[root]/etc/cron.d/sysupdate";
21//! let kind = detect(path).unwrap();
22//! let parsed = parse(kind, b"@reboot root /dev/shm/.x/run\n", path);
23//! let job = &parsed.entries[0];
24//! assert_eq!(job.summary(), "@reboot as root: /dev/shm/.x/run");
25//! assert_eq!(flags(job), [Flag::TemporaryDirectory, Flag::AtReboot]);
26//! ```
27
28mod accounts;
29mod at;
30mod autostart;
31mod base64;
32mod cron;
33mod flags;
34mod modules;
35mod pam;
36mod path;
37mod preload;
38mod shell;
39mod ssh;
40mod sshd;
41mod sudoers;
42mod summary;
43mod systemd;
44mod text;
45mod udev;
46
47pub use accounts::{Account, Password, PasswordState};
48pub use flags::{flags, Flag};
49pub use path::detect;
50
51/// This crate's version, for records of what parsed them.
52pub const VERSION: &str = env!("CARGO_PKG_VERSION");
53
54/// A kind of file, each read its own way.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub enum Kind {
57    /// A user's crontab (`var/spool/cron/crontabs/<user>`, RHEL's
58    /// `var/spool/cron/<user>`): a schedule, then the command, run as the
59    /// account the file is named after.
60    Crontab,
61    /// The system crontabs (`etc/crontab`, `etc/cron.d/*`): a schedule, the
62    /// account, then the command.
63    SystemCrontab,
64    /// `etc/anacrontab`: period, delay, job id and command, run as root.
65    Anacrontab,
66    /// A systemd unit (`.service`, `.timer`, `.path`, `.socket`) or a
67    /// drop-in (`<unit>.d/*.conf`), system-wide or a user's.
68    SystemdUnit,
69    /// `.ssh/authorized_keys` (and `authorized_keys2`): keys that may log in
70    /// as the account whose home it's in.
71    AuthorizedKeys,
72    /// `etc/rc.local`, `etc/rc.d/rc.local`, and ESXi's
73    /// `etc/rc.local.d/local.sh` (kept across reboots, where ESXi rebuilds
74    /// most of `/etc`, which makes it the attackers' favourite there): run
75    /// by root at boot.
76    RcLocal,
77    /// Shell start-up files, run when an account logs in or opens a shell:
78    /// `etc/profile`, `etc/profile.d/*.sh`, `etc/bash.bashrc`, a home's
79    /// `.bashrc`, `.profile`, `.zshrc`, ….
80    ShellInit,
81    /// `etc/ld.so.preload`: libraries loaded into every dynamically linked
82    /// program.
83    LdSoPreload,
84    /// `etc/sudoers`, `etc/sudoers.d/*`: who may run what as whom.
85    Sudoers,
86    /// A job `at` queued (`var/spool/cron/atjobs/*`, `var/spool/at/*`,
87    /// `var/at/jobs/*`): commands run once, later, as the account that
88    /// queued them.
89    AtJob,
90    /// A System V init script (`etc/init.d/*`, `etc/rc.d/init.d/*`): run by
91    /// root at boot, on systems that still start them.
92    InitScript,
93    /// PAM's configuration (`etc/pam.d/*`, `etc/pam.conf`): the modules
94    /// every login goes through.
95    Pam,
96    /// The SSH server's configuration (`etc/ssh/sshd_config`,
97    /// `etc/ssh/sshd_config.d/*`).
98    SshdConfig,
99    /// udev rules (`etc/udev/rules.d/*.rules`, `usr/lib/udev/rules.d`, …):
100    /// commands run as root when a matching device appears.
101    Udev,
102    /// XDG autostart entries (`etc/xdg/autostart/*.desktop`, a home's
103    /// `.config/autostart/*.desktop`): commands a desktop session starts at
104    /// login.
105    XdgAutostart,
106    /// Kernel modules loaded at boot (`etc/modules`, `modules-load.d/*`).
107    ModulesLoad,
108    /// modprobe's configuration (`modprobe.d/*.conf`).
109    Modprobe,
110    /// `etc/passwd`: the accounts, their ids and shells.
111    Passwd,
112    /// `etc/shadow`: the state of each account's password.
113    Shadow,
114    /// `etc/group`: the groups' members.
115    Group,
116}
117
118impl Kind {
119    /// A short name: `crontab`, `system crontab`, `anacrontab`, `systemd
120    /// unit`, `authorized keys`, `rc.local`, `shell init`, `ld.so.preload`,
121    /// `sudoers`, `at job`, `init script`, `pam`, `sshd config`, `udev
122    /// rule`, `xdg autostart`, `modules load`, `modprobe`, `passwd`,
123    /// `shadow`, `group`.
124    #[must_use]
125    pub const fn name(self) -> &'static str {
126        match self {
127            Self::Crontab => "crontab",
128            Self::SystemCrontab => "system crontab",
129            Self::Anacrontab => "anacrontab",
130            Self::SystemdUnit => "systemd unit",
131            Self::AuthorizedKeys => "authorized keys",
132            Self::RcLocal => "rc.local",
133            Self::ShellInit => "shell init",
134            Self::LdSoPreload => "ld.so.preload",
135            Self::Sudoers => "sudoers",
136            Self::AtJob => "at job",
137            Self::InitScript => "init script",
138            Self::Pam => "pam",
139            Self::SshdConfig => "sshd config",
140            Self::Udev => "udev rule",
141            Self::XdgAutostart => "xdg autostart",
142            Self::ModulesLoad => "modules load",
143            Self::Modprobe => "modprobe",
144            Self::Passwd => "passwd",
145            Self::Shadow => "shadow",
146            Self::Group => "group",
147        }
148    }
149}
150
151/// One job, setting, key, command line, library or rule.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct Entry {
154    /// The kind of file it's in.
155    pub kind: Kind,
156    /// Its line number, from 1 (the first line, when continued).
157    pub line: usize,
158    /// The account it runs as (cron and at jobs, units' commands,
159    /// `rc.local` and init scripts) or
160    /// belongs to (a home's keys and start-up files); for a sudoers rule,
161    /// the accounts and `%groups` it's granted to. `None` when the file
162    /// doesn't say: a system unit without `User=` runs as root unless a
163    /// drop-in says otherwise.
164    pub user: Option<String>,
165    /// What runs: the command; for a key, its forced command
166    /// (`command="…"`); for ld.so.preload, the library; for a sudoers rule,
167    /// the commands allowed; for a PAM rule, the module (`pam_exec.so`'s
168    /// program); for an sshd setting, the command it runs, if any.
169    pub command: Option<String>,
170    /// When it runs: cron's five fields or `@reboot`…, anacron's period,
171    /// a timer's `OnCalendar=`, `OnBootSec=`… value, as written; an at
172    /// job's time, from its file name (`2026-10-07T07:14Z`, UTC).
173    pub schedule: Option<String>,
174    /// What else the line holds.
175    pub detail: Detail,
176}
177
178impl Entry {
179    fn new(kind: Kind, line: usize, detail: Detail) -> Self {
180        Self {
181            kind,
182            line,
183            user: None,
184            command: None,
185            schedule: None,
186            detail,
187        }
188    }
189}
190
191/// What else a line holds, by what it is.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub enum Detail {
194    /// `NAME=value` in a crontab (`SHELL`, `PATH`, `MAILTO`, …), for the
195    /// jobs after it.
196    Environment {
197        /// The variable.
198        name: String,
199        /// Its value, quotes removed.
200        value: String,
201    },
202    /// A cron job: schedule, account and command are on the entry.
203    CronJob,
204    /// An anacron job: its period is the entry's schedule.
205    AnacronJob {
206        /// Minutes anacron waits before running it.
207        delay_minutes: u32,
208        /// The job's name (`cron.daily`), naming its timestamp file.
209        id: String,
210    },
211    /// A `Key=value` line in a systemd unit.
212    UnitSetting {
213        /// `Unit`, `Service`, `Timer`, `Install`, ….
214        section: String,
215        /// `ExecStart`, `User`, `Description`, `WantedBy`, `OnCalendar`, ….
216        key: String,
217        /// The value as written, continuation lines joined.
218        value: String,
219        /// For `Exec…=`: the prefixes before the command (`-` failure
220        /// ignored, `@` own argv\[0], `:` no variable expansion, `+` and
221        /// `!` full privileges), removed from the entry's command.
222        exec_prefixes: String,
223    },
224    /// A key in `authorized_keys`.
225    AuthorizedKey(AuthorizedKey),
226    /// A command line in `rc.local`, an init script or a shell start-up
227    /// file.
228    ShellCommand,
229    /// A library in ld.so.preload, the entry's command.
230    PreloadLibrary,
231    /// A sudoers rule: who may run what, where, as whom.
232    SudoRule(SudoRule),
233    /// A sudoers alias, kept as written: rules name it, it isn't expanded.
234    SudoAlias {
235        /// `User_Alias`, `Runas_Alias`, `Host_Alias` or `Cmnd_Alias`.
236        alias_kind: String,
237        /// Its name.
238        name: String,
239        /// What it stands for.
240        members: Vec<String>,
241    },
242    /// A sudoers `Defaults` line.
243    SudoDefaults {
244        /// Whom or what it applies to, with its sigil (`:alice`,
245        /// `@host`, `>root`, `!/bin/sh`), `None` for everyone.
246        scope: Option<String>,
247        /// The settings (`env_reset`, `!authenticate`, …).
248        settings: String,
249    },
250    /// `@include`, `@includedir`, `#include` or `#includedir`: more rules
251    /// read from another file, or every file in a directory.
252    SudoInclude {
253        /// The file or directory.
254        path: String,
255        /// Whether it's a directory.
256        directory: bool,
257    },
258    /// A command line of an at job; account, command and run time are on
259    /// the entry.
260    AtJob {
261        /// The queue, from the file name (`a`, `b`, …; `=` while running).
262        queue: Option<char>,
263        /// The job number, from the file name.
264        job: Option<u32>,
265        /// The account's id, from the header (`# atrun uid=1000`).
266        uid: Option<u32>,
267    },
268    /// A PAM rule.
269    PamRule(PamRule),
270    /// `@include`: the rules of another file in `etc/pam.d`.
271    PamInclude(String),
272    /// A setting of the SSH server.
273    SshdSetting {
274        /// The keyword, as written (`PermitRootLogin`).
275        key: String,
276        /// Its value, quotes removed.
277        value: String,
278        /// The `Match` criteria it applies under (`User backup`), `None`
279        /// for every connection.
280        condition: Option<String>,
281    },
282    /// A udev rule: its pairs in order; the entry's command is what it
283    /// runs.
284    UdevRule(Vec<UdevPair>),
285    /// An XDG autostart entry's command.
286    Autostart {
287        /// Its `Name=`.
288        name: Option<String>,
289        /// `Hidden=true` or `X-GNOME-Autostart-enabled=false`: not started.
290        disabled: bool,
291    },
292    /// A kernel module to load at boot, the entry's command (with its
293    /// parameters, in `etc/modules`).
294    KernelModule,
295    /// A modprobe directive: `install`, `remove`, `options`, `blacklist`,
296    /// `alias`, `softdep`, ….
297    ModprobeDirective {
298        /// The directive.
299        directive: String,
300        /// The module (or alias) it's about.
301        module: String,
302        /// The rest: for `install` and `remove`, the command run instead,
303        /// also the entry's command.
304        arguments: String,
305    },
306    /// An account of `etc/passwd`; its name is the entry's user.
307    Account(Account),
308    /// An account's password state, from `etc/shadow`; its name is the
309    /// entry's user.
310    Password(Password),
311    /// A group with members, from `etc/group`.
312    Group {
313        /// Its name.
314        name: String,
315        /// Its id.
316        gid: Option<u32>,
317        /// Its members, as listed (members by primary group aren't).
318        members: Vec<String>,
319    },
320}
321
322/// One `KEY{attribute}op"value"` pair of a udev rule.
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct UdevPair {
325    /// `ACTION`, `SUBSYSTEM`, `ATTR`, `RUN`, `ENV`, ….
326    pub key: String,
327    /// What's in the braces (`ATTR{idVendor}`, `RUN{builtin}`).
328    pub attribute: Option<String>,
329    /// `==` or `!=` (a match), or `=`, `+=`, `-=`, `:=` (an action).
330    pub operator: String,
331    /// The value, quotes removed and `\"` unescaped.
332    pub value: String,
333}
334
335/// A key that may log in.
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub struct AuthorizedKey {
338    /// Options before the key (`command="…"`, `from="…"`, `no-pty`), in
339    /// order.
340    pub options: Vec<KeyOption>,
341    /// `ssh-ed25519`, `ssh-rsa`, `ecdsa-sha2-nistp256`, ….
342    pub key_type: String,
343    /// The key, base64 as written.
344    pub key: String,
345    /// `SHA256:…`, as `ssh-keygen -l` prints it; `None` when the key isn't
346    /// valid base64.
347    pub fingerprint: Option<String>,
348    /// What follows the key, often `user@host`.
349    pub comment: Option<String>,
350}
351
352/// An option before a key: `no-pty`, or `from="198.51.100.0/24"`.
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct KeyOption {
355    /// Its name, as written.
356    pub name: String,
357    /// Its value, quotes removed and `\"` unescaped.
358    pub value: Option<String>,
359}
360
361/// A sudoers rule: `users hosts = (run-as) TAGS: commands`.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct SudoRule {
364    /// Accounts, `%groups`, `#uids` and aliases it's granted to.
365    pub users: Vec<String>,
366    /// Hosts it applies on (`ALL`).
367    pub hosts: Vec<String>,
368    /// Whom the commands may run as, inside the parentheses
369    /// (`ALL:ALL`); `None` when not written: root.
370    pub run_as: Option<String>,
371    /// Tags and options (`NOPASSWD`, `SETENV`, `CWD=/`), for every command
372    /// they're written before.
373    pub tags: Vec<String>,
374    /// The commands, aliases or `ALL`.
375    pub commands: Vec<String>,
376}
377
378/// A PAM rule: `type control module arguments`.
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct PamRule {
381    /// The service it's for: the file's name in `etc/pam.d`, the first
382    /// word in `etc/pam.conf`.
383    pub service: String,
384    /// `auth`, `account`, `password` or `session`, as written (a leading
385    /// `-` keeps a missing module out of the log).
386    pub rule_type: String,
387    /// `required`, `sufficient`, `include`, … or a bracketed list
388    /// (`[success=1 default=ignore]`).
389    pub control: String,
390    /// The module: a name looked up in the module directory, or a path.
391    pub module: String,
392    /// The module's arguments.
393    pub arguments: Vec<String>,
394}
395
396/// A file's entries.
397#[derive(Debug, Clone, Default, PartialEq, Eq)]
398pub struct Parsed {
399    /// Entries in file order.
400    pub entries: Vec<Entry>,
401    /// Lines that couldn't be read.
402    pub problems: Vec<String>,
403}
404
405impl Parsed {
406    fn problem(&mut self, line: usize, what: &str) {
407        self.problems.push(format!("line {line}: {what}"));
408    }
409}
410
411/// Read a file of `kind` found at `path` on the host. The path names the
412/// account for users' crontabs, keys, start-up files and units.
413#[must_use]
414pub fn parse(kind: Kind, data: &[u8], path: &str) -> Parsed {
415    let text = String::from_utf8_lossy(data);
416    let account = path::account(path);
417    match kind {
418        Kind::Crontab => cron::crontab(&text, cron::Form::User(path::file_name(path))),
419        Kind::SystemCrontab => cron::crontab(&text, cron::Form::System),
420        Kind::Anacrontab => cron::anacrontab(&text),
421        Kind::SystemdUnit => systemd::unit(&text, account),
422        Kind::AuthorizedKeys => ssh::authorized_keys(&text, account),
423        Kind::RcLocal => shell::script(&text, Kind::RcLocal, Some("root")),
424        Kind::ShellInit => shell::script(&text, Kind::ShellInit, account),
425        Kind::LdSoPreload => preload::libraries(data),
426        Kind::Sudoers => sudoers::rules(&text),
427        Kind::AtJob => at::job(&text, path::file_name(path)),
428        Kind::InitScript => shell::script(&text, Kind::InitScript, Some("root")),
429        Kind::Pam => pam::rules(&text, path::pam_service(path)),
430        Kind::SshdConfig => sshd::config(&text),
431        Kind::Udev => udev::rules(&text),
432        Kind::XdgAutostart => autostart::entry(&text, account),
433        Kind::ModulesLoad => modules::load_list(&text),
434        Kind::Modprobe => modules::modprobe(&text),
435        Kind::Passwd => accounts::passwd(&text),
436        Kind::Shadow => accounts::shadow(&text),
437        Kind::Group => accounts::group(&text),
438    }
439}