From 801b45ae7e036c3e4645797dd571ea70da769835 Mon Sep 17 00:00:00 2001 From: gouhongshen Date: Fri, 4 Sep 2026 18:52:59 +0800 Subject: [PATCH 1/2] fix(sandbox): parse launcher option arity --- crates/astra-sandbox/src/bash_ast.rs | 245 ++++++++++++++++++++++++--- crates/astra-tools/src/shell_ops.rs | 5 + 2 files changed, 223 insertions(+), 27 deletions(-) diff --git a/crates/astra-sandbox/src/bash_ast.rs b/crates/astra-sandbox/src/bash_ast.rs index dc197847a..2f63037ff 100644 --- a/crates/astra-sandbox/src/bash_ast.rs +++ b/crates/astra-sandbox/src/bash_ast.rs @@ -379,15 +379,69 @@ fn resolve_transparent_launcher( let executable = command_basename(word.literal().ok_or(())?); index += 1; match executable.as_str() { - "command" | "builtin" | "exec" | "nohup" => { - let Some(next) = skip_literal_options(words, index, &[])? else { + "command" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new("pVv", "", &[], &[], &[]), + )? + else { + return Ok(None); + }; + index = next; + } + "builtin" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new("", "", &["--help"], &[], &[]), + )? + else { + return Ok(None); + }; + index = next; + } + "exec" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new("cl", "a", &["--help"], &[], &[]), + )? + else { + return Ok(None); + }; + index = next; + } + "nohup" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new("", "", &["--help", "--version"], &[], &[]), + )? + else { return Ok(None); }; index = next; } "env" => { - let Some(next) = - skip_literal_options(words, index, &["-u", "--unset", "-C", "--chdir"])? + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new( + "i0v", + "uCPa", + &[ + "--ignore-environment", + "--null", + "--debug", + "--list-signal-handling", + "--help", + "--version", + ], + &["--unset", "--chdir", "--path", "--argv0"], + &["--block-signal", "--default-signal", "--ignore-signal"], + ), + )? else { return Ok(None); }; @@ -400,24 +454,74 @@ fn resolve_transparent_launcher( index += 1; } } - "sudo" | "doas" | "pkexec" => { - let Some(next) = skip_literal_options( + "sudo" => { + let Some(next) = skip_launcher_options( words, index, - &[ - "-u", - "--user", - "-g", - "--group", - "-h", - "--host", - "-p", - "--prompt", - "-R", - "--chroot", - "-C", - "--close-from", - ], + LauncherOptionGrammar::new( + "ABbEHikKlNnPSseVv", + "CDghpRTUurt", + &[ + "--askpass", + "--background", + "--bell", + "--edit", + "--set-home", + "--help", + "--login", + "--remove-timestamp", + "--reset-timestamp", + "--list", + "--non-interactive", + "--preserve-groups", + "--stdin", + "--shell", + "--version", + "--validate", + ], + &[ + "--close-from", + "--chdir", + "--group", + "--host", + "--prompt", + "--chroot", + "--command-timeout", + "--other-user", + "--role", + "--type", + "--user", + ], + &["--preserve-env"], + ), + )? + else { + return Ok(None); + }; + index = next; + } + "doas" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new("Lns", "aCu", &[], &[], &[]), + )? + else { + return Ok(None); + }; + index = next; + } + "pkexec" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new( + "", + "", + &["--disable-internal-agent", "--keep-cwd", "--version"], + &["--user"], + &[], + ), )? else { return Ok(None); @@ -429,10 +533,37 @@ fn resolve_transparent_launcher( } } -fn skip_literal_options( +#[derive(Clone, Copy)] +struct LauncherOptionGrammar { + short_flags: &'static str, + short_options_with_value: &'static str, + long_flags: &'static [&'static str], + long_options_with_value: &'static [&'static str], + long_options_with_optional_value: &'static [&'static str], +} + +impl LauncherOptionGrammar { + const fn new( + short_flags: &'static str, + short_options_with_value: &'static str, + long_flags: &'static [&'static str], + long_options_with_value: &'static [&'static str], + long_options_with_optional_value: &'static [&'static str], + ) -> Self { + Self { + short_flags, + short_options_with_value, + long_flags, + long_options_with_value, + long_options_with_optional_value, + } + } +} + +fn skip_launcher_options( words: &[CommandWord], mut index: usize, - options_with_value: &[&str], + grammar: LauncherOptionGrammar, ) -> Result, ()> { while let Some(word) = words.get(index) { let argument = word.literal().ok_or(())?; @@ -442,14 +573,50 @@ fn skip_literal_options( if !argument.starts_with('-') || argument == "-" { return Ok(Some(index)); } - let option = argument.split_once('=').map_or(argument, |(name, _)| name); - index += 1; - if options_with_value.contains(&option) && !argument.contains('=') { - if words.get(index).is_none() { - return Ok(None); + + if argument.starts_with("--") { + let (option, inline_value) = argument + .split_once('=') + .map_or((argument, false), |(name, _)| (name, true)); + if grammar.long_flags.contains(&option) && !inline_value + || grammar.long_options_with_optional_value.contains(&option) + { + index += 1; + continue; + } + if !grammar.long_options_with_value.contains(&option) { + return Err(()); } index += 1; + if !inline_value { + if words.get(index).is_none() { + return Ok(None); + } + index += 1; + } + continue; } + + let mut flags = argument[1..].chars().peekable(); + if flags.peek().is_none() { + return Ok(Some(index)); + } + while let Some(flag) = flags.next() { + if grammar.short_flags.contains(flag) { + continue; + } + if !grammar.short_options_with_value.contains(flag) { + return Err(()); + } + if flags.peek().is_none() { + index += 1; + if words.get(index).is_none() { + return Ok(None); + } + } + break; + } + index += 1; } Ok(None) } @@ -933,11 +1100,18 @@ mod tests { "command dd if=/dev/zero of=/dev/sda", "builtin dd if=/dev/zero of=/dev/sda", "exec dd if=/dev/zero of=/dev/sda", + "exec -a alias dd if=/dev/zero of=/dev/sda", + "exec -cla alias dd if=/dev/zero of=/dev/sda", + "exec -a \"$alias\" dd if=/dev/zero of=/dev/sda", "nohup dd if=/dev/zero of=/dev/sda", "sudo wipefs -a /dev/sdb", + "sudo -D /tmp dd if=/dev/zero of=/dev/sda", "doas wipefs -a /dev/sdb", + "doas -a passwd dd if=/dev/zero of=/dev/sda", "pkexec wipefs -a /dev/sdb", + "pkexec --user root dd if=/dev/zero of=/dev/sda", "env MODE=secure shred -u secrets.txt", + "env -u HOME dd if=/dev/zero of=/dev/sda", "bash -lc 'dd if=/dev/zero of=/dev/sda'", "bash -oc pipefail 'dd if=/dev/zero of=/dev/sda'", "bash -oO pipefail extglob -c 'wipefs -a /dev/sdb'", @@ -973,6 +1147,9 @@ mod tests { "bash -oc pipefail 'echo dd'", "bash --norc -c 'echo dd'", "bash --rcfile /tmp/bashrc -c 'echo dd'", + "exec -a alias printf '%s\\n' dd", + "env -u HOME printf '%s\\n' dd", + "sudo -u root printf '%s\\n' dd", "busybox echo dd", "printf '%s\\n' dd | xargs printf '%s\\n'", "printf '%s\\n' dd | xargs", @@ -1016,6 +1193,20 @@ mod tests { } } + #[test] + fn ambiguous_launcher_options_fail_closed() { + for command in [ + "exec --future-option dd if=/dev/zero of=/dev/sda", + "sudo --future-option dd if=/dev/zero of=/dev/sda", + "env -S 'dd if=/dev/zero of=/dev/sda'", + ] { + assert!( + analyze_bash_risks_ast(command).contains(&CommandRisk::RemoteCodeExecution), + "launcher option with unproven arity must fail closed: {command}" + ); + } + } + #[test] fn chmod_setuid_variants() { for cmd in [ diff --git a/crates/astra-tools/src/shell_ops.rs b/crates/astra-tools/src/shell_ops.rs index bb580b32b..dbcf070bd 100644 --- a/crates/astra-tools/src/shell_ops.rs +++ b/crates/astra-tools/src/shell_ops.rs @@ -5110,6 +5110,11 @@ printf 'probe.txt:1:needle\n' fn validate_execute_bash_blocks_top5_security_risks() { for command in [ "dd if=/dev/zero of=/dev/sda", + "exec -a alias dd if=/dev/zero of=/dev/sda", + "sudo -D /tmp dd if=/dev/zero of=/dev/sda", + "doas -a passwd dd if=/dev/zero of=/dev/sda", + "pkexec --user root dd if=/dev/zero of=/dev/sda", + "env -u HOME dd if=/dev/zero of=/dev/sda", "bash --norc -c 'dd if=/dev/zero of=/dev/sda'", "env MODE=secure bash --rcfile /tmp/bashrc -c 'wipefs -a /dev/sdb'", "busybox dd if=/dev/zero of=/dev/sda", From 3fdec66d6ddb833d2ae372a2c51b41bc36fd55ea Mon Sep 17 00:00:00 2001 From: gouhongshen Date: Fri, 4 Sep 2026 22:05:30 +0800 Subject: [PATCH 2/2] fix(sandbox): resolve common command dispatchers --- crates/astra-sandbox/src/bash_ast.rs | 121 +++++++++++++++++++++++++++ crates/astra-tools/src/shell_ops.rs | 25 ++++++ 2 files changed, 146 insertions(+) diff --git a/crates/astra-sandbox/src/bash_ast.rs b/crates/astra-sandbox/src/bash_ast.rs index 2f63037ff..0f82dde1f 100644 --- a/crates/astra-sandbox/src/bash_ast.rs +++ b/crates/astra-sandbox/src/bash_ast.rs @@ -528,6 +528,84 @@ fn resolve_transparent_launcher( }; index = next; } + "timeout" | "gtimeout" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new( + "fpv", + "ks", + &[ + "--foreground", + "--preserve-status", + "--verbose", + "--help", + "--version", + ], + &["--kill-after", "--signal"], + &[], + ), + )? + else { + return Ok(None); + }; + let Some(next) = skip_launcher_operands(words, next, 1)? else { + return Ok(None); + }; + index = next; + } + "nice" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new( + "", + "n", + &["--help", "--version"], + &["--adjustment"], + &[], + ) + .with_legacy_numeric_short_option(), + )? + else { + return Ok(None); + }; + index = next; + } + "ionice" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new( + "thV", + "cnpPu", + &["--ignore", "--help", "--version"], + &["--class", "--classdata", "--pid", "--pgid", "--uid"], + &[], + ), + )? + else { + return Ok(None); + }; + index = next; + } + "setsid" => { + let Some(next) = skip_launcher_options( + words, + index, + LauncherOptionGrammar::new( + "cfwhV", + "", + &["--ctty", "--fork", "--wait", "--help", "--version"], + &[], + &[], + ), + )? + else { + return Ok(None); + }; + index = next; + } _ => return Ok(Some(index - 1)), } } @@ -540,6 +618,7 @@ struct LauncherOptionGrammar { long_flags: &'static [&'static str], long_options_with_value: &'static [&'static str], long_options_with_optional_value: &'static [&'static str], + legacy_numeric_short_option: bool, } impl LauncherOptionGrammar { @@ -556,8 +635,14 @@ impl LauncherOptionGrammar { long_flags, long_options_with_value, long_options_with_optional_value, + legacy_numeric_short_option: false, } } + + const fn with_legacy_numeric_short_option(mut self) -> Self { + self.legacy_numeric_short_option = true; + self + } } fn skip_launcher_options( @@ -597,6 +682,15 @@ fn skip_launcher_options( continue; } + if grammar.legacy_numeric_short_option + && argument.strip_prefix('-').is_some_and(|value| { + !value.is_empty() && value.chars().all(|ch| ch.is_ascii_digit()) + }) + { + index += 1; + continue; + } + let mut flags = argument[1..].chars().peekable(); if flags.peek().is_none() { return Ok(Some(index)); @@ -621,6 +715,18 @@ fn skip_launcher_options( Ok(None) } +fn skip_launcher_operands( + words: &[CommandWord], + index: usize, + count: usize, +) -> Result, ()> { + let command_index = index.checked_add(count).ok_or(())?; + if command_index > words.len() { + return Ok(None); + } + Ok((command_index < words.len()).then_some(command_index)) +} + fn resolve_multicall_applet( words: &[CommandWord], shell_depth: usize, @@ -1123,6 +1229,13 @@ mod tests { "busybox dd if=/dev/zero of=/dev/sda", "toybox wipefs -a /dev/sdb", "sudo busybox dd if=/dev/zero of=/dev/sda", + "timeout 5 dd if=/dev/zero of=/dev/sda", + "sudo timeout -s KILL 5 dd if=/dev/zero of=/dev/sda", + "nice -n 5 wipefs -a /dev/sdb", + "nice -5 dd if=/dev/zero of=/dev/sda", + "ionice -c 2 dd if=/dev/zero of=/dev/sda", + "setsid wipefs -a /dev/sdb", + "setsid env MODE=secure timeout 5 sudo dd if=/dev/zero of=/dev/sda", "printf '%s\\n' data | xargs -n 1 dd if=/dev/zero of=/dev/sda", "find . -exec dd if=/dev/zero of=/dev/sda {} \\;", "find . -execdir sh -c 'wipefs -a /dev/sdb' {} \\;", @@ -1150,6 +1263,10 @@ mod tests { "exec -a alias printf '%s\\n' dd", "env -u HOME printf '%s\\n' dd", "sudo -u root printf '%s\\n' dd", + "timeout 5 printf '%s\\n' dd", + "nice -n 5 printf '%s\\n' dd", + "ionice -c 2 printf '%s\\n' dd", + "setsid printf '%s\\n' dd", "busybox echo dd", "printf '%s\\n' dd | xargs printf '%s\\n'", "printf '%s\\n' dd | xargs", @@ -1199,6 +1316,10 @@ mod tests { "exec --future-option dd if=/dev/zero of=/dev/sda", "sudo --future-option dd if=/dev/zero of=/dev/sda", "env -S 'dd if=/dev/zero of=/dev/sda'", + "timeout --future-option 5 dd if=/dev/zero of=/dev/sda", + "nice --future-option dd if=/dev/zero of=/dev/sda", + "ionice --future-option dd if=/dev/zero of=/dev/sda", + "setsid --future-option dd if=/dev/zero of=/dev/sda", ] { assert!( analyze_bash_risks_ast(command).contains(&CommandRisk::RemoteCodeExecution), diff --git a/crates/astra-tools/src/shell_ops.rs b/crates/astra-tools/src/shell_ops.rs index dbcf070bd..bbf7103d8 100644 --- a/crates/astra-tools/src/shell_ops.rs +++ b/crates/astra-tools/src/shell_ops.rs @@ -5118,6 +5118,12 @@ printf 'probe.txt:1:needle\n' "bash --norc -c 'dd if=/dev/zero of=/dev/sda'", "env MODE=secure bash --rcfile /tmp/bashrc -c 'wipefs -a /dev/sdb'", "busybox dd if=/dev/zero of=/dev/sda", + "timeout 5 dd if=/dev/zero of=/dev/sda", + "sudo timeout -s KILL 5 dd if=/dev/zero of=/dev/sda", + "nice -n 5 wipefs -a /dev/sdb", + "ionice -c 2 dd if=/dev/zero of=/dev/sda", + "setsid wipefs -a /dev/sdb", + "setsid env MODE=secure timeout 5 sudo dd if=/dev/zero of=/dev/sda", "printf data | xargs dd if=/dev/zero of=/dev/sda", "find . -exec wipefs -a /dev/sdb {} \\;", "tool=dd; \"$tool\" if=/dev/zero of=/dev/sda", @@ -5146,6 +5152,25 @@ printf 'probe.txt:1:needle\n' fn validate_execute_bash_allows_typical_build_commands() { assert!(validate_execute_bash_command("cargo test -p foo --quiet").is_ok()); assert!(validate_execute_bash_command("echo hello && ls").is_ok()); + assert!(validate_execute_bash_command("timeout 5 printf '%s\\n' dd").is_ok()); + assert!(validate_execute_bash_command("nice -n 5 printf '%s\\n' dd").is_ok()); + assert!(validate_execute_bash_command("ionice -c 2 printf '%s\\n' dd").is_ok()); + assert!(validate_execute_bash_command("setsid printf '%s\\n' dd").is_ok()); + } + + #[test] + fn validate_execute_bash_rejects_ambiguous_dispatcher_options() { + for command in [ + "timeout --future-option 5 dd if=/dev/zero of=/dev/sda", + "nice --future-option dd if=/dev/zero of=/dev/sda", + "ionice --future-option dd if=/dev/zero of=/dev/sda", + "setsid --future-option dd if=/dev/zero of=/dev/sda", + ] { + assert!( + validate_execute_bash_command(command).is_err(), + "unknown dispatcher option arity must fail closed: {command}" + ); + } } #[test]