先说结论:2026 NCTF Web方向这道 N-RustPICA 我前后折腾了快一天,最终卡点在“Rust 侧解析器与 CPython 解析器的差异”上,绕进去之后反而觉得这题出得很讲究。它既不是单纯考 Python 沙箱逃逸,也不是考 Rust 代码审计,而是把两者揉在一起,让你在跨语言边界上找缝隙。
如果你正准备打 CTF 的 Web 方向,或者对“ Rust 内嵌 Python 做沙箱”这种玩法感兴趣,这篇复盘应该能帮你省下大量试错时间。我会从信息收集开始,把环境结构、沙箱规则、绕过思路、最终利用完整走一遍,中间包含我踩进去的坑和事后验证过的细节。
1. 拿到题目先看什么:环境探测与信息收集
打 CTF 有一个习惯不能省:不管题目叫什么,先做“最小信息采集”。题目名字里的信息量往往比你想的大,尤其是这种看起来像自造词的题目。
1.1 看到“N-RustPICA”这个名字,我第一反应
2026-NCTF-web-N-RustPICA 拆开看就三块:
NCTF是比赛名称,Web 方向,说明这是一道 Web 安全题;Rust大概率指服务端开发语言,也可能是某个功能模块用 Rust 实现;PICA不是随便起的,它对应 Python 生态里的一个 AST 构建库,全称是“Python Internal Compiler Architecture”相关工具链,常被用来做代码分析、语法树构造。CTF 题目很喜欢这种双关命名:Rust + PICA 意味着服务端可能用 Rust 解析 Python 代码。
因为 Web + Rust + Python AST 这三个关键词交叉在一起,我第一判断是:这八成是一个“Rust 编写的 Web 服务,内部嵌了 Python 解释器,并且加了一层 AST 沙箱”。也就是说,选手要提交 Python 代码,服务端会用 Rust 侧的解析器检查代码结构,再交给真实的 Python 解释器执行。
这种双语言结构在真实工程里不少见:Rust 追求性能和内存安全,Python 追求开发效率,两者通过 PyO3 之类的绑定层结合。在 CTF 里,它天然地把“解析器差异”变成了考点——先解析的是一套规则,真正执行的又是另一套逻辑,中间那条缝就是突破口。
1.2 从首页信息能拿到什么
打开题目环境,首页是一个非常简洁的页面,只给了一个 POST 接口 /run,请求体是 JSON:
json复制{"code": "1 + 1"}
返回内容也很直白:
json复制{"result": 2}
没有任何源码下载入口,没有报错堆栈,也没有提示 flag 的位置。这种行为模式在 CTF 里很常见:出题人故意把后端逻辑藏起来,逼迫你先判断语言栈和沙箱规则。
首页 HTML 的响应头暴露了一个信号:Server: axum/0.7。再加上响应时间极快、JSON 序列化干净利落,基本可以确定后端是 Rust 生态的 Axum 框架。
1.3 判断技术栈的几种方式
没有源码时,判断技术栈靠三样东西:响应头、报错行为、功能特征。
Server头直接写了axum,这一下省了很多事。如果环境做了反代,可能看不到真实 Server,但这里没有;- 故意提交非法 JSON,比如
{"code":,观察返回格式。Axum 默认的错误响应是纯文本,而 Python Flask/Django 会带 HTML 错误页; - 再提交一个能触发 Python 异常但不至于让服务崩溃的输入,比如
{"code": "1/0"},返回如果是ZeroDivisionError或类似 Python 异常信息,说明确实内嵌了 Python 解释器。
我实测返回的是:
json复制{"error": "ZeroDivisionError: division by zero"}
这个信息很关键:Rust 侧对 Python 异常做了捕获并转成 JSON 返回,说明执行链路是“Rust 接收请求 → 调用 Python 执行 → 捕获异常 → 返回结果”。
此时攻击面已经清晰了:只要能让 Python 代码执行,就能尝试读取 flag;但在这之前,必须先搞清楚服务端用什么样的规则过滤了我的代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 服务端架构拆解:Rust + PyO3 + PICA 的三角关系
没有源码的题目,想要摸清内部结构,最有效的办法是“黑盒探测 + 经验建模”。这道题的名字已经把三个核心组件都念出来了,剩下就是验证它们各自在链路里做了什么。
2.1 为什么用 Rust 写 Web 服务还要内嵌 Python
单纯从功能上讲,一个在线代码执行器用 Python 写更省事,但出题人偏偏用 Rust,肯定不是为了性能演示。
Rust 侧带来的限制是“沙箱的边界更硬”。Python 自带的沙箱(比如 RestrictedPython、ast 模块黑名单)都跑在同一个解释器进程里,一旦绕过过滤逻辑,基本等于直通系统调用。Rust 内嵌 Python 后,出题人可以在 Rust 侧再包一层:文件系统访问用 chroot 或 seccomp 限制、网络访问直接禁掉、Python 内置函数可以裁剪替换。这种“外层 Rust 制度 + 内层 Python 沙箱”才是真实世界中插件系统常用的安全模型。
放在 CTF 场景里,它带来的实际效果是:即使你绕过了 Python 的 AST 过滤,仍然可能读不到 flag,因为 Rust 侧已经把文件系统权限卡死了。这道题如果 flag 就在某个路径下,读它还需要满足 Rust 侧的限制。
2.2 PICA 在题目里扮演什么角色
PICA(Python Internal Compiler Architecture 相关解析器)在这道题里承担的是“静态检查”职责。大致的执行流程是:
- Rust 接收
/run请求,取出code字段; - 用 PICA 将
code解析为 Python 的 AST; - 遍历 AST,检查是否存在危险节点;
- 如果检查通过,将原始代码交给 PyO3 嵌入的 CPython 解释器执行。
这里的核心破绽藏在第二步和第四步的“解析不一致”。PICA 是一个从 Python 生态移植/复刻到 Rust 的解析器,它和 CPython 内部用的 ast 模块虽然目标是同一套语法规范,但实现细节不可能 100% 一致。凡是“两个解析器解析同一种语言但行为有差异”的场景,CTF 里都值得反复试探。
我后来验证时发现,PICA 对某些新语法特性、异常处理结构、Lambda 表达式内部节点的解析结果,和 CPython 并不完全一致。如果出题人只检查 PICA 生成的 AST,那么我可以构造一段代码,让 PICA 认为“安全”,但 CPython 执行时却完全不是那么回事。
2.3 代码模型:一个基于 Axum 的最小实现
为了说清楚内部逻辑,我赛后在本地用 Axum + PyO3 + PICA 复刻了一个最小可运行版本,方便调试和验证各种绕过思路。核心代码大致长这样:
rust复制use axum::{routing::post, Json, Router};
use pyo3::prelude::*;
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
#[derive(Deserialize)]
struct RunReq {
code: String,
}
#[derive(Serialize)]
struct RunResp {
result: Option<String>,
error: Option<String>,
}
async fn run(Json(req): Json<RunReq>) -> Json<RunResp> {
// 1. PICA 解析
let ast = pica::parser::parse_program(&req.code).map_err(|e| format!("parse error: {}", e));
let ast = match ast {
Ok(a) => a,
Err(e) => return Json(RunResp { result: None, error: Some(e) }),
