Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/modules/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use super::{
ParamExt,
};
use crate::connection::{Connection, ExecuteOptions};
use crate::utils::{cmd_escape, powershell_escape, shell_escape};
use crate::utils::{cmd_arg_escape, powershell_escape, shell_escape};
use once_cell::sync::Lazy;
use std::path::Path;
use std::process::Command;
Expand Down Expand Up @@ -89,7 +89,7 @@ impl CommandModule {
let escaped_args: Vec<std::borrow::Cow<'_, str>> = argv
.iter()
.map(|arg| match shell_type.as_str() {
"cmd" => cmd_escape(arg),
"cmd" => cmd_arg_escape(arg),
"powershell" => powershell_escape(arg),
"posix" | "sh" | "bash" => shell_escape(arg),
_ => shell_escape(arg), // Default to POSIX for safety/backward compatibility
Expand Down
6 changes: 4 additions & 2 deletions src/modules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,11 +393,11 @@ pub fn validate_command_args(args: &str) -> ModuleResult<()> {
// If the string contains only safe characters, we can skip the detailed check.
// This avoids checking 24 patterns for every safe string (O(N) vs O(M*N)).
//
// Safe characters: alphanumeric, space, _, -, ., /, :, +, =, ,, @, %
// Safe characters: alphanumeric, space, _, -, ., /, :, +, =, ,, @
let is_safe = args.bytes().all(|b| matches!(b,
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' |
b' ' | b'_' | b'-' | b'.' | b'/' | b':' |
b'+' | b'=' | b',' | b'@' | b'%'
b'+' | b'=' | b',' | b'@'
));

if is_safe {
Expand Down Expand Up @@ -429,6 +429,7 @@ pub fn validate_command_args(args: &str) -> ModuleResult<()> {
("!", "history expansion !"),
("\\", "shell escaping \\"),
("$", "variable expansion $"),
("%", "variable expansion %"),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope percent-sign blocking to Windows cmd contexts

Adding % to dangerous_patterns makes validate_command_args reject all raw commands containing %, even on non-Windows shells where % is commonly valid (for example date +%F or printf '%s\n'). Because CommandModule::validate_params runs this validation before shell-type handling, this introduces a cross-platform behavior regression from a Windows-specific security fix.

Useful? React with πŸ‘Β / πŸ‘Ž.

("#", "shell comment #"),
];

Expand Down Expand Up @@ -2080,6 +2081,7 @@ mod tests {
// Extended checks
assert!(validate_command_args("bash;echo").is_err());
assert!(validate_command_args("cmd&").is_err());
assert!(validate_command_args("echo %USERNAME%").is_err());
}

#[test]
Expand Down
44 changes: 44 additions & 0 deletions src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,39 @@ pub fn cmd_escape(s: &str) -> Cow<'_, str> {
Cow::Owned(escaped)
}

/// Escape a string for use as a single argument in Windows cmd.exe.
///
/// This function wraps the string in double quotes and escapes any internal double quotes,
/// similar to `cmd_escape`. Additionally, it escapes `%` characters by replacing them
/// with `%""` to prevent variable expansion inside the quoted string.
///
/// This is crucial because `cmd.exe` performs variable expansion even inside double quotes.
/// By inserting an empty string `""` after the `%`, we break the variable name token
/// without changing the value (since `""` is empty), effectively preventing expansion.
///
/// # Arguments
///
/// * `s` - The argument string to escape
///
/// # Returns
///
/// * The escaped argument string safe for cmd.exe execution as a literal
pub fn cmd_arg_escape(s: &str) -> Cow<'_, str> {
let mut escaped = String::with_capacity(s.len() + 16);
escaped.push('"');

for c in s.chars() {
match c {
'"' => escaped.push_str("\"\""),
'%' => escaped.push_str("%\"\""),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve literal percent signs in cmd argument escaping

The new cmd_arg_escape mapping '%'=>"%\"\"" still leaves a %...% token in the final command (for example %USERNAME% becomes "%""USERNAME%"""), so cmd.exe can still treat it as environment-variable expansion and rewrite the argument instead of passing a literal %...%. In CommandModule::get_command_string this affects every argv element for shell_type="cmd", so legitimate Windows arguments containing % are now corrupted at execution time.

Useful? React with πŸ‘Β / πŸ‘Ž.

_ => escaped.push(c),
}
}

escaped.push('"');
Cow::Owned(escaped)
}

/// Escapes a string for safe use in PowerShell commands.
///
/// This function handles special characters that could cause issues
Expand Down Expand Up @@ -321,6 +354,17 @@ mod tests {
assert_eq!(cmd_escape(""), "\"\"");
}

#[test]
fn test_cmd_arg_escape() {
assert_eq!(cmd_arg_escape("simple"), "\"simple\"");
assert_eq!(cmd_arg_escape("with space"), "\"with space\"");
assert_eq!(cmd_arg_escape("with\"quote"), "\"with\"\"quote\"");
// Verify % is escaped with %""
assert_eq!(cmd_arg_escape("%USERNAME%"), "\"%\"\"USERNAME%\"\"\"");
assert_eq!(cmd_arg_escape("100%"), "\"100%\"\"\"");
assert_eq!(cmd_arg_escape(""), "\"\"");
}

#[test]
fn test_powershell_escape() {
assert_eq!(powershell_escape("simple"), "'simple'");
Expand Down
43 changes: 43 additions & 0 deletions tests/security_windows_command_injection.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
use rustible::modules::{validate_command_args, command::CommandModule, Module, ModuleParams, ModuleContext};
use rustible::utils::cmd_arg_escape;
use std::collections::HashMap;

#[test]
fn test_validate_command_args_blocks_percent() {
// This should now fail (Err) because we added % to dangerous_patterns
assert!(validate_command_args("echo %USERNAME%").is_err());
}

#[test]
fn test_cmd_arg_escape_escapes_percent() {
let input = "%USERNAME%";
let escaped = cmd_arg_escape(input);
// Should now return "%""USERNAME%" inside quotes (outer quotes + inner escaped quotes)
// cmd_arg_escape wraps in "...", so result is "%""USERNAME%"
// Wait, escaped string content: "%""USERNAME%"
// Representation in Rust string literal: "\"%\"\"USERNAME%\"\"\""
assert_eq!(escaped, "\"%\"\"USERNAME%\"\"\"");
}

#[test]
fn test_command_module_argv_escapes_percent() {
let module = CommandModule;
let mut params: ModuleParams = HashMap::new();
params.insert(
"argv".to_string(),
serde_json::json!(["echo", "%USERNAME%"]),
);
params.insert("shell_type".to_string(), serde_json::json!("cmd"));

let context = ModuleContext::default().with_check_mode(true);

let result = module.execute(&params, &context).unwrap();
let msg = result.msg;

// msg format: "Would execute: <cmd>"
// cmd should be: "echo" "%""USERNAME%"
// Expected substring in msg: "\"%\"\"USERNAME%\"\"\""

println!("Message: {}", msg);
assert!(msg.contains("\"%\"\"USERNAME%\"\"\""));
}
Loading