☰
Rust RawFd 生命周期审计实战:用 RAWFD 规则检测文件描述符 double-close 与泄漏漏洞
2026/10/10 2:05:44 网站建设 项目流程
  • AI 技能
  • AI 插件
  • 应用安全
  • 网络安全
  • AI 评测

【免费下载链接】skills

Trail of Bits Claude Code skills for security research, vulnerability detection, and audit workflows

项目地址:https://gitcode.com/gh_mirrors/skills8/skills
点击查看免费下载

本篇技术指南聚焦 Trail of Bitsrust-review插件中RAWFD(raw-fd-lifecycle)漏洞发现规则的完整解读:为什么RawFd是一个以约定而非类型承载所有权的危险抽象,from_raw_fd/into_raw_fd/as_raw_fd如何在 Rust 代码中引入 double-close(CWE-672 / CWE-415 类似形态)与 fd 泄漏(表耗尽 DoS),以及审计时应遵循的验证门(Gates)、误报清单(FPs)与修复方案。读完本文,你将掌握一套可复现的原始文件描述符生命周期审计方法论,并了解它在 rust-review 审计流水线中如何被调度、验证与判定。

背景:RawFd是裸i32,所有权只是约定

在 Rust 中,操作系统文件描述符最原始的形态是RawFd——一个裸的i32整数。std::os::fd::RawFd本身不携带任何资源信息:它不拥有底层描述符,不实现Drop,编译器也无法对它进行任何所有权检查。换句话说,谁应该 close 这个 fd,完全靠代码作者之间的默契约定来维持,类型系统不提供任何保障。

这与 Rust 官方倡导的 I/O safety 类型体系形成鲜明对比:

  • OwnedFd:拥有一个 fd,Drop时恰好 close 一次(RAII);
  • BorrowedFd:借用一个 fd,生命周期由借入者担保,Drop时绝不 close;
  • File/TcpStream等标准库类型:内部持有OwnedFd,同样保证恰好一次 close。

问题恰恰出在两者之间的转换边界。RawFd与上述类型之间通过三个核心函数互相转换:

函数语义所有权效果
from_raw_fd(fd: RawFd) -> OwnedFd取得原始 fd 的所有权从此OwnedFd负责 close,原RawFd数字不得再被使用
into_raw_fd(self) -> RawFd交出所有权,返回裸整数原OwnedFd不再负责 close,调用方必须自行 close
as_raw_fd(&self) -> RawFd仅借用查看不转移所有权,返回的整数仅在当时有效

正如关联文档 raw-fd-lifecycle-finder.md 所概括的:RawFd是裸i32——所有权是约定,不是类型。一旦开发者绕过OwnedFd/BorrowedFd直接摆弄裸整数,就同时打开了 double-close 和泄漏两类漏洞的窗口。

两种失败模式:double-close 与 missing-close(泄漏)

RAWFD规则关注两类截然不同、但同样源于"所有权约定被破坏"的失败模式。

模式一:double-close——同一个 fd 被 close 两次

当同一个底层描述符出现两个负责 close 的"所有者"时,第二个 close 命中一个已被回收的 fd 编号,而这个编号此刻可能正指向一个完全无关的新资源(新打开的 socket、日志文件、共享内存句柄……)。其后果通常是关闭无关资源导致的功能中断,严重时可被利用为资源破坏(这正是 CWE-672"操作后释放资源的使用"以及 CWE-415"双重释放"的 fd 侧类比)。

典型触发路径:

  1. from_raw_fd被对同一个 fd 调用了两次——第一次生成了一个OwnedFd,第二次又从某个还活着的来源拿同一个整数重新构造所有者;
  2. 来源File/OwnedFd仍然存活时,又用它的as_raw_fd()结果喂给from_raw_fd——此时原始所有者与新建的所有者共享同一底层资源;
  3. 借用值被当作所有权转移——把as_raw_fd()(语义是借用)的结果传给from_raw_fd(语义是取得所有权),借出方稍后 close,借入方也 close。

模式二:missing-close(泄漏)——fd 从未被关闭

当一个确定拥有fd 的值被当成普通整数丢弃、被mem::forget遗忘、或被包进ManuallyDrop而从不触发 close 时,底层描述符就永久泄漏。长期运行的服务若持续泄漏 fd,最终会耗尽 fd 表(进程级RLIMIT_NOFILE限制),此后一切open/accept/connect全部失败——这是经典的资源耗尽型 DoS。

典型触发路径:

  • into_raw_fd()返回的裸整数被直接 drop(没有后续close/re-wrap);
  • libc::open/libc::dup/libc::pipe等裸系统调用返回的 fd 被当作i32处理而非包进OwnedFd;
  • 一个File/OwnedFd被mem::forget或ManuallyDrop包裹,且没有任何其他路径负责关闭它(包括?提前返回和 panic-unwind 路径)。

规则明确声明:同样的逻辑完全适用于 Windows 上的RawHandle/RawSocket,即from_raw_handle/into_raw_handle与句柄/套接字类型。因此RAWFD的审计模板同时覆盖 Unix 和 Windows 两条平台线。

验证门(Gates):确认漏洞成立的三条/两条必要条件

RAWFD规则为两种失败模式分别定义了严格的验证门。审计时所有门都必须通过才能提交 finding,缺一不可。

double-close 的三条 Gates

  1. 到达所有权接管点:一个RawFd/RawHandle到达了from_raw_fd/from_raw_handle(该调用语义上取得所有权)。
  2. 存在第二个所有者:同一个 fd 在别处也"被拥有"——要么存在第二次from_raw_fd,要么来源File/OwnedFd仍然存活,要么该值来自as_raw_fd()(本质是借用,却被当所有权使用)。
  3. 两条路径都可达 close:两个所有者在某条可行控制流上都会走到 close,且没有mem::forget/into_raw_fd中和掉其中一个,使第二个 close 不会发生。

注意第 3 条的关键词是"可行路径":审计必须证明存在至少一条从入口到双 close 的实际可达路径,而不是纯理论推演。

missing-close(泄漏)的两条 Gates

  1. 产出了拥有的 fd:into_raw_fd()或裸系统调用(open/dup/pipe)产出了一个确定归属的 fd。
  2. 所有权被丢弃且无补救:该 fd 被当作普通整数 drop、被mem::forget遗忘、或其所有者被ManuallyDrop包裹,且在每一条路径上(包括?提前返回与 panic-unwind)都没有匹配的close或 re-wrap(重新包回OwnedFd)。

泄漏门的第 2 条对路径完备性要求很高:即使正常路径上 close 了,只要某条错误路径(?传播、panic)会跳过 close,规则仍然成立。

误报清单(FPs):什么样的代码不该报

RAWFD规则明确给出了四类必须排除的误报形态。这些反例本质上都是"所有权约定没有被破坏"的情形:

  1. 代码始终停留在类型安全层:fd 一直保存在OwnedFd/File/BorrowedFd中——RAII 保证恰好 close 一次,借用方永远不会 close。这是最安全、也最常见的形态。
  2. 所有权已立即移交:into_raw_fd()的返回值立即交给另一个所有者(例如立刻传给from_raw_fd构造新OwnedFd)——所有权发生了转移而非泄漏。
  3. as_raw_fd()仅作为系统调用实参:借用值只用于调用一次syscall(...),从未被包进任何所有者——借用没有变成所有权,不会产生双 close。
  4. mem::forget/ManuallyDrop伴随文档化的 FFI/所有者交接:存在// SAFETY:或等价注释明确说明资源已移交给外部实体(例如 C 库接管后由对方负责释放)——此时"不 close"是契约的一部分,不是泄漏。

这四条反例与 destructor-skip-finder.md(DROPSKIP规则)中"泄漏/跳过是有意为之且已文档化"的 FP 条款相互呼应,共同构成资源生命周期类审计的误报防线。

修复方案(Patch):让 RAII 替你管理生命周期

规则给出的修复指引遵循同一原则:回到类型系统,把"约定"变回"类型"。

  • 默认形态:使用OwnedFd/BorrowedFd/File,让 RAII 恰好 close 一次——不要手动摆弄裸i32。
  • 转移所有权:用OwnedFd传递;BorrowedFd的底层借用语义与AsFdtrait 用于跨函数传递时保持借用关系。
  • 必须持有裸 fd 时:将其包裹进恰好一个OwnedFd,并通过From/Into转换(OwnedFd与RawFd之间的安全转换路径)传递,绝不再从as_raw_fd()重新推导第二个所有者。

一句话概括:如果你不得不用裸 fd,就让"恰好一个OwnedFd"成为唯一的 close 决策者,其他位置一律以借用(BorrowedFd/AsFd)或From/Into转换来引用,而不是再造一个所有者。

在 rust-review 流水线中的位置与调度

RAWFD不是孤立的一条规则,它是 rust-review 插件resource-handling集群(Cluster)的成员。相关证据可以在仓库中直接核验:

集群归属与门控

在 manifest.json 中,resource-handling集群的gate为always,包含两个 pass:

Bug class前缀子提示文件
raw-fd-lifecycleRAWFDraw-fd-lifecycle-finder.md
destructor-skipDROPSKIPdestructor-skip-finder.md

gate: always意味着:无论目标 crate 是否含有unsafe、FFI 或并发代码,该集群都会运行。这是因为 fd/句柄生命周期错误在纯 safe Rust 中同样可能发生(例如into_raw_fd是 safe 函数),因此RAWFD是每次审计的常驻检查项。从 README.md 可以看到,这套 bug-class 覆盖来自对 RustSec 咨询数据库(1,078 条目)与真实审计中经验 bug 形态的研究积累。

集群级 Phase A:搜索种子

resource-handling.md 为该集群定义了统一的 Phase A 库存搜索(以 ripgrep 正则形式下发,worker 需用rg执行):

rg seed: "\b(from_raw_fd|into_raw_fd|as_raw_fd)\b" rg seed: "\b(RawFd|OwnedFd|BorrowedFd)\b" rg seed: "libc::(close|dup|dup2|open|pipe|socket)\b" rg seed: "\b(RawHandle|RawSocket|from_raw_handle|into_raw_handle)\b" rg seed: "\bmem::forget\b|\bManuallyDrop\b" rg seed: "process::exit|libc::exit"

这些种子把审计范围锁定在四条线索上:fd 类型与三转换函数、裸 libc 系统调用、Windows 句柄/套接字等价物、以及可能绕过 close 的mem::forget/ManuallyDrop/process::exit。拿到候选后,worker 依据RAWFD的 Gates 逐一验证,而不是见种子就报。

与 DROPSKIP 的去冲突规则

RAWFD与同集群的DROPSKIP在边界上有明确分工(见 resource-handling.md 的 Deconfliction 一节):

  • 文件描述符/句柄/套接字的生命周期被错误处理(double-close、泄漏、from_raw_fd未伴随所有权转移)→ 报RAWFD;
  • 其他安全相关Drop被mem::forget/ManuallyDrop/process::exit跳过(密钥清理、数据库连接、事务、锁)→ 报DROPSKIP;
  • 特别地,当mem::forget/ManuallyDrop/exit遗弃了一个 fd 支持的值(OwnedFd、File、TcpStream)时,只报RAWFD(fd 泄漏才是精确的 bug),同一位置不得重复报DROPSKIP;
  • 而 FFI 所有权内存的跨分配器释放不匹配则归属ffi-cross-language集群的FOREIGNDROP,不是RAWFD/DROPSKIP。

这套去冲突规则保证了同一根因不会被多个 pass 重复报告,后续 dedup 阶段(rust-review-dedup-judge)的合并压力也随之降低。

从候选到 finding:worker 的验证与产出协议

当RAWFDpass 被调度给某个rust-review-worker时,该 worker 遵循 agents/rust-review-worker.md 定义的严格协议:

  1. 执行 Phase A 库存搜索后,针对每条候选做数据流验证:从攻击者可控的入口(网络、文件、IPC、FFI 输入)追踪到from_raw_fd/裸 syscall/mem::forget汇点,检查是否存在缓解措施(如// SAFETY:是否真实成立、上游是否保证唯一 close)。
  2. 确认的漏洞按统一模板写入{output_dir}/findings/RAWFD-NNN.md,YAML frontmatter 含id、bug_class、title、location(单个path:line)、function、confidence、worker,正文含 Description / Code / Data flow / Reachability trace / Impact / Mitigations checked / Recommendation 七个固定小节。
  3. 写入 coverage-gate 文件:每 pass 必须有filed: RAWFD-001或cleared (...)记录,不允许skipped:——即"没搜到任何from_raw_fd/as_raw_fd候选"才能写cleared,未运行搜索就报"无发现"属于 coverage 违规。

worker 提交后,finding 依次经过 dedup-judge 合并重复、fp-judge 判定fp_verdict与 severity(威胁模型REMOTE/LOCAL_UNPRIVILEGED直接影响结论,见 agents/rust-review-fp-judge.md),最终汇入REPORT.md与REPORT.sarif。整套流水线的编排细节(Phase 0–9、worker 波次、重试分类器)可查阅 SKILL.md。

审计清单速查

把上述内容浓缩为一份可直接对照检查的清单,供实际审计RawFd代码时逐项核对:

  1. 代码中是否出现RawFd/RawHandle/RawSocket裸类型,或from_raw_fd/into_raw_fd/as_raw_fd三函数的混用?
  2. 每个from_raw_fd的输入,其 fd 数字是否只来自唯一的所有权来源,且该来源已确认让渡所有权?
  3. 每个as_raw_fd()返回值是否仅用于瞬时 syscall 参数,从未被包进任何from_raw_fd?
  4. into_raw_fd()的返回值是否在每条路径(含?与 panic-unwind)上都有对应的close或 re-wrap?
  5. mem::forget/ManuallyDrop是否伴随// SAFETY:文档化的外部交接?如果没有,就是泄漏候选。
  6. libc::open/dup/pipe的返回值是否立即被包进OwnedFd,还是当作i32随处传递?
  7. 同一 fd 是否存在两个"都可能 close"的所有者?若是,能否证明其中一条路径被mem::forget/into_raw_fd中和?

核心结论:RawFd的裸i32形态决定了所有权只能靠约定维持,而约定必然有被打破的一天。RAWFD规则的价值在于把"打破约定"这件事从模糊的代码嗅觉转化为可验证的 Gates——double-close 必须同时满足"到达接管点 + 存在第二所有者 + 双 close 路径可达"三条,泄漏必须满足"产出拥有的 fd + 全路径无 close"两条;再辅以四类 FP 反例和"恰好一个OwnedFd"的修复铁律,你就能系统性地消灭 Rust 代码中最隐蔽的一类操作系统资源错误。

  • AI 技能
  • AI 插件
  • 应用安全
  • 网络安全
  • AI 评测

【免费下载链接】skills

Trail of Bits Claude Code skills for security research, vulnerability detection, and audit workflows

项目地址:https://gitcode.com/gh_mirrors/skills8/skills
点击查看免费下载

相关推荐

上一篇:Bioicons:3000+免费矢量科学图标库,科研可视化的终极解决方案
下一篇:FigmaCN中文插件终极指南:3分钟让Figma界面变中文

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询