Foundry Cast 深度解析:cast from-rlp如何修复深度嵌套 RLP 列表的栈溢出
【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundry
导读
本篇文章围绕 Foundry 项目 changelog 条目 cast-deeply-nested-rlp.md 展开,深入剖析cast from-rlp命令针对“深度嵌套 RLP 列表导致栈溢出”这一缺陷的修复方案。文章以该变更记录为核心,结合 rlp_converter.rs 的实现细节与 conversions.rs 中的回归测试,讲解 RLP 编解码的递归风险、迭代式(显式栈)解码、非递归析构与格式化等工程实践。读完本文,你将理解cast from-rlp/cast to-rlp的完整使用方式,掌握如何在 Rust 中安全处理任意深度嵌套的递归数据结构,并能看懂 Foundry 针对此类问题的测试设计思路。
RLP 与 cast 的 RLP 编解码命令
RLP(Recursive Length Prefix,递归长度前缀)是以太坊底层使用的序列化格式,用于编码区块、交易、收据等核心数据结构。它的“递归”特性在于:一个列表中可以嵌套任意层级的字符串与子列表,因此一条 RLP 数据的结构天然是一棵深度可变的树。
Foundry 的 cast 工具提供了两条配套命令,位于 opts.rs:
cast to-rlp(可见别名--to-rlp):把 JSON 数组/十六进制字符串编码为 RLP 十六进制。cast from-rlp(可见别名--from-rlp):把 RLP 十六进制解码为嵌套的 JSON 数组。可选--as-int(别名--int)将 RLP 数据整体当作大整数解码。
两者的命令行帮助注释给出了最基本的往返示例(见 opts.rs):
cast to-rlp "[]"→0xc0cast to-rlp "0x22"→0x22cast to-rlp "[\"0x61\"]"→0xc161cast to-rlp "[\"0xf1\", \"f2\"]"→0xc481f181f2
其中0xc0是空列表的 RLP 编码(0xc0为短列表头),0xc161表示“包含一个字节0x61的列表”,与 conversions.rs 中的 CLI 测试断言完全一致。
从命令分发代码 args.rs 可以看到from-rlp的实现路径:先将输入做十六进制解码(失败时报Could not decode hex),随后在--as-int模式下调用U256::decode,否则调用crate::rlp_converter::Item::decode得到一棵Item树,再to_string()输出为 JSON 风格文本。可见核心的递归结构定义与解码逻辑全部收敛在rlp_converter.rs中,这正是本次补丁的修改对象。
问题背景:深度嵌套列表为何会撑爆调用栈
在修复之前,cast from-rlp对 RLP 列表的解析采用的是“一个函数对应一层递归”的朴素实现:遇到列表就递归调用自身进入子列表,返回时再逐层组装父列表。这种写法在正常情况下没有问题,但 RLP 格式允许攻击者或恶意数据构造出极深的嵌套层级——例如一万层“列表套列表”。此时每次递归都要压入新的栈帧,调用栈会被迅速耗尽,程序以stack overflow崩溃退出。
这个风险并不只存在于解码阶段。即便解码成功,得到的Item树本身依然是深层嵌套的,后续两处看似“自然”的递归同样会触发栈溢出:
- 析构(Drop):Rust 中递归结构体的默认析构会递归释放子节点。changelog 原文(cast-deeply-nested-rlp.md)点明“防止
cast from-rlp在深度嵌套 RLP 列表上撑爆栈”,而源码注释进一步说明:"The default recursive drop can overflow after successfully decoding deeply nested RLP"——即即使解码成功,默认递归析构也可能在解码完成后溢出。 - 格式化(Display):把
Item树输出为嵌套 JSON 文本时,若按树形递归遍历,同样面临相同的栈深度问题。
因此,这是一处需要同时在“解码、释放、格式化”三个环节消除递归的典型修复。
修复核心一:用显式栈做迭代式解码
Item的数据结构定义在 rlp_converter.rs,只有两种形态:
pub enum Item { Data(Vec<u8>), // 字符串/字节数据 Array(Vec<Self>), // 子列表,可任意嵌套 }其Decodable实现(rlp_converter.rs)展示了本次修复的编码技巧——把递归调用替换为显式维护的帧栈(frames):
impl Decodable for Item { fn decode(buf: &mut &[u8]) -> alloy_rlp::Result<Self> { struct ListFrame<'a> { remaining: std::vec::IntoIter<&'a [u8]>, // 本层剩余子项 items: Vec<Item>, // 本层已收集的子项 } let items = match Header::decode_raw(buf)? { PayloadView::String(data) => return Ok(Self::Data(data.to_vec())), PayloadView::List(items) => items, }; let mut frames = vec![ListFrame { remaining: items.into_iter(), items: Vec::new() }]; loop { // 从栈顶帧取下一个子项;取尽则弹栈组装 Array 交给父帧 let Some(encoded) = frames.last_mut().unwrap().remaining.next() else { let frame = frames.pop().unwrap(); let item = Self::Array(frame.items); if let Some(parent) = frames.last_mut() { parent.items.push(item); continue; } return Ok(item); // 栈中只剩一层时即为最终结果 }; // 子项仍是列表则压入新帧,字符串则直接收进当前帧 match Header::decode_raw(&mut &encoded[..])? { PayloadView::String(data) => { frames.last_mut().unwrap().items.push(Self::Data(data.to_vec())); } PayloadView::List(items) => { frames.push(ListFrame { remaining: items.into_iter(), items: Vec::new() }); } } } } }这段代码的要点在于:
- 借助
alloy_rlp的Header::decode_raw与PayloadView,先解析 RLP 头部:若是字符串(PayloadView::String)直接构造Item::Data返回;若是列表(PayloadView::List)则拿到这一层的子项切片迭代器。 - 程序维护
frames: Vec<ListFrame>作为堆上的显式调用栈:遇到嵌套列表就push一个新帧,当前层处理完毕就pop并组装成Item::Array交给父帧,直到栈中只剩根帧并返回。 - 整个解码过程没有函数递归,无论嵌套多少层,每次循环都只操作栈顶帧,栈空间消耗与层级深度无关。
这种“用Vec模拟调用栈、把递归改写为循环”的模式,是处理深度不受控的递归数据的通用解法,也是本次补丁对 rlp_converter.rs 的核心改造。
修复核心二:非递归 Drop 与任务栈式格式化
解码只是第一道关口。修复同时覆盖了解码之后的两处隐患。
析构阶段的非递归展开。Item实现了自定义Drop(rlp_converter.rs),用pending向量显式收集待释放的节点,迭代地把每个Array的子节点展开到pending尾部,从而避免递归式析构:
impl Drop for Item { fn drop(&mut self) { let Self::Array(items) = self else { return }; let mut pending = std::mem::take(items); while let Some(mut item) = pending.pop() { if let Self::Array(children) = &mut item { pending.append(children); } } } }这样即使是解码成功的万层嵌套Item树,其内存释放过程也始终运行在常量级栈空间内,不会在解码“成功”之后又因析构崩溃。
格式化阶段的非递归输出。Item的Display实现(rlp_converter.rs)同样弃用了递归写法,改用Task枚举任务栈:输出一个数组时,把Close(收右括号)压栈,再逆序压入各子项的Item任务与Comma任务,循环弹栈逐个写出。Data以"0x..."十六进制字符串形式输出,Array以[...]嵌套形式输出,最终得到可直接阅读的 JSON 风格文本。
至此,解码、析构、格式化三处递归全部被消除,cast from-rlp在任意深度的 RLP 输入下都能稳定运行。
回归测试:如何验证一万层嵌套不再崩溃
本次补丁在 CLI 集成测试中加入了专门的回归用例,位于 conversions.rs。值得注意的不仅是“测了什么”,更是“怎么构造测试数据”:由于测试进程自身也有调用栈上限,若用递归方式去编码一万层嵌套列表,测试代码本身就会先栈溢出。因此测试采用迭代方式逐层构造 RLP 头:
// Build the RLP encoding of 10,000 nested single-item lists without recursively encoding it. const NESTING_DEPTH: usize = 10_000; let mut encoded_len = 1; let mut headers = Vec::with_capacity(NESTING_DEPTH); for _ in 0..NESTING_DEPTH { let mut header = Vec::new(); Header { list: true, payload_length: encoded_len }.encode(&mut header); encoded_len += header.len(); headers.push(header); } // 逆序拼接各层头部,最后补一个空字符串项 0x80 let mut deeply_nested = Vec::with_capacity(encoded_len); for header in headers.iter().rev() { deeply_nested.extend_from_slice(header); } deeply_nested.push(0x80); cmd.cast_fuse().arg("--from-rlp").stdin(hex::encode_prefixed(deeply_nested)).assert_success();该用例通过 stdin 把深度为 10,000 的嵌套 RLP 喂给--from-rlp,断言命令成功退出且无栈溢出。这一万层数据正是对“解码、析构、格式化”全链路非递归化的端到端验证。
同一测试还覆盖了常规的往返与边界行为(conversions.rs):
| 命令 | 输入 | 期望输出 |
|---|---|---|
cast from-rlp | 0xc0 | [] |
cast from-rlp | 0x0f | "0x0f" |
cast from-rlp | 0x33 | "0x33" |
cast from-rlp | 0xc161 | ["0x61"] |
cast from-rlp | 820002 | "0x0002" |
cast from-rlp | 00 | "0x00" |
cast to-rlp | [] | 0xc0 |
cast to-rlp | 0x22 | 0x22 |
cast to-rlp | ["0x61"] | 0xc161 |
cast to-rlp | ["0xf1", "f2"] | 0xc481f181f2 |
其中820002是一段 2 字节字符串0002的 RLP 编码(82为长度头),普通模式下原样保留前导零输出"0x0002";而在--as-int模式下则会被拒绝。这与rlp_converter.rs单元测试rlp_data(rlp_converter.rs)中针对 foundry-rs/foundry issue #9197 的回归保持一致。
边界行为:--as-int与非规范整数
from-rlp的--as-int模式走的是另一条路径(args.rs):直接对整段字节做U256::decode并输出十进制字符串,而不是构造Item树。该模式对非规范整数编码(即带有前导零的大整数)持严格态度。CLI 测试from_rlp_rejects_noncanonical_integers(conversions.rs)验证了820002与00在--as-int下均以Error: leading zero失败退出——这正是 issue #9197 所描述的歧义场景:同一段 RLP 既可以解释为“字节串”也可以解释为“大整数”,cast 选择在整数模式下拒绝含前导零的非规范形式,避免语义混淆。
对于普通(非--as-int)模式,value_to_item(rlp_converter.rs)则定义了 JSON 侧到Item的转换规则,与to-rlp命令共用:null映射为空数据、数字按U256取规范字节、字符串按十六进制解码、数组递归转换;布尔值与对象({})不被 RLP 支持,直接报错。这与to-rlp的“输入是十六进制编码字符串或十六进制编码字符串数组,可任意递归”(opts.rs)的文档说明互为印证。
总结:从一条补丁记录看递归安全的工程范式
.changelog/cast-deeply-nested-rlp.md虽然只是一行简短的补丁说明,但其背后是三类典型的递归安全隐患及其系统性解法:
- 解码阶段:用显式帧栈(
Vec<ListFrame>)改写递归下降,栈开销与嵌套深度解耦; - 析构阶段:自定义
Drop,以迭代方式展开子节点,防止“解码成功但释放崩溃”; - 格式化阶段:以任务栈(
Task枚举)替代树形递归输出。
与之配套的测试则演示了如何构造深度 10,000 的 RLP 输入而不让测试自身溢出,并同步覆盖普通往返、长数据(from_rlp_long)、非规范整数拒绝等边界。这一整套模式对任何需要解析“深度不可控的递归格式”(如 JSON、XML、表达式树)的 Rust 项目都具有直接参考价值。若要进一步研读实现与测试,可依次查看 rlp_converter.rs、args.rs、opts.rs 与 conversions.rs。
【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundry
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考