著名漏洞摘要第9期:规则降级、ZK约束错误与Aftermath攻击

OpenZeppelin 发布于 2026-07-02 阅读 28

本文分析了三个区块链安全漏洞:1)Stellar智能账户中,规则选择未包含在签名数据中,允许交易赞助者在签名后降级安全规则;2)PrivacyBoost的ZK电路将数组大小参数误用于树编号范围检查,导致第17棵树后所有操作永久停滞;3)Aftermath Finance的永续合约中,整数符号处理不当,攻击者通过负费用注入伪造抵押品提取资金。每个漏洞均附有攻击原理、修复方案及安全启示。

引言

欢迎来到《臭名昭著的 Bug 文摘》第 9 期——这是一份精心策划的汇编,汇集了近期区块链漏洞和安全事件的见解。当我们的安全研究人员不进行审计时,他们会花时间了解最新的安全动态、分析审计报告、并剖析链上事件。我们相信这些知识对更广泛的安全社区具有宝贵价值,为研究人员提供了提升技能的资源,并帮助新手了解区块链安全世界。请和我们一起探索这组漏洞!

漏洞分析:Stellar 智能账户授权中的规则选择降级

此高危漏洞是在 OpenZeppelin 对 OpenZeppelin Stellar 合约库 (RC v0.7.0) 的审计 中发现的。

理解 Soroban 授权模型

Soroban 是 Stellar 基于 Rust 的智能合约平台。它的授权模型与以太坊的 msg.sender 方法不同:当合约调用 require_auth() 时,Soroban 主机拦截该调用并调用账户合约的 __check_auth,传入三个参数:

  • signature_payload(network_id, nonce, expiration_ledger, invocation_tree) 的 SHA-256 哈希,完全由主机计算并在提交时加密固定。
  • auth_payload:用户提供的数据(签名和任何元数据),账户合约负责验证这些数据。
  • auth_contexts:正在授权的操作列表。

在多签名流程中,签名者离线签名,而 赞助者(协调钱包、中继器或费用提升支付者)组装并提交最终交易。签名者承诺 signature_payload;赞助者控制 auth_payload

这种分离是有意为之:主机保证所执行内容的完整性,而账户合约负责验证谁授权了以及在什么条件下授权。

OpenZeppelin 智能账户与上下文规则

OpenZeppelin Stellar 合约 库在此模型之上构建了一个可编程账户。账户存储命名的 上下文规则,每个规则将一组签名者和可选策略合约(例如,支出限制、多重签名阈值)绑定到特定的操作范围。例如,一个财务账户可能有一条严格规则,要求 3/5 签名者加上每日支出上限,同时还有一条会话规则,允许单个密钥在有限时间窗口内进行小额转账。

由于多条规则可能覆盖相同范围,调用者必须通过在 AuthPayload 中提供 context_rule_ids 向量来为每个上下文显式选择一条规则:

pub struct AuthPayload {
    pub signers: Map,       // signer → signature bytes
    pub context_rule_ids: Vec,        // which rule to use per auth_context
}

do_check_auth 函数对每个签名者进行身份验证,针对其选择的规则验证每个 auth_context,并强制执行每个规则的策略。

漏洞

在存在漏洞的版本中,签名是在查阅 context_rule_ids 之前直接针对原始 signature_payload 进行验证的:

// context_rule_ids are NOT included in what signers sign.
authenticate(e, signature_payload, &signatures.signers);

// Rule IDs are taken at face value with no cryptographic binding.
let context_rule_id = signatures.context_rule_ids.get_unchecked(i as u32);
get_validated_context_by_id(e, &context, &all_signers, context_rule_id)

由于 context_rule_ids 从未成为签名数据的一部分,赞助者可以在收集签名后静默地交换规则 ID,而对未更改的 signature_payload 上的签名仍然有效。get_validated_context_by_id 信任提供的规则 ID,而 do_check_auth 只执行所选规则附带的策略,而不是签名者预期的策略。

因此,授权在降级规则下成功,而签名者对此毫不知情或未经同意,绕过了他们认为有效的支出限制或阈值策略等保护措施。以下逐步演示说明了具体的利用场景。

攻击逐步演示

想象一个账户有两条覆盖同一代币合约的规则:

  • 规则 1(严格):需要 3/5 签名者,并强制执行每日支出限制策略。
  • 规则 2(宽松):只需要 1 个签名者,且无任何策略。
  1. 三位签名者签署交易的 signature_payload,期望规则 1 生效。
  2. 赞助者收集签名并构建包含 context_rule_ids = [1]AuthPayload
  3. 在提交前,赞助者将 context_rule_ids = [1] 替换为 context_rule_ids = [2]
  4. do_check_auth 运行:签名验证通过(signature_payload 未更改),规则 2 被应用,支出限制从未被检查,转账成功且无上限。

https://7795250.fs1.hubspotusercontent-na1.net/hubfs/7795250/video_assets/215960413270/inherited/web_optimized.mp4

在更具破坏性的变体中,赞助者可以选择一条只需要一个由他们自己控制的签名者的规则,从而有效地将一个健壮的多重签名缩减为单点失陷,而其他签名者完全不知道他们配置的保护措施已被绕过。

修复

修复后,计算一个 auth_digest,它承诺同时包含主机负载和规则选择,然后针对该组合摘要对签名者进行身份验证:

// auth_digest = sha256(signature_payload || context_rule_ids.to_xdr())
let mut preimage = signature_payload.to_bytes().to_bytes();
preimage.append(&signatures.context_rule_ids.clone().to_xdr(e));
let auth_digest = e.crypto().sha256(&preimage);

// Any post-collection change to context_rule_ids invalidates all signatures.
authenticate(e, &auth_digest, &signer, &sig_data);

现在替换 context_rule_ids 会改变 auth_digest,导致签名验证失败。因此,签名者必须针对 auth_digest 而非原始 signature_payload 生成他们的签名。

修复还添加了一个显式的 UnauthorizedSigner 检查,拒绝 AuthPayload 中其密钥未出现在所选规则中的任何签名者,从而关闭了一个相关向量,攻击者可能通过该向量将任意验证者合约注入签名者映射。

要点:在将离线签名收集与交易组装分开的系统中,每个安全关键参数都必须加密绑定到签名者签署的内容中。将规则选择排除在已签名负载之外会产生一个完全由提交方控制的静默降级向量。更广泛地说,任何可配置的授权范围,如果签名者无法独立验证,都是此类攻击的候选对象。


漏洞分析:ZK 电路约束错误导致 PrivacyBoost 永久停止

此严重漏洞是在 OpenZeppelin 对 Sunnyside Labs 的 PrivacyBoost 协议审计 中发现的。

理解 PrivacyBoost

Sunnyside Labs 构建的 PrivacyBoost 是一个基于周期的 EVM 兼容区块链屏蔽池,允许用户进行机密代币转账。其核心思想沿用了 Zcash 中熟悉的 UTXO 模型:协议不跟踪余额,而是发行私人票据,每个票据表示一个承诺,形式为 Poseidon(npk, tokenId, value, rnd)。花费票据需要通过零知识证明证明所有权,从而向公众隐藏发送者身份和交易金额。

为了在链上存储这些承诺,PrivacyBoost 使用深度为 20、容量高达 2^20 个叶子的仅追加默克尔树。当树填满时,协议执行轮换:完整的树成为永久历史,一个新的空树变为活跃状态。每棵树获得一个单调递增的 15 位整数作为其标识符,这意味着理论上协议在其生命周期内最多可容纳 32,768 棵树。

https://7795250.fs1.hubspotusercontent-na1.net/hubfs/7795250/video_assets/215960411779/inherited/web_optimized.mp4

一个可信执行环境 (TEE) 充当中继,收集用户交易并将其打包成周期。对于每个周期,中继生成 Groth16 零知识证明以证明批处理的有效性,并将其提交到链上。三个独立的 ZK 电路处理不同的操作:周期提交、存款和强制提款。强制提款电路值得特别关注:它作为协议的反审查逃生舱口,允许任何用户在中继不合作的情况下,在时间延迟后通过直接提交自己的 ZK 证明退出。

ZK 电路的一个重要特性是它们的约束被编译成一个固定的约束系统。更新电路不是推送代码更改那么简单:需要重新编译电路,运行一个新的多方可信设置仪式来生成新的证明和验证密钥,并重新部署新的验证者合约。这使得 ZK 电路漏洞的修复成本比普通智能合约漏洞高得多。

漏洞

所有三个电路都包含一个范围检查,将活跃树编号限制为小于 Shape.MaxTrees,一个设为 16 的常量:

// circuit constraint present in all three circuits
assert activeTreeNumber < Shape.MaxTrees   // Shape.MaxTrees = 16

问题在于 Shape.MaxTrees 是一个形状参数:它定义了每个批处理中证明数组可以容纳多少个树根,这是关于电路数据布局的结构性大小细节。它与树标识符的有效域无关。合约正确地定义了树编号为 15 位单调递增标识符,最大值为 32,767。

这种不匹配制造了一个定时炸弹。对于前 16 棵树(标识符 0 到 15),一切正常。当第 16 棵树填满并发生轮换时,currentTreeNumber 变为 16。合约现在要求任何有效证明引用树编号 16,但电路断然拒绝任何 activeTreeNumber >= 16 的证明。无法为任何操作构建有效证明。存款、转账、提款和强制提款全部永久停止。

https://7795250.fs1.hubspotusercontent-na1.net/hubfs/7795250/video_assets/215960159775/inherited/web_optimized.mp4

常量文件进一步加剧了问题。有两个常量定义了树编号范围检查的位宽:

NoteTreeNumberBits  = 5   // range check: 5-bit → max value 31
AuthTreeNumberBits  = 5   // range check: 5-bit → max value 31
TreeNumberBitsPerSlot = 15 // packing: 15-bit → max value 32767

即使将 < Shape.MaxTrees 边界纠正为更大的值,用于范围验证的 5 位分解对于任何超过 31 的树编号都会失败。漏洞的两个层面需要一起修复。

后果是彻底的:所有用户资金变得无法访问,而为应对恶意中继行为而设计的强制提款机制也失效了。逃生舱口正是在用户最需要它的时刻失效。

修复

审计建议要么完全移除 < Shape.MaxTrees 范围检查(因为树的有效性已通过 (treeNumber, root) 匹配逻辑强制执行),要么将其替换为对正确 15 位域 activeTreeNumber < 32768 的约束。位宽常量也需要更新为 15 以匹配打包逻辑。

两种选项都不是简单的热修复。两者都需要重新编译所有三个电路,并运行一个新的可信设置仪式来生成新的证明和验证密钥,然后才能将更新后的验证者合约部署到链上。

Sunnyside Labs 团队在主网部署之前解决了这个问题。

要点:在 ZK 系统中,形状参数和值域约束在代码中看起来很相似,但含义完全不同。用于为证明布局确定数组大小的参数与约束该数组索引的标识符范围毫无关系。除了概念性错误之外,这个案例还说明了 ZK 电路漏洞的不对称性:它们编译时没有错误,在有效范围内通过所有测试,只有在协议运行足够长时间跨越阈值后才会显现。强制提款电路与主电路同时失效,这提醒我们,共享相同缺陷假设的安全机制在该假设失效时无法提供任何保护。

事件分析:Aftermath Finance 在 Sui 上的永续合约中的有符号整数位走私

2026 年 4 月 29 日,一名攻击者利用了 Sui 上 Aftermath Finance 的永续期货协议。团队随后发布了一份详细的 事后分析,指出根本原因是集成者费用会计逻辑中的有符号整数缺陷,并承诺对受影响的用户进行赔偿。本分析通过代码层面的视角,补充了该披露:一个 u256 存储字段、一个手写的补码库以及一个未加防护的公共设置器如何在单笔交易中逆转抵押品会计。

关于源代码的说明:Aftermath 的合约部署时未经验证的源代码,因此以下所有代码均通过 Revela 反编译器(通过 SuiVision)从链上字节码重建。诸如 v3v8v40v57 等标识符是反编译器生成的占位符;函数签名、结构体字段名称和断言错误名称由编译器保留且可靠。

背景:没有原生有符号类型的带符号算术

Move 仅提供无符号整数(u8 到 u256),尚不提供有符号类型。需要带符号数量(盈亏、资金费率、增量等任何可能低于零的量)的协议通常实现为操作 u256 值的函数库,采用补码解释。第 255 位充当符号位:低于 2^255 的值非负,等于或高于 2^255 的值负(幅度为 2^256 − 原始值)。然后使用位操作技巧在这些编码上实现标准的有符号操作。

Aftermath 的 ifixed 库正是这样做的,固定点比例为 10^18(因此 1.0 是原始 u256``10^18)。每个公共函数都接受并返回 u256。该库内部是正确的:其从无符号整数空间跨越到有符号固定点空间的转换函数(from_balancefrom_u256to_* 系列)对可能落入范围负半部分的输入执行符号一致性断言。较窄宽度的转换器如 from_u64from_u128 跳过了显式断言,因为它们的输入在乘以 10^18 后无法达到负半部分。保护是真实的,尽管只在库期望它重要的地方。

漏洞不在于 ifixed 本身,而在于永续合约包(独立部署并依赖于该库)如何与其集成。集成者费用路径绕过了库假定的边界纪律。

漏洞

每个利用周期是一个单独的可编程交易区块 (PTB),它打开两个账户、将攻击者注册为其自己的集成者并设置负的接受方费用、针对真实对手方的做市单执行市价单,然后提取由此产生的合成抵押品。攻击者在约 36 分钟内重复了该周期 17 次(11 次成功,6 次失败)。相关有效负载位于三个调用中:

MoveCall  9: add_integrator_config(addr, max_taker_fee = 0)
MoveCall 13: create_integrator_info(addr, taker_fee = 2^256 − 10^23)
MoveCall 15: place_market_order(...)

清算所中的 IntegratorInfo 结构体将费用存储为原始 u256,直接从用户输入设置,无边界检查:

struct IntegratorInfo has copy, drop {
    integrator_address: address,
    taker_fee: u256,
}

public fun create_integrator_info(arg0: address, arg1: u256): Option {
    let v0 = IntegratorInfo {
        integrator_address: arg0,
        taker_fee: arg1,            // raw u256, no boundary check
    };
    option::some(v0)
}

没有任何验证确保位模式在下游代码应用的有符号解释下是合理的。

攻击逐步演示

恶意值: 攻击者传入 taker_fee = 2^256 − 10^23。作为 u256,这是一个接近范围顶部的大正数。作为 ifixed 解释下的有符号固定点值,它在用户空间中解码为 -100,000.0(原始幅度 10^23,除以 10^18 比例,由于高位设置而为负)。相同的 256 位根据解释规则意味着不同的东西。漏洞在于该值在一种规则下写入,在另一种规则下读取,且没有任何强制一致性的措施。

边界绕过: 写入路径直接存储原始 u256。库的符号一致性断言从未被调用,因为没有调用任何转换函数。该值以原始 u256 进入并保持原始状态直到被读取。费用计算稍后在会话关闭时运行(end_sessionprocess_session_hot_potatoprocess_fill_takercalculate_taker_fees),其中存储的值由 ifixed::less_than_eqifixed::mul 使用。两者都在有符号空间中运行,并将位模式解码为补码。它们不会、也不应该重新验证已类型化为 ifixed 的值。验证本应在边界处进行,但协议绕过了边界。

上限设置为零: 作为漏洞结构性质的明确证据:攻击者将其 max_taker_fee 上限设置为 0,这是最严格可能的_无符号_界限,但绕过仍然有效。在有符号的 less_than_eq 下,零位于可表示范围的_中间_:整个上半个 u256 解码为负,任何负值都显然 ≤ 0。检查 signed_lte(-100,000, 0) 返回 true。只有比较器的解释规则重要;上限值无关紧要。即使是合理的正上限也提供不了防御。

抵押品膨胀:calculate_taker_fees 中,解码后的 -100,000 乘以交易的报价量:

let v3 = v5.taker_fee;            // attacker's −100,000
ifixed::mul(v3, arg2)             // arg2 = quote_filled  → large negative ifixed

那个大的负集成者费用 (v8) 随后流入头寸更新:

position::add_to_collateral_usd(
    position,
    ifixed::sub(v6, ifixed::add(v7, v8)),    // v6: pre-fee collateral delta
                                              // v7: base (protocol) taker fee
                                              // v8: integrator taker fee (malicious)
    collateral_price,
);

预期的语义:取抵押品增量 v6,减去两个费用,应用结果。当两个费用都为正且较小时,这会从交易者的抵押品中扣除大约费用总额。当 v8 为大的负值时,它被反转:v7 + v8 是一个大的负值,而 v6 - (大的负值) 变成 v6 + |v8|,一个大的正值。本应_扣除_小额费用的路径反而向头寸_注入_了大量合成美元信用。抵押品被记入大量正值,而 FilledTakerOrder 事件将集成者费用本身(相同幅度,相反符号)记录为 integrator_taker_fees 中的大负 ifixed 值。

兑现: 在抵押品膨胀到位后,compute_free_collateral 诚实报告了相对于所需保证金的大量盈余,攻击者调用 deallocate_free_collateral,随后调用 withdraw_collateral,以极小的种子存款提取真实的 USDC。被提取的流动性是由其做市单与攻击者市价单匹配的交易对手方提供的。

https://7795250.fs1.hubspotusercontent-na1.net/hubfs/7795250/video_assets/215960411821/inherited/web_optimized.mp4

为什么现有安全防护未能捕获它

  1. 库的边界断言被绕过,因为协议从未跨越边界: 每个需要符号一致性检查的 ifixed 转换都带有一个,但集成者费用路径未调用其中任何一个。u256 参数直接进入结构体字段,并在有符号空间中未经重新验证就被使用。检测是真实且正确放置的;该值只是从未经过它。
  2. max_taker_fee 上限在有符号比较下强制执行:cap = 0 选择所示,一旦比较器将值解码为有符号,上限对负输入并不严格。最严格的无符号界限提供不了保护。
  3. negative_fees_accrued 断言守卫了错误的变量:process_session_hot_potato 中:
let v57 = ifixed::add(v11, v40);
assert!(!ifixed::is_neg(v57), errors::negative_fees_accrued());

断言的名称编码了对这一威胁类别的防御,但 v57 聚合了会话的运行做市商费用总额 (v11) 和_基础_接受方费用 (v40),这两者都不包含恶意的集成者费用。集成者费用通过 add_to_collateral_usd(在此断言运行之前)膨胀抵押品,并在之后累积到 IntegratorVault.fees 中。两条路径都避开了检查。保护存在,但它守卫的是相邻变量。

修复

Aftermath 的响应迅速:数小时内识别、公开披露、生态系统协调,并承诺赔偿受影响的用户。直接的代码修复是在边界验证费用:在将其作为 ifixed 值存储之前,通过库其余部分依赖的相同符号一致性断言运行 u256 输入,并使用与后续读取值相匹配的解释进行比较来强制执行费用上限。

更持久的修复是结构性的。核心缺陷在于,一个安全关键值在无符号规则下写入,在有符号规则下读取,而类型系统在两者之间没有任何强制一致性。可以通过以下方式避免:

  • 将数量包装在具有验证构造函数的标称结构类型中: 诸如 public struct Amount(u128) 之类的标称包装器,或者在无法立即迁移时对现有数学库进行边界包装,使无效位模式不可表示,并在所有使用点(包括未来的使用点)携带验证不变量,而不是依赖每个调用点记住检查。
  • 在每个字段声明处显式做出有符号与无符号的决策: 使用区分有符号和无符号的类型声明字段可以在代码中记录选择,并在审查中可见:不同的无符号类型使负值不可表示,而有符号类型强制任何负值必须有意构造。

要点: 此漏洞存在于两个解释规则之间的间隙中:协议在一种规则下写入,在另一种规则下读取,且没有语言级别的强制一致性。每当在重新解释的无符号位模式上构建自定义有符号算术时:保持安全的纪律由约定强制执行,而约定会失效。边界断言仅在实际跨越边界的路径上有帮助,如果比较器的解释与写入者不同,上限毫无意义。具有验证构造函数的标称类型包装器将该纪律移到类型系统中,在那里无法跳过,并强制在每个字段声明处有意做出有符号与无符号的选择,而不是偶然继承。

常见问题

什么是 Stellar 智能账户上的规则选择降级攻击?

在 Stellar 的 Soroban 授权模型中,签名者承诺由主机计算的固定 signature_payload。在存在漏洞的 OpenZeppelin Stellar 合约库版本中,确定应用哪个授权策略的 context_rule_ids 向量未包含在签名数据中。交易赞助者可以在收集签名后替换规则 ID,将严格的多重签名加支出限制规则降级为较弱的单签名者规则,而所有签名仍然有效。修复将 context_rule_ids 绑定到与主机负载组合的 auth_digest 中,因此任何签名后的修改都会使所有签名失效。

ZK 电路约束错误如何导致 PrivacyBoost 永久停止?

PrivacyBoost 的所有三个 Groth16 电路都使用 assert activeTreeNumber < Shape.MaxTrees 约束活跃树编号,其中 Shape.MaxTrees = 16 是一个证明数组大小参数,而不是树标识符的有效上限。合约正确地将树编号定义为 15 位单调递增标识符,最大值为 32,767。在第 16 棵树轮换后,currentTreeNumber 变为 16,电路拒绝该值,使得无法为任何操作生成有效证明,包括存款、转账和强制提款逃生舱口。解决该漏洞需要重新编译所有三个电路并运行新的可信设置仪式以生成新的证明和验证密钥。

2026 年 4 月 Aftermath Finance 在 Sui 上的攻击原因是什么?

攻击源于永续协议集成者费用会计中的有符号整数缺陷。IntegratorInfo 结构体将接受方费用存储为原始 u256,无边界验证。攻击者传入 2^256 - 10^23,该值在协议的补码 ifixed 解释下解码为 -100,000.0。当费用计算从抵押品增量中减去该大的负值时,它反转了会计,向头寸注入了大量合成美元信用。攻击者随后调用 deallocate_free_collateralwithdraw_collateral,以极小的种子存款提取真实 USDC。

为什么 Aftermath Finance 的现有安全防护未能捕获此次攻击?

ifixed 库的符号一致性断言从未被调用,因为集成者费用路径直接将原始 u256 存储到结构体字段,绕过了所有转换函数。max_taker_fee 上限使用有符号比较器强制执行,因此将上限设为 0 无法提供保护,因为任何补码负值都显然小于或等于 0。negative_fees_accrued 断言检查的是一个聚合变量,仅覆盖基本协议费用,而非膨胀抵押品的集成者费用路径。

免责声明

需要强调的是,本内容的意图不是批评或指责受影响的项目。相反,目标是提供对漏洞的客观分析,作为区块链社区学习和未来更好地保护自己的教育材料。

  • 原文链接: openzeppelin.com/news/th...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论