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 and cmd extensions) on Windows using the Command 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 等均受影响,具体可以参考如下页面:

  1. CERT/CC: Multiple programming languages fail to escape arguments properly in Microsoft Windows
  2. 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::argsQuoteAlwaysAuto 两种模式
  • 原始参数,即 CommandExt::raw_argQuoteNever

对 Rust 而言,如果使用普通参数,那么 Rust 会帮助对参数字符进行转义处理;而如果使用原始参数,则参数字符的转义由开发者自己负责。对 CVE-2024-24576 这个漏洞而言,是指使用普通参数的情况下,Rust 没有处理好参数的转义,导致引发了注入漏洞。所以这里只看普通参数的场景,quoteescape 的可能取值如下:

  • 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
@@ -7,7 +7,7 @@
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;
@@ -17,6 +17,7 @@
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.cmdCreateProcess 本身含有特殊的处理逻辑),但转义的逻辑是一致的,所以不影响漏洞触发。

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 set lpCommandLine to the following arguments: /c plus the name of the batch file.

实际上,lpApplicationName 可以是 .bat 文件的路径,CreateProcessW 会自动进行相应的转换操作,测试代码如下:

#include <Windows.h>
#include <stdio.h>

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 系统中,启动子进程时可以通过数组的方式指定 argvenvp(比如调用 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

不知道是作者自己也没有弄清楚,还是故意留了一手 :D 至于真实的解析逻辑,得看 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. 参考文档

  1. https://flatt.tech/research/posts/batbadbut-you-cant-securely-execute-commands-on-windows/
  2. https://blog.rust-lang.org/2024/04/09/cve-2024-24576.html
  3. https://www.kb.cert.org/vuls/id/123335
  4. https://github.com/frostb1ten/CVE-2024-24576-PoC/tree/main
  5. https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw
  6. https://learn.microsoft.com/en-us/archive/blogs/twistylittlepassagesallalike/everyone-quotes-command-line-arguments-the-wrong-way
  7. https://persistence-info.github.io/Data/cmdautorun.html
请作者喝杯咖啡☕