让「安全」的 Rust 执行「不安全」的代码
aeverj
aeverj
红队工坊
2026年10月23日 06:23
北京
去阅读
原文标题: pwning \"safe\" Rust into running my code
Rust 社区里有句话,大家应该都听过:
「Rust 是一门内存安全的编程语言。」
这话当然没错,至少在正常情况下是这样。
但我突然有了一个大胆的想法:能不能在完全不使用 unsafe 的情况下,让 Rust 执行任意代码?
而且,我还给自己加了几条限制:不使用 Nightly 实验性功能,不耍过程宏(Procedural Macro)的小聪明,也不靠 build.rs 构建脚本作弊。
只使用纯粹、安全、稳定版的 Rust,采用 2024 Edition。
结果呢?
还真能做到,而且做起来比想象中更离谱。
漏洞究竟出在哪里?
问题出在 Rust 类型系统的一个漏洞上。
GitHub 上已经有人报告了这个问题:
[rust-lang/rust#135011] https://github.com/rust-lang/rust/issues/135011
简单来说,Rust 的类型检查器(Type Checker)在处理循环定义的关联类型(Associated Type)时,存在一个类型健全性漏洞(Soundness Hole)。
正常情况下,编译器应该严格检查每个变量的类型,确保不同类型之间不会被随意混用。
但这里出现了一个问题。
当多个特征实现(Trait Implementation)互相引用,最终形成一个循环时,负责解析类型的类型规范化器(Type Normalizer)就可能被绕晕。
利用这个漏洞,我们可以欺骗编译器,让它把任意类型 L 当成任意类型 R。
熟悉 Rust 的朋友应该已经意识到这意味着什么了。
这不就相当于 std::mem::transmute 吗?
没错。
只不过,std::mem::transmute 正常情况下必须放在 unsafe 中调用,而我们的版本完全不需要。
核心调用代码长这样:
fn transmute<L, R>(x: L) -> R {
transmute_inner_1::<L, R, RewriteReflRight<FakeRefl<L, R>>>(x)
}
当然,这只是最外层的入口函数。
背后还有大约 80 行特征定义,用来构造一份伪造的「类型相等证明」。
它的原理并不复杂:通过循环引用,让编译器无法真正把关联类型解析成具体类型,自然也就无法正确验证两个类型到底是不是相同。
但离谱的地方在于:
Rust 稳定版编译器居然真的接受了这段代码。
编译器仿佛在说:「嗯,看起来没什么问题,过吧。」
有了这个漏洞,我们能干什么?
答案是:几乎所有 std::mem::transmute 能做的危险操作,我们都能做。
例如:
• 伪造指针(Pointer Forgery)
• 构造虚假的虚函数表(vtable)
• 将任意字节重新解释为其他类型
• 绕过 Rust 原本应该提供的类型安全保证
换句话说,Rust 类型系统原本建立起来的安全边界,在这里被直接绕过了。
为了验证这个漏洞,我决定做一个经典实验:
伪造一个指向 libc::system() 的函数指针,然后直接调用它。
代码如下:
type SystemFn = extern \"C\" fn(*const u8) -> i32;
fn main() {
let system_fn: SystemFn =
transmute::<usize, SystemFn>(libc::system as *const () as usize);
system_fn(b\"echo 'mrow :3'\\0\".as_ptr());
}
这段代码做了什么?
首先,我们定义了一个函数指针类型 SystemFn,它接受一个指针参数,并返回一个 32 位整数。
接着,我们获取 libc::system 的地址,把它转换为 usize,再通过刚才构造的 transmute,将这个整数变成一个可以调用的函数指针。
最后,调用这个函数指针,执行一条简单的 shell 命令。
运行结果:
[*] rustpwn - ACE in safe stable Rust
[*] rust-lang/rust#135011
mrow :3
成功了。
我们在 Rust 程序中实现了任意代码执行(Arbitrary Code Execution,ACE)的演示。
更重要的是,整个过程:
没有出现任何一个 unsafe 关键字。
代码可以在稳定版 Rust 上编译,也能正常运行。
即使执行:
grep -rn unsafe src/main.rs
也找不到任何结果。
甚至在代码中加入:
#[forbid(unsafe_code)]
编译器照样放行。
这意味着,即使一个项目明确禁止使用不安全 Rust 代码,也无法通过这个限制阻止漏洞被利用。
类型系统到底是怎么被骗过去的?
现在,我们来看看背后的原理。
整个技巧的核心,是通过多层特征构造一个假的类型相等证明,让编译器误以为:
L == R
也就是说,让 Rust 相信两个原本完全不同的类型其实是同一个类型。
为了做到这一点,代码使用了几个关键组件。
• TypeIs<T>:一个简单的类型恒等特征。对任何类型,它的关联类型 Is 都等于 T。
• TyEq / TyEqImpl:一组用于表达类型相等关系的特征,可以把它们理解成一套「类型相等证明」机制。
• Refl<T>:真正合法的自反性证明。也就是证明 T == T。这当然完全没问题。
• FakeRefl<L, R>:整个漏洞利用中最关键的部分。它试图伪造一份 L == R 的证明,并通过 DeriveEqFromImpl 将证明委托给 Proof<()>,从而形成循环。
• RewriteReflRight<P>:利用另一份证明的定义改写等式右侧的类型,借助编译器无法完整解析循环关联类型的缺陷,让伪造的证明通过检查。
听起来有点绕?
我们可以用一个简单的比喻理解。
正常情况下,如果你想证明 A 等于 B,就必须拿出一份真实有效的证明。
但 FakeRefl<L, R> 采用了另一种操作:
「你要证明 L 等于 R?没问题,证明就在 FakeRefl<L, R>::Proof<()> 里面。」
编译器于是去查看这个证明。
结果发现,这份证明又指向了自己。
就这样,证明形成了一个循环。
编译器沿着循环不断尝试解析,却无法得到能够正确验证的具体类型。
而漏洞恰恰就在这里:编译器没有因为这个无法正确完成的检查而拒绝代码,反而接受了这份证明。
于是,它最终被说服,认为 L 和 R 可以当成相同类型处理。
是不是很荒唐?
更离谱的还在后面。
最初报告这个漏洞的 steffahn,还成功把整个漏洞利用缩减到了下面这几行:
trait Tr<R>: Sized { type Prf: Tr<R, Prf = Self>; }
impl<L, R> Tr<R> for L {
type Prf = R where L: Tr<R>, R: Tr<R, Prf = <L::Prf as Tr<R>>::Prf>;
}
fn transmute<L: Tr<R>, R>(r: L) -> <L::Prf as Tr<R>>::Prf { r }
没错。
仅仅 4 行稳定版 Rust 代码,就能实现一个不需要 unsafe 的 transmute。
这已经不是普通的类型转换技巧了,而是在利用类型系统自身的漏洞。
越往下挖,问题越严重
发现这个问题之后,我又去翻了翻 GitHub 上的漏洞讨论。
本来以为事情到这里就差不多了。
结果越看越不对劲。
■这个漏洞已经存在了 11 年
steffahn 发现,这类漏洞甚至可以追溯到 Rust 1.1。
那是什么时候发布的版本?
2015 年 6 月。
也就是说,这并不是某次新版本更新不小心引入的回归问题(Regression)。
它是 Rust 特征求解器(Trait Solver)中一个长期存在的根本性缺陷。
从 Rust 发展的早期开始,这个问题就已经存在。
直到 11 年后,我们依然能利用它。
■更糟糕的是,这还不止一个漏洞
Rust 类型团队的成员 lcnr 分析了这些精简后的漏洞复现代码,结果发现了另一件令人意外的事情。
steffahn 在尝试简化最初的漏洞时,竟然无意间发现了第二个独立的类型健全性漏洞。
lcnr 在讨论中指出:
这次精简实际上暴露了另一个独立的类型健全性问题。至少,我原本打算用来修复第一个漏洞的方法,并不足以解决这个问题。
也就是说:
最初的漏洞利用触发的是一个缺陷。
而后来缩减出来的 4 行代码,触发的却是另一个缺陷。
本来以为只有一个坑。
结果挖着挖着,发现旁边还有一个。
■优先级 P-medium,而且没有负责人
接下来是让我最难理解的部分。
我们已经证明,这个漏洞可以在完全安全的稳定版 Rust 中实现类似 transmute 的能力。
它可以造成任意类型混淆(Type Confusion),使任意代码执行成为可能,并破坏语言本应提供的类型安全保证。
那么,这个漏洞在 Rust 项目中被标记为什么优先级呢?
P-medium,中等优先级。
而且没有分配修复负责人。
这个问题自 2025 年 1 月被报告以来,一直处于这样的状态。
作为背景,截至 2026 年 5 月,Rust 官方仓库中还有超过 100 个尚未关闭、被标记为 I-unsound 的问题。
[查看 Rust 中的 I-unsound 问题] https://github.com/rust-lang/rust/labels/I-unsound
我们今天讨论的,只是其中一个。
■真正的修复可能需要重构整个特征求解器
为什么这样一个严重的漏洞没有被迅速修复?
因为问题可能比表面上复杂得多。
根据 lcnr 的分析,根本原因在于:
Rust 编译器会信任关联类型上声明的约束,却没有正确验证这个关联类型最终能否规范化为一个具体类型。
正常情况下,这些约束应该能够被验证。
但是,当某个关联类型通过循环引用不断指向自己时,它就可能无法被解析为最终结果。
这种情况下,相应的约束实际上并不成立。
可编译器没有正确检查这一点。
于是,类型系统就出现了可以被利用的漏洞。
另一位参与讨论的开发者 Nadrieril,则把这个问题与经典类型理论联系了起来。
他指出,这与非正递归(Non-positive Recursion)中的 ω 组合子(ω Combinator)有关。
[关于递归类型与相关类型理论的讨论] https://cs.stackexchange.com/questions/84307/what-are-the-rules-for-positive-recursive-types-in-dependent-type-theory/84312
简单来说,问题不只是某段代码写错了。
更深层的原因,可能在于类型系统本身的设计。
Rust 允许通过关联类型构造非正递归,而这种形式的递归在依赖类型理论(Dependent Type Theory)中已知可能导致类型系统失去健全性。
这意味着,修复它并不一定是加一个判断条件那么简单。
真正的解决方向,是 Rust 正在推进的特征系统重构项目:
[trait-system-refactor-initiative] https://github.com/rust-lang/trait-system-refactor-initiative/issues/62
其中一个核心工作,就是新的特征求解器 -Znext-solver。
但问题在于,这是一项规模庞大的重构工程。
它已经持续开发了很多年,当时仍然没有达到可以全面替代旧实现的程度。
所以,这个漏洞并不是没人知道。
而是修复它所涉及的问题,可能比漏洞本身还要棘手。
■最可怕的地方:任何第三方 Crate 都可能悄悄带入这个问题
现在,假设你是一个非常注重安全的 Rust 开发者。
你给整个项目加上:
#[forbid(unsafe_code)]
你要求所有代码都不能出现 unsafe。
你还会使用 cargo audit 检查依赖中的已知安全问题,使用 Clippy 做静态检查,甚至用 Miri 检查潜在的未定义行为。
听起来已经非常谨慎了,对吧?
然而,这个漏洞展示了一种更麻烦的情况。
任何发布在 crates.io 上的第三方 Crate(Rust 软件包),理论上都可以包含这样的 transmute 实现。
而引用它的下游项目,即使自身完全没有写 unsafe,也可能因此失去原本依赖的类型安全保证。
更麻烦的是,常见的检查手段不一定能发现它:
• cargo audit:主要检查依赖中已知且已被收录的安全漏洞,无法仅凭代码包含这种技巧就自动识别问题。
• #[forbid(unsafe_code)]:只禁止显式的不安全代码,但这里根本没有使用 unsafe。
• 搜索 unsafe 关键字:当然也没有用,因为代码中不存在这个关键字。
• Clippy:不会因为这种类型系统层面的欺骗就自动报警。
• Miri:虽然能够检查许多运行时未定义行为,但不能依靠源代码层面的常规检查直接识别这种伪造的类型关系。
问题就在这里。
从代码表面上看,一切都符合 Rust 的安全语法。
但类型系统已经被欺骗了。
也就是说,「没有使用 unsafe」并不必然意味着代码就能获得绝对的安全保证。
那么,真正的 Shellcode 呢?
既然已经能伪造函数指针,那能不能更进一步,直接执行 Shellcode?
我也尝试过。
这次我使用的是 AArch64 架构的 Shellcode,思路很直接:
-
使用
mmap分配一块具有读、写、执行权限的内存页面,也就是 RWX 内存。 -
将机器指令写入这块内存。
-
跳转到对应地址,执行这些指令。
从原理上来说,这条路是可行的。
但实际测试时,我遇到了一个麻烦。
我使用的是 Apple Silicon 平台。
在 macOS 上,系统对即时编译(Just-In-Time Compilation,JIT)内存有额外的保护机制。
特别是针对可执行内存页面,macOS 会通过 MAP_JIT 和 pthread_jit_write_protect_np 等机制实施写入与执行权限方面的限制,也就是 W^X 保护策略。
这意味着,如果想完成整个 Shellcode 执行流程,我还需要伪造对这些系统接口的调用。
理论上可以做到。
但说实话,有点麻烦。
而且没太大必要。
因为之前通过 libc::system() 伪造函数指针的实验,已经足以证明核心问题:
我们可以在不使用 unsafe 的情况下,绕过 Rust 的类型安全限制,实现任意代码执行。
至于在 x86\_64 Linux 上,如果运行环境允许通过 mmap 分配 RWX 内存页面,那么直接执行原始 Shellcode 理论上应该更加简单。
最后的一些感想
「用 Rust 重写吧!」他们说。
「Rust 是内存安全的!」他们说。
但现实是:
一个已经存在 11 年的类型系统漏洞。
被标记为 P-medium。
没有分配负责人。
从 2025 年 1 月被公开报告,到 2026 年 5 月仍然可以复现。
代码依旧能够编译。
程序依旧能够运行。
当然,这并不意味着 Rust 的内存安全设计毫无价值,也不代表所有 Rust 程序都不安全。
但这次实验至少说明了一件事:
即使一门语言把内存安全作为核心设计目标,其编译器和类型系统本身也可能存在漏洞。
当安全保证建立在编译器正确性的基础上时,一旦编译器接受了本应拒绝的类型关系,那些看似牢不可破的安全边界就可能失效。
而最有意思,也最令人担忧的地方在于:
我们整个实验,甚至没有写过一个 unsafe。
完整源代码:
[查看 rustpwn 项目] https://code.wejust.rest/lain/rustpwn
如果你对网络安全、红队攻防技术充满热情,渴望学习更多实战技巧,例如渗透测试、自动化脚本编写、免杀技术等, 欢迎关注我的公众号
在这里,我会持续分享更多高质量的技术文章,与你一同探索网络安全的奥秘,提升实战技能! 让我们一起在队攻防的道路上,不断精进,突破边界!
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论