1. 开局信息收集:先把服务从黑盒翻成白盒
1.1 拿到题目地址后的第一轮探测
NCTF 的 Web 题向来不按套路出牌,这次看到 N-RustPICA 这个名字的时候,我就知道不是普通的 PHP 或者 Node 题了——题目名里直接写明了 Rust。CTF 里带 Rust 的 Web 题,要么是 Rust 写的反向代理,要么是一个完整的 Rust Web 服务,而且漏洞点往往藏在框架之外,比如 unsafe 代码、反序列化边界、整数处理,甚至是在线进程操作这种偏 Pwn 的功能。
拿到靶机地址之后,我习惯先做一轮最基础的探测,不要一上来就上扫描器。打开终端,先用 curl 看响应头和服务信息:
bash复制curl -i http://target:port/
curl -i http://target:port/robots.txt
curl -i http://target:port/healthz
curl -X OPTIONS http://target:port/ -i
第一轮返回的响应头信息量很大。如果 Server 字段是 nginx,那说明前面还有层反代,真实 Rust 服务在内部;如果出现 content-type: application/json,说明这服务大概率是一个纯 API 服务;如果 / 直接返回 404 或者一段 HTML,那说明路由没有匹配根路径,没必要跟根路径死磕。
实际操作里,我在第一次请求就发现了一个非常关键的细节:访问一个不存在的路径时,服务返回的是非常熟悉的 Rust 风格 404 页面——纯文本、简短、没有花哨的 HTML 框架代码。这基本可以断定是 axum、actix-web 或者 rocket 这类主流框架,接下来做字典 fuzz 的时候可以有的放矢。
另外,现在很多 CTF 平台给的容器入口是一个 web 终端,第一次打开可能会提示 dsh web authentication required; reopen the url printed by dsh web. 这种信息,这是容器管理平台在跟你做一次浏览器会话认证,不是什么漏洞,不用慌。重新打开一次平台弹出的跳板 URL,让浏览器带上认证信息之后,再访问题目地址就好。
1.2 从响应头里读出的 Rust 痕迹
Rust 的 Web 框架跟传统框架有个很大区别:默认情况下,它不是“文件驱动”的。PHP 套件经常能在报错里看到文件路径,比如 /var/www/html/index.php,而 Rust 服务经常给出的是路由名和端口,比如 listening on 0.0.0.0:8080。
如果你用 curl 发起了一个带 Content-Type: application/json 的 POST 请求,但 body 不是合法 JSON,Rust 的 serde 会直接返回 400 或者 422,并且在响应体里给出非常明确的字段级错误。这点和 Spring 或者 Flask 不一样,后两者经常吞掉错误细节,而 Rust 的 serde_json::from_str 在未做统一异常处理时会把错误原样带出来,这就是一个很好的“代码提示器”。
举个例子,我向一个 /api/parse 接口发送了 {"filename":"Cargo.toml"},如果响应是:
json复制{"error":"missing field `path` at line 1 column 24"}
那就等于告诉我代码里定义了一个结构体,里面有一个必填字段叫 path。这道题面瞬间就打开了,后续构造参数都知道往哪个方向试。
还有一个判断点:看返回的 500 页面或者 panic 信息。Rust 的安全模型保证大多数时候不会像 C 那样直接段错误,但 unwrap() 或者 expect() 依然会让程序 panic,如果题目作者没有配置自定义的 panic hook,actix-web 或者 axum 会返回一个包含 panic 信息的响应,有时候甚至会带出错代码所在的行号。行号这种信息在 CTF 里太珍贵了,等于直接白送了一部分代码结构。
1.3 环境交互接口不要漏
很多容器化部署的 Rust Web 服务都会留一个初始化接口或者健康检查接口,CTF 平台往往通过 HTTP 探测来判断容器是否启动完成。这类接口可能是 /init、/healthz、/ready、/start,也有可能是 POST /api/setup。
在 N-RustPICA 这道题里,我后来发现服务启动时会读取一个配置文件,如果配置不存在就返回一个非常奇怪的错误码。这个特性一开始被我当成无用信息,结果它就是某条漏洞链的入口之一——文件上传完成后需要触发一次“配置重载”,而这个重载动作恰恰就是后面命令注入的触发点。
所以拿到题目地址之后,除了 fuzz 常规路径,建议手工测一下 GET /healthz、POST /init、GET /status 这类和生命周期相关的接口。这类接口通常没有复杂的权限校验,是作者最容易放松警惕、直接往里塞业务逻辑的地方。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Rust Web 服务的安全特性与考点拆解
2.1 所有权与借用检查:为什么安全语言还会出 Web 漏洞
Rust 社区经常宣传“内存安全”,但这指的是它能在编译期阻止悬垂指针、缓冲区溢出和 use-after-free 这类经典内存漏洞,不代表它天然免疫 Web 漏洞。所有权、借用检查这些机制解决的是“内存安全”,但解决不了“逻辑安全”。
在 CTF 里,这道题恰恰就是“内存安全已经拉满,逻辑安全全是洞”的典型。Rust 的所有权系统强制要求一个值同时只能有一个所有者,同一时刻要么只能有一个可变借用,要么有多个只读借用。这套规则保证了数据竞争在安全代码里不会出现,但是题目作者如果写了一些 unsafe 代码来规避借用检查,比如用 Box::into_raw 把指针丢到全局变量里,那漏洞就来了。
我在做这道题的时候,顺着 rust 所有权系统, 借用检查, 生命周期 这几个关键词去复盘,发现题目里有一个并发点:服务用了一个全局 HashMap 来缓存用户上传的“补丁包”,每次请求都会先查缓存,如果缓存里没有就重新加载。这里就存在一个经典的“检查-使用”竞态窗口——两个请求同时提交同一个文件名,一个负责删除缓存,一个负责读取缓存,最后把缓存里的路径用于命令拼接时,路径已经被篡改了。
这类问题用 Rust 的可变借用视角去看,就是“缓存对象的生命周期比请求长,但对缓存的修改和读取没有做串行化”。在真实开发里,大家会用 Mutex 或者 RwLock 来包一层,但如果题目作者图省事,用了一个 Arc<Mutex<HashMap>> 但锁的粒度太粗或太细,依然会有可乘之机。
2.2 生命周期、unsafe 代码与 Web 漏洞的结合点
生命周期本身是 Rust 编译器用来保证引用的有效范围不超过被引用者的生存范围的机制。正常情况下,生
