做安全测试的同行,几乎人手一个 Burp Suite。功能没得说,但用久了有几件事让我越来越难受:它是 Java 写的,要拖一个JRE,装好含运行时动辄200MB 起步;空载启动内存常年几百兆,开几个标签跑一轮扫描就上 G;高 DPI 下界面重绘还发钝。
我想要的其实很朴素:一个开箱即用、轻、快、原生的工作台,把 Burp 那四大件(抓包代理 / 重放 / 爆破 / 扫描)和周边该有的工具都备齐。于是我用Rust做内核、用gpui(Zed 团队那套 GPU 加速 UI 框架)做界面,从零写了一个,叫Scry。
先把最能说明问题的数字摆出来——同一台 macOS 上的实测产物:
| 指标 | Burp Suite | Scry |
|---|---|---|
| 运行时依赖 | 需要 JVM / JRE | 无,纯原生二进制 |
| 主程序体积 | 含 JRE 数百 MB | 单文件14MB |
| 打包后 | —— | .app15MB/ zip9.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偷看首字节而不消费它:
// 回 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 / 同域多连接直接命中缓存):
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 只是它们的薄壳。
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的发布配置是体积的关键开关:
[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自述身份与权限:
{ "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写回结果并触发重绘。
爆破 / 扫描这类"流式出结果"的场景,则用一个mpscchannel:后台串行发包、每出一条结果就send回来,前台每 120msdrain一次增量刷进表格。这样无论后台打多少包,GPU 主线程永远只做渲染。
光异步还不够,数据一多照样卡——所以列表用虚拟化(uniform_list只渲视口内那 ~20 行,和总量无关),响应体解码加一层thread_localLRU 缓存(按堆指针 + 长度做 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,所以可以和界面同时跑、互不抢端口:
{"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":"多身份重放做越权检测"} ]}}注册进客户端配置即可被发现:
// ~/.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 扩展沙箱)单独抠出来玩一遍,也很值。
如果这篇对你有帮助,欢迎评论区聊聊你心目中"理想的安全工具"是什么样。