DK27ss/Aiken-opaque-expect-bypass

GitHub: DK27ss/Aiken-opaque-expect-bypass

该研究揭示了 Aiken 编译器的 expect 语句在解构 opaque 类型时绕过不变性检查,导致 Cardano 智能合约验证器可被攻击者注入畸形数据的安全漏洞。

Stars: 0 | Forks: 1

# `expect` 破坏了 opaque 类型的不变性边界(Aiken 编译器) *将攻击者的 `Data` 静默解码为一种由模块维护其不变性,但* *从未被重新建立的 opaque 类型,这导致每一个假设了该不变性的读取操作(`dict.get`、`has_key`、* *`quantity_of`、`rational.compare` 以及 opaque 智能构造器的保证)都变成了* *由攻击者控制的值。* ***免责声明*** *这项工作仅出于研究、教育和安全分析目的而进行,旨在帮助提高 Cardano 生态系统以及使用 Aiken 开发的项目的安全性和弹性。* *所提供的信息、技术、发现或概念验证材料仅用于识别、理解和负责任地报告潜在的安全问题。它们**绝不**旨在鼓励、促成或促进任何恶意活动。* *对于第三方在 Cardano 区块链或任何使用 Aiken 开发的项目上使用、修改或应用本研究而造成的任何损害、误用、未经授权的操作或后果,我**不承担任何责任**。* *任何选择使用或应用这些信息的人,需**完全自行承担风险**,并有责任确保其行为符合所有适用的法律、法规以及受影响项目的相关政策。* ## 0. TL;DR - **是什么:** 在 Aiken 中,`opaque type` 隐藏了内部表示,并通过其智能构造器维护一个*不变性*(例如 `Dict` = 已排序、唯一键;`Rational` = 分母 > 0;`Value` = 嵌套的已排序 map;用户自定义的 `opaque Shares { Int }` = 非负)。`expect x: T = data` 仅解码了它的**表示**,并且**从不重新检查不变性**。 - **后果:** 传入一个其 opaque 字段违反了不变性的 datum/redeemer。`expect` 会接受它。随后每一个*假设*不变性成立的操作都会返回一个由攻击者选择的值——伪造的余额、隐藏的条目、反转的价格比率,或是负数的份额。 - **与 Trace 无关:** 与 F-7 原语擦除漏洞不同,该漏洞在 `verbose`、`compact` **和** `silent` 模式下都存在。普通测试之所以能通过,仅仅是因为测试输入的是格式良好的数据。 - **受影响版本(已在 7 个二进制文件上验证):** `1.0.24`、`1.1.21`、`1.1.22`、`1.1.23`。 **不受影响版本:** `1.0.26`、`1.0.28`、`1.0.29`。此漏洞是一个在 1.0.26 中被修复的**回退**,随后由于防护机制中未标注解构漏洞而在整个 1.1.x 系列中再次出现。 ## 1. 受影响版本 测试惯用法:验证器读取 datum 字段的日常方式为 `expect Datum { balances, .. } = raw`,其中 `balances : Dict`。 | 版本 | `ExpectOnOpaqueType` 防护 | 真实的 `Dict` 解构字段 | exploit | |---------|---------------------------|-------------------------------|-----| | 1.0.24 | **缺失** | 接受(无防护) | **存在** | | 1.0.26 | 存在(+ 明确消息) | **在类型检查阶段被拦截** | 已关闭 | | 1.0.28 | 存在 | **已拦截** | 已关闭 | | 1.0.29 | 存在 | **已拦截** | 已关闭 | | 1.1.21 | 存在,**有漏洞** | **可编译,无不变性检查** | **存在** | | 1.1.22 | 存在,有漏洞 | 可编译,绕过防护 | **存在** | | 1.1.23 | 存在,有漏洞 | 可编译,绕过防护 | **存在** | 该防护为 `Error::ExpectOnOpaqueType`(位于 `crates/aiken-lang/src/tipo/environment.rs:1720`),在类型转换目标 `contains_opaque()` 时于 `unify(..)` 内部触发。在 1.1.x 版本中,它只对**绑定**(`expect x: T = d`)触发,但**对未标注的解构** `expect T { field, .. } = d` 会被跳过,而这正是实际开发中读取字段的方式。 ## 2. opaque 类型及其不变性 Aiken 中的 `opaque type` 包装了一个内部表示,并在定义模块之外将其隐藏。调用者只能通过模块导出的**智能构造器**来构建值,而这些构造器会强制执行一种结构性的**不变性**。标准库在各个地方都依赖于这一点: ``` // aiken/collection/dict pub opaque type Dict { inner: Pairs } // INVARIANT: `inner` is sorted ascending by key, with NO duplicate keys. // Maintained by: new / insert / from_pairs (sorts) / from_ascending_pairs (validates). ``` `Value` 是对 `Dict>` 的 `opaque`(具有相同的不变性,且为嵌套结构)。 `Rational` 是 `opaque { numerator: Int, denominator: Int }`,其不变性为 `denominator > 0`,需通过 `rational.new` 构建。用户也可以定义自己的类型:例如带有非负性智能构造器的 `opaque Shares { Int }` 等。 image 读取端**信任该不变性**以保证正确性。在 stdlib ≥2.x 中: ``` // aiken/collection/dict early-terminating get: ASSUMES sorted order fn do_get(self: Pairs, k: ByteArray) -> Option { when self is { [] -> None [Pair(k2, v), ..rest] -> if k <= k2 { // builtin.less_than_equals_bytearray if k == k2 { Some(v) } else { None } // <-- STOPS: "we've passed where k would be" } else { do_get(rest, k) } } } ``` 如果列表未排序,`do_get` 的逻辑就会出错;重复的键会使 `get` 返回*第一次*出现的结果(这对于线性的 stdlib-1.6.0 `do_get` 和提前终止的 2.x 版本都成立)。 ## 3. 根本原因 `expect x: T = data` 会编译为一个**解码器**,该解码器会遍历 `Data` 并检查它是否匹配 `T` 的*表示*(即构造器标签、字段元数以及基础的内置类型,如 `unMapData`、`unBData`、`unIData` 等)。它**不会**调用模块的智能构造器,也**不会**重新运行任何不变性检查。对于 opaque 类型,这正是抽象边界的破坏:输出的值虽然被标记为 `Dict`/`Value`/`Rational`/`Shares` 类型,但从未被拥有这些不变性的代码验证过。 image ### 3.1 擦除不变性的确切代码行 `expect` 解码器由 `expect_type_assign` (`crates/aiken-lang/src/gen_uplc.rs`,在 v1.1.23 中位于 **1912** 行 / 在 v1.1.21 中位于 1830 行的 `fn`)生成。它的第一个动作 就是剥离 opaque 包装器: ``` // gen_uplc.rs (v1.1.23:1926-1932 ; v1.1.21:1844-1850) // Shouldn't be needed but still here just in case // this function is called from anywhere else besides assignment let tipo = &convert_opaque_type(tipo, &self.data_types, true); // <-- opacity ERASED here let uplc_type = tipo.get_uplc_type(); // now a bare Map/List/Int match uplc_type { /* emits unMapData + element type-checks, nothing more */ } ``` `convert_opaque_type(.., deep = true)`(`crates/aiken-lang/src/tipo.rs:741`)会递归地将 opaque 类型替换为其底层表示(`Dict` → 其内部的 `Pairs`/Map),从 1928 行开始,编译器就再也不知道曾经存在过不变性了;它生成的解码器只是针对普通 map 的解码器。**正是这一行代码使得漏洞利用成为可能。** ### 3.2 为什么它会作用于嵌套的 `Dict` 字段(现实路径) `expect_type_assign` 是**递归的**:它会在构造器字段、元组项和键值对的两端调用自身(v1.1.23:1971, 1988, 2109)。因此,读取 `expect Datum { balances, .. } = raw` 时会递归进入 `balances : Dict` 字段,并且 1928 行会对该嵌套字段进行去 opaque 化。因此,这种擦除并不是一个仅限于顶层的怪癖,它恰好作用于协议通常使用的结构(即 datum 内部的 opaque map)。 ### 3.3 安全性被委托给了一个存在泄漏的类型检查防护 代码生成器的作者假设这种剥离是安全的——参见注释 *"Shouldn't be needed … just in case this function is called from anywhere else besides assignment."* 其假设是,带有不变性的 opaque **永远无法合法地到达代码生成阶段**,因为类型检查器会先用防护拦截它: ``` // crates/aiken-lang/src/tipo/environment.rs:1720 (inside unify) if allow_cast && lhs.contains_opaque() { return Err(Error::ExpectOnOpaqueType { location }); // "reckless opaque cast" } ``` `contains_opaque()`(`tipo.rs:231`)会通过泛型参数进行递归,因此它*确实*能看到嵌套的 `Dict`,防护诊断(`error.rs:292`)甚至明确指出了危险: *"ensure that any structural invariant is checked for."*(确保检查了任何结构性不变性。) **缺陷在于这种割裂:** 代码生成器无条件地将 opaque 解码为其表示形式,*假设* 防护已经拦截了危险情况。但该防护只对**绑定**(`expect x: T = d`)触发;而**未标注的解构** `expect T { field, .. } = d`(这是日常读取 datum 的惯用法)却能漏过它并到达 1928 行。在 1.0.24 版本中,这种防护根本不存在。 ## 4. 漏洞利用机制(两个被独立证明的部分) ### 部分 A(生成的解码器未发出任何不变性检查) 编译一个仅用于解码的验证器并反汇编其 UPLC: ``` pub type Datum { Datum { owner: ByteArray, balances: Dict } } pub fn probe(raw: Data) -> Int { expect Datum { balances, .. } = raw when dict.get(balances, #"01") is { None -> -1 ; Some(x) -> x } } ``` ``` $ aiken build -t silent # v1.1.21 / v1.1.22 / v1.1.23 -> compiles $ aiken uplc decode --cbor ``` 结果(在 1.1.21/22/23 上完全一致,共 506 行):**解码区域**使用了 `unMapData` + 对每个元素进行 `unBData`/`unIData` 类型检查,并且**不包含**任何排序或相等性比较。整个程序中唯一存在的 `lessThanEqualsByteString` / `equalsByteString` 都位于 `do_get` **内部**,而不在解码器中。证明:解码器不会排序、去重或拒绝任何内容——攻击者提供的任何 map 都会被原封不动地包装成 `Dict`。 ### 部分 B(假设不变性的读取会返回由攻击者控制的值) 在手动构建的畸形 map 上运行确切的 stdlib `do_get`(使用 List/Pair 字面量;没有构建 `Data`,因此可以干净地编译通过)。在 **v1.1.21、v1.1.22、v1.1.23**(全部通过)上: | 畸形 map (`balances`) | `dict.get(#"01")` | 攻击者收益 | |----------------------------------------|-------------------|---------------| | 重复键 `[(#"01",777),(#"01",5)]` | **`Some(777)`** | 伪造读取的值(用 777 代替真实的 5) | | 未排序 `[(#"02",5),(#"01",9)]` | **`None`** | 隐藏实际存在的条目 | | 受控的已排序 map `[(#"01",9),(#"02",5)]` | `Some(9)` | (正确) | image **两种不同的原语利用:** - **重复键伪造** *与版本无关*。适用于任何返回第一个匹配项的 `get`(包括线性的 1.6.0 和提前终止的 2.x)。这是一个始终可用的攻击向量。 - **未排序 → 假装不存在** 需要提前终止的(stdlib ≥2.x)`get`/`has_key`。Minswap 的 stdlib 1.6.0 线性 `get` 仍然*能找到*乱序的键,因此这种特定效果在那里不适用;但重复键伪造的方法依然有效。 结合 A + B:`expect` 接受畸形的 map(A),而在其上进行的 `dict.get`/`has_key` 会返回攻击者选择的值(B)。端到端来看,攻击者完全控制了验证器逻辑读取到的值。 ## 5. 推广(相同的根本原因,其他 opaque 类型) image - **`Value = opaque Dict>`。** 如果一个存在于 datum/redeemer 中(而不是从账本脚本上下文中读取)的 `Value` 包含重复的 `(policy, asset)`,会使 `quantity_of` 返回被伪造的第一个数量值。*注意:* 从脚本上下文中获取的 `Value`(`output.value`、`tx.mint`、`input.value`)是由账本构建的,并且是规范排序的 → 安全;该攻击需要攻击者自己在 datum/redeemer 中提供的 `Value`。 - **`Rational = opaque { numerator, denominator }`**(不变性为 `denominator > 0`)。一个带有 `denominator = -1` 的 datum `Rational` 会**反转** `rational.compare`(导致本应失败的检查通过);`denominator = 0` 会导致后续出现除以零的中断(恶意破坏/DoS)。 - **用户自定义 opaque 类型** —— 这是受影响最广的部分。例如其智能构造器禁止负数的 `opaque Shares { Int }`:`expect: Shares = datumField` 会接受一个**负数**值。在 1.0.24 上进行了端到端验证:`expect x: NonNeg = i_data(-5)` 得出 `value(x) == -5`(通过)。opaque+constructor 的安全模式在跨越 `Data` 边界后便失效了。 - **`bls12_381/scalar`**(必须 < 域阶数)—— 属于同类问题;会干扰 ZK/加密检查。 ## 6. 可达性 exploit 需要一个畸形的 `Map`(未排序或包含重复键)到达脚本,这**只有在攻击者直接提供 `Data` 的情况下才可能发生——即 datum 和 redeemer 字段**: - Cardano 的 `PlutusData::Map` 是一个*键值对列表*;它的 CBOR 解码**会保留顺序并允许重复键**。一个 datum/redeemer 是从提交者提供的确切字节解码而来的——账本不会对它们进行排序或去重。这正是为什么标准库会提供 `from_ascending_pairs` / `from_pairs` 并警告不要通过 `expect` 直接将原始 `Data` 转换为 `Dict`。 - 相比之下,**脚本上下文**(`tx.outputs[].value`、`tx.mint`、`tx.withdrawals`、`tx.datums` 键)内部的 `Value` 和 map 是由账本根据其自身规范的、已排序的表示组装而成的 → 这些是安全的。exploit 仅影响 *datum/redeemer* 层面。 因此,易受攻击的模式具体来说就是:**协议将一个 `Dict`/`Value`/`Rational`/opaque 存储在 datum 中,或者在 redeemer 中接受这样一个类型,使用 `expect` 进行解码,然后使用基于不变性假设的操作去读取它。** ## 7. 复现 ### 7.1 部分 A(解码器没有不变性检查)—— v1.1.21/22/23 ``` # 包含 aiken-lang/stdlib v2 (2.x) 的项目 # lib/p.ak use aiken/collection/dict.{Dict} use aiken/collection/dict pub type Datum { Datum { owner: ByteArray, balances: Dict } } pub fn probe(raw: Data) -> Int { expect Datum { balances, .. } = raw when dict.get(balances, #"01") is { None -> -1 Some(x) -> x } } # validators/v.ak use p validator v { mint(r: Data, _p: Data, _t: Data) { p.probe(r) == 777 } else(_){fail} } $ aiken build -t silent # compiles (bypass); annotated/bind form F-8s instead $ CC=$(jq -r '.validators[0].compiledCode' plutus.json) $ printf "%s" "$CC" | xxd -r -p > cc.bin $ aiken uplc decode --cbor cc.bin | grep -c lessThanEqualsByteString # only inside do_get ``` ### 7.2 部分 B(读取错误)—— 任何 1.1.x 版本 ``` use aiken/builtin fn do_get(self: Pairs, k: ByteArray) -> Option { when self is { [] -> None [Pair(k2, v), ..rest] -> if builtin.less_than_equals_bytearray(k, k2) { if k == k2 { Some(v) } else { None } } else { do_get(rest, k) } } } test dup_forged() { do_get([Pair(#"01", 777), Pair(#"01", 5)], #"01") == Some(777) } // pass test uns_absent() { do_get([Pair(#"02", 5), Pair(#"01", 9)], #"01") == None } // pass ``` ### 7.3 推广的 opaque 绕过端到端测试 —— v1.0.24 ``` use aiken/builtin pub opaque type NonNeg { n: Int } pub fn mk(i: Int) -> NonNeg { expect i >= 0 ; NonNeg { n: i } } pub fn value(x: NonNeg) -> Int { x.n } test opaque_bypass() { let raw: Data = builtin.i_data(-5) expect x: NonNeg = raw value(x) == -5 // PASS => -5 accepted as NonNeg (invariant bypassed) } ``` ### 7.4 修复该问题的防护 —— v1.0.26/28/29 相同的 `expect Datum { balances, .. }: Datum = raw` 会产生: ``` Error aiken::check::illegal::expect_on_opaque × I caught an opaque type possibly breaking its abstraction boundary. ... reckless opaque cast ... ensure that any structural invariant is checked ... ``` ## 8. 经济影响 —— 具体的抽干资金场景 该原语漏洞提供的利用能力是 **“由攻击者决定 Dict/Value/Rational/opaque 字段读取到的内容。”** 这在常见的 DeFi 模式中会直接导致资产被盗: - **余额 / 份额伪造 → 直接提款。** 一个金库在其 datum 中以 `Dict`/`Value` 存储每个用户的余额或 LP 份额。攻击者提交一个其 datum 的 map 包含 `[(me, HUGE), (me, real)]` 的消费交易。`dict.get(balances, me)` 会读取到 `HUGE`;针对虚增余额的提款检查 `amount <= balance` 将会通过。可重复操作,成本 = 交易费 → 可一直抽干整个资金池/金库。 - **价格 / 比率锁定 → 价值提取。** 一个预言机或 AMM 在 datum 中存储 `Rational` 类型的价格/手续费。当 `denominator = -1` 时,`rational.compare(offered, floor)` 会发生反转,因此本应因低于底价而被拒绝的交易会通过。攻击者可以低价买入 / 高价卖出并赚取差价。 - **存在但显示为不存在 → 双花 / 重放 / 双重铸造。** 通过将键放置在排序顺序之外(stdlib ≥2.x),用于强制执行“尚未注册”/“nonce 未使用”/“仅限一次”的 `has_key`/`get == None` 防护会被击溃:防护看到的是 `None`,但实际上状态中已经包含了该键 → 导致铸造两次、重放操作或重新注册。 - **不存在但显示为存在 → 授权绕过。** 设置 datum 中的白名单/角色 `Dict`:一个精心构造的 `[(attacker, Admin), ...]`(或者一个隐藏了真实角色的重复项)会使 `get(allowlist, attacker)` 返回 `Some(Admin)` → 自我授予管理员/批处理者/预言机更新者权限。 - **负数金额 → 账目下溢。** 一个包装非负金额的用户 opaque 类型,如果从带有负值的 datum 中解码,会破坏下游的资产守恒计算。 By DK27ss ☕️ Buy me a coffee : bc1qz5qjvgsgtrenx7zkjare7mty7zhafm473608c7
标签:Aiken, 区块链安全, 可视化界面, 形式化验证, 智能合约审计, 漏洞分析, 路径探测