Root cause
Injection & unsafe execution introduced
feat: add Android device control tool (feature-gated)
How AI contributed: Direct introductionPublished Mar 5, 2026 in
qhkm/zeptoclawAll 5 findings in this repository
Android device_shell() blocklist bypass via argument permutation (HIGH, Rust crate zeptoclaw, affected <= 0.6.1, patched 0.6.2): src/tools/android/actions.rs device_shell normalizes whitespace then matches a blocked-array of literal substrings ("rm -rf", "rm -r", reboot, factory_reset, wipe, format, dd if=, mkfs, flash, fastboot) with lower.contains(pattern). Permuted/flags-combined invocations (rm -fr, rm -f -r, rm --recursive --force, busybox/toybox rm -fr) do not contain the literal substrings and reach the device via adb shell, defeating the destructive-command blocklist.
Root cause
feat: add Android device control tool (feature-gated)
Fix
The fix parses rm arguments and requires recursive and force flags as a set, so flag reordering, combination, and long-form spellings cannot bypass the destructive-command block.
Case facts
@@ -0,0 +1,461 @@+//! Android action handlers.+//!+//! Implements all ADB actions: navigation, text input, clipboard,+//! app management, and device control. Includes text escaping for+//! shell metacharacters and coordinate sanitization.++use crate::error::{Result, ZeptoError};++use super::adb::AdbExecutor;++/// Escape text for ADB shell `input text` command.+///+/// ADB `input text` requires escaping of shell metacharacters:+/// `\ " ' \` $ ! ? & | ; ( ) [ ] { } < > (space)`+pub fn escape_adb_text(text: &str) -> String {+ let mut escaped = String::with_capacity(text.len() * 2);+ for ch in text.chars() {+ match ch {+ ' ' => escaped.push_str("%s"),+ '\\' | '"' | '\'' | '`' | '$' | '!' | '?' | '&' | '|' | ';' | '(' | ')' | '[' | ']'+ | '{' | '}' | '<' | '>' => {+ escaped.push('\\');+ escaped.push(ch);+ }+ _ => escaped.push(ch),+ }+ }+ escaped+}++/// Parse and sanitize coordinates from various input formats.+///+/// Supports:+/// - `[x, y]` — normal JSON array+/// - `"828, 2017"` — string with comma/space+/// - `8282017` — concatenated digits (tries split at positions 2-4)+pub fn parse_coordinates(+ x_val: Option<&serde_json::Value>,+ y_val: Option<&serde_json::Value>,+ coords_val: Option<&serde_json::Value>,+) -> Result<(i32, i32)> {+ // Try explicit x, y first+ if let (Some(x), Some(y)) = (x_val, y_val) {+ let x = value_to_i32(x)?;+ let y = value_to_i32(y)?;+ return validate_coords(x, y);+ }++ // Try coords as string "x, y" or "x y"+ if let Some(coords) = coords_val.and_then(|v| v.as_str()) {+ let parts: Vec<&str> = coords.split([',', ' ']).filter(|s| !s.is_empty()).collect();+ if parts.len() == 2 {+ let x = parts[0]+ .trim()+ .parse::<i32>()+ .map_err(|_| ZeptoError::Tool("Invalid x coordinate".into()))?;+ let y = parts[1]+ .trim()+ .parse::<i32>()+ .map_err(|_| ZeptoError::Tool("Invalid y coordinate".into()))?;+ return validate_coords(x, y);+ }+ }++ // Try coords as array [x, y]+ if let Some(arr) = coords_val.and_then(|v| v.as_array()) {+ if arr.len() == 2 {+ let x = value_to_i32(&arr[0])?;+ let y = value_to_i32(&arr[1])?;+ return validate_coords(x, y);+ }+ }++ // Try concatenated number (e.g., 8282017)+ if let Some(n) = coords_val.and_then(|v| v.as_i64()) {+ let s = n.to_string();+ if s.len() >= 4 && s.len() <= 9 {+ // Try splits at positions 2, 3, 4+ for split_pos in 2..=4.min(s.len() - 1) {+ if let (Ok(x), Ok(y)) =+ (s[..split_pos].parse::<i32>(), s[split_pos..].parse::<i32>())+ {+ if (0..=10000).contains(&x) && (0..=10000).contains(&y) {+ return Ok((x, y));+ }+ }+ }+ }+ }@@ -404,15 +404,105 @@ pub async fn wake_screen(adb: &AdbExecutor) -> Result<String> { Ok("Screen woken".into()) } +fn token_basename(token: &str) -> &str {+ token.rsplit('/').next().unwrap_or(token)+}++/// Returns the argument slice for an `rm` invocation, supporting:+/// - `rm ...`+/// - `/system/bin/rm ...`+/// - `busybox rm ...` / `toybox rm ...` (including path-prefixed busybox/toybox)+fn rm_invocation_args<'a>(tokens: &'a [&'a str]) -> Option<&'a [&'a str]> {+ if tokens.is_empty() {+ return None;+ }++ let first = token_basename(tokens[0]);+ if first == "rm" {+ return Some(&tokens[1..]);+ }++ if (first == "busybox" || first == "toybox")+ && tokens.get(1).is_some_and(|sub| token_basename(sub) == "rm")+ {+ return Some(&tokens[2..]);+ }++ None+}++/// Check if a command invokes `rm` and contains both `-r` and `-f` flags+/// in any order or combination (e.g., `rm -rf`, `rm -fr`, `rm -r -f`,+/// `rm --recursive --force`, `rm -r --force`, etc.).+fn is_rm_recursive_force(tokens: &[&str]) -> bool {+ let Some(rm_args) = rm_invocation_args(tokens) else {+ return false;+ };+ let mut has_r = false;+ let mut has_f = false;+ for &tok in rm_args {+ if tok.starts_with("--") {+ match tok {+ "--recursive" => has_r = true,+ "--force" => has_f = true,+ _ => {}+ }+ } else if tok.starts_with('-') && !tok.starts_with("--") {+ // Short flags: `-rf`, `-r`, `-f`, `-fr`, `-r -f`, etc.+ let flags = &tok[1..];+ if flags.contains('r') {+ has_r = true;+ }+ if flags.contains('f') {+ has_f = true;+ }+ }+ }+ has_r && has_f+}+ /// Run an arbitrary shell command on the device. pub async fn device_shell(adb: &AdbExecutor, cmd: &str) -> Result<String> {+ // Block shell metacharacters that enable command chaining.+ // Check this FIRST — before normalization — to prevent bypass via+ // metacharacters that the tokenizer would otherwise split on.+ let dangerous_chars = [';', '|', '`', '$', '&', '\n'];+ for ch in &dangerous_chars {+ if cmd.contains(*ch) {+ return Err(ZeptoError::Tool(format!(+ "Blocked shell metacharacter '{}' in command. Use specific actions instead of raw shell.",+ ch+ )));+ }+ }+ // Normalize whitespace for blocklist check- let normalized: String = cmd.split_whitespace().collect::<Vec<_>>().join(" ");+ let tokens: Vec<&str> = cmd.split_whitespace().collect();+ let normalized = tokens.join(" "); let lower = normalized.to_lowercase(); - let blocked = [- "rm -rf",- "rm -r",+ // Flag-aware check for rm with recursive + force (any order/combination)+ let lower_tokens: Vec<&str> = lower.split_whitespace().collect();+ if is_rm_recursive_force(&lower_tokens) {+ return Err(ZeptoError::Tool(+ "Blocked dangerous command: rm with recursive + force flags".to_string(),AI-assisted change 907287ae92ea4b704755e18d6e2414bff9c0e306e16bbc10fbda012eafd8b19e · Fix 62ef8a7f5a0aa9fefd3cc5accebd142611776bb383561fdbaa2d6f376c7446f5