做安全测试的同行,几乎人手一个 Burp Suite。功能没得说,但用久了有几件事让我越来越难受:它是 Java 写的,要拖一个 JRE ,装好含运行时动辄 200MB 起步 ;空载启动内存常年几百兆,开几个标签跑一轮扫描就上 G;高 DPI 下界面重绘还发钝。
我想要的其实很朴素:一个开箱即用、轻、快、原生 的工作台,把 Burp 那四大件(抓包代理 / 重放 / 爆破 / 扫描)和周边该有的工具都备齐。于是我用 Rust 做内核、用 gpui (Zed 团队那套 GPU 加速 UI 框架)做界面,从零写了一个,叫 Scry。

先把最能说明问题的数字摆出来------同一台 macOS 上的实测产物:
| 指标 | Burp Suite | Scry |
|---|---|---|
| 运行时依赖 | 需要 JVM / JRE | 无,纯原生二进制 |
| 主程序体积 | 含 JRE 数百 MB | 单文件 14MB |
| 打包后 | ------ | .app 15MB / zip 9.9MB |
| 渲染 | Java Swing/SWT | gpui,macOS 走 Metal |
| 内核语言 | Java | Rust(18 个 crate 的 workspace) |
这篇文章不打算给你一份功能清单------那种东西看了也记不住。我想复盘的是造这个工具时真正决定成败的 6 个技术决策:每一个都是动手前认真权衡过、并且踩过坑的地方。比起"我做了什么",这些"为什么这么做、为什么没那么做"对你或许更有参考价值。
说明:全文聊的是「安全工具本身怎么造 」------架构、体积、性能、Rust 工程实践。所有能力都面向授权范围内的安全测试,不涉及任何针对真实目标的攻击教程。文末有郑重声明。
决策一:内核用 TLS 终止式 MITM,而不是抓网卡
这是最容易想当然、也最该想清楚的一步。
一种很自然的直觉是:"像 Wireshark 那样用 libpcap 被动抓网卡不就行了?" 我认真核对过------做安全测试工作台,拿被动嗅探当内核是行不通的,三个硬伤躲不开:
- 解不开 HTTPS 。TLS 1.3 前向保密下,哪怕你手里有服务器私钥也解不开;被动抓只能靠
SSLKEYLOGFILE,而那仅限于你能控制、且愿意吐密钥的客户端,原生 App、C2 流量统统无效; - 只读,改不了包。Repeater / Intruder / Scanner / 拦截改包,全都要在中间"截下来改",嗅探做不到;
- 裸 TCP 重组又脏又苦,乱序、重传处理起来事倍功半。
Burp、mitmproxy、Charles、Fiddler、Reqable------没有一个 拿 libpcap 当内核。它们清一色是 TLS 终止式 MITM 代理 :自己站在中间,对客户端扮演服务器、对服务器扮演客户端,在中间拿到明文。Scry 走的就是这条路(scry_proxy::mitm)。

CONNECT 之后,偷看一个字节
代理收到 CONNECT host:443 时,怎么知道隧道里到底是 TLS 还是明文?我的做法是先回 200,再用 MSG_PEEK 偷看首字节而不消费它:
rust
// 回 200,客户端才会在隧道里发后续字节
client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n").await?;
// 偷看隧道内首字节(peek 不消费 socket)
let mut first = [0u8; 1];
let looks_tls = matches!(
client.peek(&mut first).await,
Ok(n) if n >= 1 && first[0] == 0x16 // 0x16 = TLS handshake record
);
if looks_tls {
mitm::intercept_https(client, host, port, /* ... */).await // 终止 TLS、解密
} else {
capture_tunneled_http(client, host, port, /* ... */).await // 隧道里其实是明文
}
0x16 是 TLS 握手记录的第一个字节。靠这一个字节自适应,一个标准 CONNECT 代理就能同时正确处理 HTTPS 和"被某些代理强行套进 CONNECT 的明文 80",再不会因为"对明文强行做 TLS 握手"而断连。这是我从一次诡异断连里抠出来的教训:旧版对任何 CONNECT 都强行 TLS,结果把走 CONNECT 的明文流量全握手到断。
动态签证书:终止 TLS 的核心
要对客户端"扮演服务器",就得递给它一张目标域名的证书 。Scry 启动时生成一个根 CA(落在 ~/.scry/ca.pem),之后为每个访问到的域名用 CA 私钥现场签一张叶子证书,按域名缓存(签发是 CPU 大头,keep-alive / 同域多连接直接命中缓存):
rust
let leaf = sign_leaf_for(&ca, "example.com"); // rcgen + ring 现签叶子
let server_config = build_server_config(leaf); // rustls ServerConfig
let acceptor = TlsAcceptor::from(Arc::new(server_config));
let tls_stream = acceptor.accept(client).await?; // 对客户端握手完成 → 明文双向流
// 对真实服务器这边再作为 TLS 客户端连上去,两段拼起来 = 中间人
客户端肯信这张"冒充"的证书,是因为它信任了 Scry 的根 CA,所以工具提供「一键安装信任」和「导出到其他电脑」的分发包。
还有个跟"安全"强相关的细节:Scry 的内置浏览器 抓包模式,不往系统里装 CA,而是给 Chromium 传
--ignore-certificate-errors-spki-list=<CA 的 SPKI 哈希>。这等于精确告诉浏览器"只放行这一把公钥",既不全局关校验、又能覆盖证书 pinning 的站点------比"一把火关掉所有证书校验"干净太多。
一条铁律:抓到先落盘
还有个不起眼但救过命的决定:抓到请求的第一件事是落盘,不是分析 。scry_proxy 拿到完整响应后第一步就 save_flow() 写进 SQLite(按 method + 规范化 URL + body 的 sha1 去重),然后才轮到展示、扩展钩子、改写。进程崩了、窗口关了,已抓到的数据一条都不会丢。
决策二:把引擎全做成纯函数 crate,UI 只当薄壳
Scry 是一个 Cargo workspace,按职责切成 18 个 crate。设计哲学就一句:能做成纯函数的引擎全部抽成独立 crate,UI 只是它们的薄壳。

text
scry_app ← gpui 界面(唯一有副作用/状态的层)
│ 复用
├─ scry_proxy HTTP/S MITM 抓包内核 + 重放 + 上游 + WS + HTTP/2 + TLS 指纹
├─ scry_ca CA 生成 + 按域名动态签叶子证书(+缓存)
├─ scry_storage SQLite 落盘 + 去重(save-first)
├─ scry_decode Content-Encoding 解压 + charset + MIME 分类
├─ scry_analyze 参数/Cookie/摘要提取 + 过滤 + 导出 curl
├─ scry_scan 被动规则 + 主动探测 + 敏感文件发现(Nikto 式)
├─ scry_sqli SQLi 检测引擎(sqlmap 式,纯函数)
├─ scry_xss XSS 上下文感知引擎(dalfox 式,纯函数)
├─ scry_codec 31 种编解码 / 加解密 / 哈希
├─ scry_diff LCS 比较(Comparer)
├─ scry_seq 令牌随机性分析(香农熵 + FIPS 140-2)
├─ scry_crawl 站点爬虫(BFS 调度)
├─ scry_ext_api / scry_ext_host 扩展契约 + 三种 Runner
├─ scry_mcp MCP 服务(给 AI 调度引擎)
└─ scry_core / scry_sniff ... 共享类型 / 被动嗅探
这么切的回报非常实在:每个引擎 crate 都零 IO、零网络、可单测 。比如 SQLi 的"生成探测载荷""判定响应是否命中"是纯函数,XSS 的"识别反射上下文""按上下文合成载荷"也是纯函数------它们一概不发包,发包这件有副作用的事统一交给 scry_proxy::replay。
为什么对安全工具这点格外重要?因为安全工具最怕自己就有 bug、误报漏报一团乱 。把判定逻辑做成无副作用的纯函数,就能用大量单测把行为焊死。落到数字上:全工作区累计 300 多个单元 / 集成测试 、clippy 零警告。引擎一旦可信,UI 这层薄壳怎么改都不慌。
决策三:为"小"和"零依赖"死磕到底
"功能不少、体积还小"不是玄学,是一连串具体取舍叠出来的。

第一,根子上选原生编译。 Rust 编成机器码,不背 JVM、不带解释器、不要目标机预装运行时------这是和 Java 系工具差一个数量级体积的根本原因。gpui 直接调 GPU(mac 上是 Metal),界面绘制不经过一层厚重的 GUI 中间件。
第二,release profile 往死里压。 Cargo.toml 的发布配置是体积的关键开关:
toml
[profile.release]
opt-level = "z" # 为体积优化(不是为速度的 3)
lto = true # 链接期跨 crate 内联 + 死代码消除
codegen-units = 1 # 牺牲并行编译,换更彻底的优化
strip = true # 剥掉符号表
# 注意:千万别加 panic = "abort"
# gpui / Mutex 等依赖栈展开(unwind),abort 会让一次 panic 直接杀进程
这套组合拳下来,二进制从几十 MB 量级压到 14MB。最后那条注释是踩出来的:图省事加 panic = "abort" 能再小一点,但 gpui 和锁的实现依赖 unwind,一旦 abort,运行时一个 panic 就是整窗崩溃,得不偿失。
第三,依赖一律挑"纯 Rust、免 C 工具链"的实现。 这条同时省体积、省编译麻烦、还为"零环境交付"铺路:
- TLS / 证书 :
rustls+tokio-rustls+rcgen全部统一用 ring 后端,绕开aws-lc那条需要 cmake / C 编译器的路; - 解压 :
flate2(gzip/deflate) +brotli,纯 Rust; - 字符集 :
encoding_rs(GBK/Big5/Shift_JIS → UTF-8,和 Firefox 同款); - 哈希 / 对称加解密 :RustCrypto 的
md-5/sha2/aes/cbc/ecb,纯 Rust; - WASM 扩展运行时 :
wasmtime,但精简了 features ------只留runtime+cranelift+wat,砍掉 component-model、async、cache、并行编译、pooling-allocator 等重头。
全程没有一个动态链接的第三方原生库(otool -L 验证过),交付出去就是"双击即用"。
决策四:扩展系统------一个契约,三种 Runner
要让用户写扩展,又不想破坏"14MB、零依赖"这条线,我把扩展做成一套钩子契约 + 三种运行后端,按信任度和语言分流:

| Runner | 适合 | 取舍 |
|---|---|---|
| 内置 / Native dylib | 可信、要极致性能 | 快,但和宿主同进程 |
| WASM 沙箱(wasmtime) | 第三方扩展(默认) | 无任何宿主 import = 无能力逃逸;fuel 限死循环、内存上限防炸弹 |
| 外部进程(Python) | 想用 Python 写 | stdio 上跑 JSON-RPC,崩溃隔离、不嵌 CPython |
钩子契约就三个:on_request / on_response / on_flow_complete,配一个 manifest.json 自述身份与权限:
json
{
"name": "passive-secret-scan",
"version": "0.1.0",
"hooks": ["on_flow_complete"],
"permissions": ["read_flow"],
"wasm": "extension.wasm",
"fuel": 50000000
}
WASM 这条尤其香:扩展模块没有任何宿主导入函数 ,意味着它默认对系统毫无能力(连读文件都做不到),fuel 配额防死循环、StoreLimits 封顶内存防炸弹,每次钩子新建实例、互相零共享,&self 钩子天然并发安全无需加锁。第三方扩展跑在这种沙箱里,心里踏实得多。关键是------这一切都静态链接进那个 14MB 的二进制,不需要装 Python、不需要装 wasmtime。
决策五:GPU 主线程,绝不让网络 IO 拖卡
gpui 是 GPU 渲染的,主线程要稳定冲 120 帧,绝不能在上面阻塞做网络 IO 。但发包是异步的 Future,这两者怎么调和?我的桥接套路是:
把发包
Future丢到 gpui 的background_executor后台线程,在那条线程上建一个 current-thread tokio runtimeblock_on驱动它(只阻塞这条后台线程);完成后用cx.spawn回到主线程,通过WeakEntity::update写回结果并触发重绘。
爆破 / 扫描这类"流式出结果"的场景,则用一个 mpsc channel:后台串行发包、每出一条结果就 send 回来,前台每 120ms drain 一次增量刷进表格。这样无论后台打多少包,GPU 主线程永远只做渲染。
光异步还不够,数据一多照样卡------所以列表用虚拟化 (uniform_list 只渲视口内那 ~20 行,和总量无关),响应体解码加一层 thread_local LRU 缓存(按堆指针 + 长度做 key)。即便单条响应几百 KB、历史几千条,滚动依然顺。这一条是抓包面板从"几千条就卡"到"恒定流畅"的关键改动:根因就是旧版每帧全量重建几千行,虚拟化后帧成本只跟视口相关。
顺带一提,界面是 Burp 式的多页签工作台,目前 15 个页签全部落地、无占位页 :Proxy / Repeater / Intruder / Scanner / Intercept / Decoder / Comparer / Sequencer,加上 SQLi、XSS、站点爬虫、越权检测、扩展、日志、仪表盘。抓包内核还补齐了 WebSocket 帧抓取 、HTTP/2 、上游链式 (解密后把流量交回 sing-box / 机场出网,应对受限网络)、以及 TLS 指纹(JA3/JA4) 的可视化------其中 JA3/JA4 是让 rustls 把 ClientHello 真的写进内存缓冲再解析算出来的,顺便发现 rustls 0.23 每次握手会随机化扩展顺序导致 JA3 不稳定、而 JA4 排序后哈希才稳定,所以界面以 JA4 为准。
决策六:顺手把能力开放给 AI
既然内核都是规整的纯函数引擎,我加了个 scry_mcp------一个独立的 MCP(Model Context Protocol)服务 ,让 Cursor / Claude 这类 AI 客户端能直接调度 Scry 的引擎:列流量、重放请求、跑被动 / 主动扫描、敏感文件发现、越权检测、编解码......
它走 stdio 上的行分隔 JSON-RPC,和 GUI 共用同一个 ~/.scry/scry.sqlite,所以可以和界面同时跑、互不抢端口:
json
{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}
{"jsonrpc":"2.0","id":1,"result":{"tools":[
{"name":"list_flows", "description":"列出抓到的流量(可按 host 过滤)"},
{"name":"send_request", "description":"发一个请求(= Repeater)"},
{"name":"passive_scan", "description":"对历史流量跑被动规则"},
{"name":"authz_test", "description":"多身份重放做越权检测"}
]}}
注册进客户端配置即可被发现:
jsonc
// ~/.cursor/mcp.json
{
"mcpServers": {
"scry": { "command": "/path/to/target/release/scry-mcp" }
}
}
效果是:你可以让 AI"把刚抓到的那条登录请求重放一遍、换个身份看看有没有越权",它真的会去调引擎执行。安全工具 + AI 助手,比想象中顺。
适用与边界(不吹银弹)
把话说清楚,免得期待错位:
- 适合:macOS 上做授权范围内的 Web 安全测试 / 流量分析、想要轻量原生替代、Rust 技术栈、想给 AI 接安全能力的人。
- 暂不如 Burp 的地方 :商业版那种深度自动扫描、庞大的 BApp 生态、团队协作 / 报告流水线------这些是多年积累,短期补不齐。Scry 的定位是"轻、快、能改、能扩、能被 AI 调度"的工作台,不是"Burp 杀手"。
- 平台:优先 macOS(gpui 在 mac 走 Metal 最成熟)。
最后照例一句郑重声明:
⚠️ 本文与该工具仅用于学习研究 与获得授权的安全测试。任何对未授权目标的扫描、抓包、改包都可能违法。请务必在合法合规、获得明确授权的前提下使用,一切后果由使用者自负。
写在最后
复盘这 6 个决策,会发现"16MB 干 Burp 的活"背后没有什么魔法,而是一串朴素选择的叠加:内核选对(MITM 而非抓网卡)、引擎做成可测的纯函数、为体积和零依赖死磕、扩展用沙箱守住安全、IO 全甩后台让 GPU 只管渲染、再顺手开放给 AI。
Rust 在系统工具这个赛道,真的能同时给你"小、快、稳"三样------这在过去往往只能三选二。如果你也受够了某些安全工具的臃肿,不妨试试用 Rust 自己造一个趁手的;哪怕只把其中某个引擎(MITM 解密、JA3 计算、WASM 扩展沙箱)单独抠出来玩一遍,也很值。
如果这篇对你有帮助,欢迎评论区聊聊你心目中"理想的安全工具"是什么样。