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}