本文分析了近期Web3领域中出现的一些漏洞和安全事件,包括Balancer被攻击事件中攻击者如何绕过资产冻结,ZKsync OS中usize类型引起的非确定性和panic问题,以及由于返回数据缓冲区溢出导致L1->L2执行被中断的问题。文章旨在为安全社区提供学习资源,提高安全意识。
欢迎来到臭名昭著的 Bug Digest #6,这是一个精心策划的 Web3 最新 bug 和安全事件的合集。我们的安全研究人员在进行审计之余,还会花时间了解最新的安全领域,分析审计报告,并剖析链上事件。我们相信这些知识对更广泛的安全社区来说是非常宝贵的,它为研究人员提供了一个磨练技能的资源,并帮助新手们在 Web3 安全世界中游刃有余。加入我们,一起探索这批 bug!
2025 年 11 月 3 日,Balancer V2 由于其 Stable Pool 模块中的精度损失漏洞而遭到黑客攻击。OpenZeppelin 发布了一份详细的分析,概述了根本原因以及问题是如何产生的。
作为 Balancer 的一个分支,Sonic 链上的 Beets 协议也因此受到同一漏洞的影响,导致攻击者窃取了价值约 300 万美元的 Beets Staked Sonic (stS) 代币。
作为回应,Sonic 团队试图通过更新他们的 Special Fee Contract(一种有权修改 EVM 状态数据库的合约)来冻结攻击者的账户,从而控制利用。如下图 A 所示,添加的 freeze 函数将账户的原生代币余额设置为零,并将账户的代码替换为一个特殊合约,该合约没有 public 函数,也无法接收原生代币。

这种 L1 级别的冻结方法起初似乎很有效:攻击者没有批准任何其他地址来转移他们的代币,所以他们只能自己发起转账。由于原生代币为零,他们无法支付 gas 费来这样做。
然而,Beets Staked Sonic 代币包含一种不需要代币持有人支付 gas 的方法:permit。它在 ERC-2612 中定义,允许通过账户的签名消息来更改账户的 ERC-20 授权。与 approve 不同,代币持有人不需要发送交易,因此不需要持有任何原生代币。通过利用这一点,攻击者首先使用 permit 来授权给他们控制的另一个账户,然后触发 一个 transferFrom 调用,将冻结的代币转账到该账户,最后通过多个交易 交换了 stS 代币。
要点:资产冻结应在代币合约级别实施。否则,攻击者可能会通过使用元交易、permit 授权或类似的无 gas 授权方案来绕过冻结。
usize 算术可能导致 ZKsync OS 中的不确定性和 panic这个关键的 bug 是 OpenZeppelin 在最近的 ZKsync OS 审计中 发现的。我们将首先解释 Rust 中 usize 的行为,然后我们将介绍 ZKsync OS 如何在底层使用两种不同的架构,最后解释发现的问题。
usize 的怪癖usize 无符号整数类型在 32 位机器上解析为 32 位无符号整数,在 64 位机器上解析为 64 位无符号整数。虽然 usize 通常用于内存索引,但其依赖于架构的性质可能会导致相同的代码在不同的硬件目标上运行时出现不一致,尤其是在像 ZKsync OS 这样在 32 位和 64 位架构上运行的系统中。
考虑一个示例,其中 usize 变量用于存储偏移量值。如果这个偏移量需要容纳大于 2 ^ 32 - 1 的值,32 位系统将无法表示它,可能会导致截断或转换错误。在 64 位系统上,相同的值可以毫无问题地处理。这种差异可能会导致程序行为和跨架构的执行流程出现关键差异,即使最终结果是失败。问题在于失败发生在执行的不同点上,这对于需要确定性行为来生成证明的系统来说是有问题的。
ZKsync OS 的核心是利用 32 位和 64 位架构来优化性能和证明生成。在实时交易处理(“前向模式”)中,系统利用高吞吐量、性能优化的环境,该环境受益于 64 位操作,从而最大限度地提高实际区块链工作负载的速度和效率。相反,在生成零知识证明(“证明模式”)时,架构会切换到基于 RISC-V 的 32 位模拟环境,从而确保证明生成的高性能执行。
usize 引起的不一致在审计期间,在整个代码库中发现了多个由 usize 引起的不一致实例,证明了 usize 算术如何导致不确定性:
l1_messenger's sendToL1(bytes) 函数中,动态字节参数解析使用 usize。一个精心设计的 calldata,其长度 接近 u32::MAX,但实际长度不足,可能会导致偏移量指向不存在的数据。这导致了一个关键的差异:随后的加法在 64 位 sequencer 上通过,但由于 calldata 短 而在稍后失败,而相同的操作在 32 位 prover 上会在受检加法期间更早回退。l2_base_token 模块中,l2_base_token_hook_inner 函数 尝试使用 try_into 函数 将 message_offset 转换为 usize。恶意用户可以构造一个偏移量接近 2^32-1 的 calldata,同时确保 calldata 足够短,以便指针引用不存在的数据。因此,代码的行为会因目标架构而异。在 64 位系统上,它会导致错误 在稍后的偏移量有效性检查期间发生。相反,在 32 位系统上,错误会更早 在加法运算期间发生。虽然这两个交易都会失败,但每个失败都发生在不同的位置。这会带来一个问题,因为 prover 运行需要产生与 runner 模式下计算的结果相同的结果。否则,L1 上无法证明区块执行,导致 L1 区块验证停止。此类 bug 有时可能不太明显,因为它们也可能在舍入而不是加法期间出现。例如,ZKsync OS 依赖其引导加载程序 来协调核心功能,包括区块内的交易处理。try_begin_next_tx 函数 由引导加载程序在每个新交易开始时调用。此函数将报告的字节长度向上舍入到机器字边界 (USIZE_SIZE),然后尝试迭代交易内容。
但是,在 USIZE_SIZE 为 4 的 32 位系统中存在漏洞。在这些环境中,如果输入大小接近 u32::MAX,则计算 next_tx_len_bytes.next_multiple_of(USIZE_SIZE) 可能会导致 usize 溢出。在发布版本中,此溢出将回绕到 0,导致即使交易内容迭代器不为空,next_tx_len_usize_words 也变为 0。对于该大小的输入,此问题不会在 64 位目标上发生,从而导致跨不同架构处理交易数据的方式存在差异。
要点:在需要跨架构的确定性行为的系统中,应避免将架构相关的类型(如 usize)用于算术运算。相反,应使用固定大小的整数(例如,u32 或 u64),这将确保所有环境中的结果一致且可预测。
ZKsync OS 执行环境预先分配一个 128 MB 的 return_data 缓冲区,用于存储从外部调用返回的数据。当从外部调用返回数据时,它被复制到缓冲区,并且剩余的 return_data 缓冲区缩小。如果没有足够的空间来复制 return_data,将导致 panic。
.png?width=1592&height=732&name=carbon%20(15).png)
我们强调了一种攻击向量,即 return_data 缓冲区可能被恶意交易故意溢出。攻击者可以构造一个合约,通过多次外部调用用户空间程序或 Identity 预编译合约,使 return_data 缓冲区溢出,同时仍保持在 ZKsync OS 允许的最大 gas 限制之下。虽然此问题对于所有交易而言都至关重要,但它也对 L1->L2 交易队列产生了额外的影响。
L1->L2 交易中的 panic 会导致当前的 L1->L2 交易永久失败。这是由于 ZKsync 的 L1->L2 交易的性质所致,这些交易被添加到队列中并且仅按顺序处理。这会造成完全阻塞,停止整个队列,并阻止所有后续的 L1->L2 交易被处理。
该问题通过将 return_data 缓冲区大小加倍来缓解,从而确保在仍低于最大 gas 限制的情况下不会溢出。此外,ZKsync OS 系统现在将此类错误视为来自故意攻击的“致命错误”,并且此类交易将在花费 gas 的同时回退。
要点:应仔细审查交易处理逻辑,以查找任何可能导致节点崩溃的场景。应避免或妥善处理此类场景,否则,每次重新启动时,交易处理都可能会无限期地崩溃节点。
重要的是要强调,此内容背后的意图不是批评或指责受影响的项目,而是提供客观的概述,作为社区学习并更好地保护未来项目的教育材料。
准备好保护你的代码了吗?
- 原文链接: openzeppelin.com/news/th...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!