Sui Move中的关键漏洞模式:来自真实审计的教训

OpenZeppelin 发布于 2026-04-30 阅读 220

本文分析了Sui Move语言中四种关键的漏洞模式,这些模式能通过编译和基本测试,但在对抗性条件下才会暴露。包括引用赋值导致字段错误修改、泛型类型参数不匹配导致资金被盗、公开函数可见性错误导致状态被篡改,以及热土豆收据未验证来源ID导致重定向资金。每种模式都提供了真实审计案例、攻击原理和修复方案。

引言

Move 的类型系统可以杜绝 Solidity 中常见的整类漏洞。对象不能被复制或静默丢弃,所有权由类型系统和运行时共同强制,并且没有动态派发导致调用中途被劫持。如果你是 Sui 新手,我们推荐阅读《从 Solidity 到 Sui》,了解对象模型和 Move 的类型系统。

Move 消除了许多经典漏洞,但没有任何语言能取代仔细审查的必要。以下模式能成功编译、通过基本测试,只有在对抗性条件下才会暴露出来。

本文涵盖了从实际安全审计中提取的 四种关键漏洞模式

模式 主要示例发现 影响
引用与值赋值 Lombard Finance 铸造功能永久失效
类型参数不匹配 Navi Protocol 通过错误池窃取资金
访问控制漏洞 Aftermath MarketMaker 提取一种代币类型时收到另一种
凭证/ID 验证 Cetus 限价单 通过错误路由支付导致盗窃

我们来逐一剖析。

第一章:Move 引用

能编译、能运行、却损坏了错误的字段

此漏洞来自 Lombard Finance 的审计。我们先看代码,挑战自己找出问题:


复制

从 Solidity 思维模式的转变

在 Solidity 中,变量赋值总是复制值:


复制

要修改存储,我们显式使用 storage 关键字:


复制

Move 的工作方式不同。它没有隐式的 storage 和 memory 区别。相反,Move 使用显式的引用(指针),你必须手动解引用才能读取或写入值。


复制

* 运算符用于访问引用背后的实际值。

理解代码

当我们解构一个 &mut Struct 时,所有字段都变成引用(指针):


复制

这些不是值,而是指向已存储的 MinterCap 对象内部字段的指针。

变量 类型 指向
limit &mut u64 存储中的 MinterCap.limit
epoch &mut u64 存储中的 MinterCap.epoch
left &mut u64 存储中的 MinterCap.left

Bug


复制

如果你来自 Solidity 背景,可能会理解为“将 limit 的值复制到 left”。但在 Move 中,这会重新赋值 left 的指向,left 和 limit 现在指向同一位置:


复制

现在当后续代码运行时:


复制

代码读取和写入的是 MinterCap.limit,而不是 MinterCap.left

修复


复制

  • *limit → 从内存位置读取(1000)
  • *left = ... → 写入 left 指向的位置(MinterCap.left

Solidity 与 Move 对比

意图 Solidity Move
将 A 的值复制到 B b=a *b = *a
读取存储的值 cap.left *left
写入存储 cap.left = x *left = x
重新赋值指针 无指针 left = other_ref

为什么这个 Bug 危险

  1. 编译成功:无编译器警告
  2. 无错误运行:不会运行时中止
  3. 看似正常工作:函数执行完成
  4. 后果隐蔽:错误的字段被修改

影响:该 Bug 导致两个问题:

  • MinterCap.left 从未被真正重置(保持为 0 的孤立值)
  • MinterCap.limit 被递减,而不是 MinterCap.left

复制

关键要点

Move 将 Solidity 隐藏的引用显式化。当你看到 &mut 类型时,请记住:

  • = 重新赋值指针(不带 *
  • * 访问底层数据

如果目的是复制值,请对两侧都解引用。如果忘记,你只是在移动指针,而实际存储保持不变,甚至更糟,最终修改了完全错误的字段。


第二章:类型参数验证

Move 的泛型很强大,但它们不验证业务逻辑

Move 的类型系统保证如果你有一个 Coin<SUI>,那它确实是 SUI。我们不会意外地将 USDC 传递给期望 SUI 的地方。

但这种泛型用法保证泛型类型参数与某个存储的配置相匹配。

案例研究:Navi Protocol

下一个发现来自 Navi 协议,一个借贷协议,用户可以通过 withdraw 函数窃取资金:


复制

如我们所见,函数接受一个 Pool<CoinType> 和一个 asset 索引,但从未验证它们是否匹配

攻击者可以提供 BTC 池和一个 USDC 的资产索引,这会让系统认为用户提取的是 USDC 代币,但由于我们从 BTC 池中提取,最终交易结束时用户会收到 BTC

案例研究:Kuna Labs

清算上下文中类似的模式:


复制

使用 SupplyPool<X, SX0> 创建的头寸可以被 SupplyPool<X, SX1> 清算。

攻击路径与影响

  1. 用户从 SupplyPool<X,SX0> 借入(他们的债务份额类型为 SX0
  2. 清算人使用 SupplyPool<X,SX1> 调用清算函数
  3. 由于没有 SX1 的抵押品,清算人无需提供任何还款
  4. 清算人仍会收到抵押品奖励
  5. 结果:免费获得抵押品,债务不减少

这些发现的共同点是什么?

Move 的泛型保证了类型安全,但不保证语义正确性

类型系统保证:

Pool<BTC> 只持有 BTC

Pool<USDC> 只持有 USDC

但不保证:

asset_index 对应正确的 Pool 类型

SupplyPoolPosition 的原始池匹配

修复

方案一:存储并验证类型名称


复制

方案二:从类型派生索引(无用户输入)


复制

方案三:在 position 中存储池引用


复制

关键要点

Move 的类型系统确保你不会传递错误类型,但不确保你传递了对应的配置。始终验证泛型类型参数是否与存储的配置匹配。


第三章:访问控制:publicpublic(package)

经典的访问控制,但形式不同

对于这种漏洞类型,关键区别很简单:

  • public:任何模块(包括攻击者部署的模块)都可以调用此函数
  • public(package):只有同一包内的模块可以调用此函数

当一个内部辅助函数被意外标记为 public 而不是 public(package) 时,攻击者可以部署自己的模块并直接调用它。

案例研究:Aftermath MarketMaker

来自 Aftermath 审计,一个可见性关键字就造成了严重漏洞:


复制

这个内部辅助函数被标记为 public 而不是 public(package)

攻击路径如下:

  1. 攻击者部署自己的模块
  2. 攻击者调用 vault::account_mut(target_vault)
  3. 攻击者获得 &mut Account<C>(完全可变访问)
  4. 攻击者修改交易头寸,耗尽抵押品

修复


复制

注意:公开的审计发现显示的是 vault::account(返回 &Account<C>);此处为了清晰展示可变变体,因为正是该路径导致了下文所述的攻击。

案例研究:SuiFrens


复制

用户可以通过直接调用此函数并传入 epoch = 0 来绕过 SuiFrens 混合的冷却期。

修复同样是改为 public(package)

思维模型:

这样理解:

可见性 谁可以调用 使用场景
fun(私有) 仅限本模块 内部辅助函数
public(package) 仅限同一包 跨模块内部 API
public 任何人,包括攻击者 对外接口

安全问题在于:“攻击者部署的模块应该能调用这个函数吗?”

如果答案是否定的 → 使用 public(package)

常见错误:

错误 1:辅助函数标记为 public


复制

错误 2:可变引用获取器


复制

错误 3:无访问控制的状态修改函数


复制

审计时需提出的问题:

  1. 这个函数应该能被外部模块调用吗?
  2. 它是否返回敏感数据的可变引用?
  3. 它是否修改状态而不需要授权?
  4. 攻击者能否从直接调用中受益?

关键要点

每个 public 函数都是一个攻击面。对于内部 API,请使用 public(package),并且对于敏感操作,始终要求能力(capability)。


第四章:凭证和 ID 验证

Hot potato 保证函数被调用,但不保证使用正确的参数

在 Move 中,没有任何能力(dropcopystorekey)的结构体被称为“hot potato”,它们必须在交易结束前被消费,因为无法存储或丢弃:


复制

这种模式保证还款函数会被调用,但不保证它们会与匹配的对象一起被调用。

案例研究:Cetus 限价单

来自 Cetus 审计,其中闪电贷凭证未验证其来源:

场景

在 Cetus 限价单中,用户通过存入 PayCoin 创建限价单。当条件满足时,订单将 PayCoin 兑换为 TargetCoin。该协议还提供闪电贷,任何人都可以临时从订单中借出 PayCoin,只要他们用 TargetCoin 偿还。


复制

凭证中包含被借订单的 order_id,但函数从未检查传入的 limit_order 参数是否与此 ID 匹配。

攻击方式


复制

修复是添加以下行:


复制

需要关注的点:

  • 凭证是否存储了源对象的 ID?
  • 消费函数是否断言 ID 匹配?
  • 是否所有相关字段都经过验证,而不仅仅是部分字段?
  • 是否存在其他代码路径可以跳过验证?

关键要点

Hot potato 保证函数被调用,但你仍然必须验证凭证与对象匹配。始终将源对象的 ID 存储在凭证中,并在消费时验证。


结论

Move 的安全保障虽强大,但并非绝对。我们涵盖的模式代表了在 Sui 中可以看到的一些关键发现模式:

  1. Move 引用:对引用变量的赋值会重新赋值指针而非值,导致静默地损坏错误字段
  2. 类型参数:泛型保证类型安全但不保证语义正确性,需对照存储的配置进行验证
  3. 访问控制:一个 publicpublic(package) 的错误就能将内部状态暴露给攻击者
  4. 凭证验证:Hot potato 保证函数被调用,但不保证被正确调用

这些 Bug 不会触发编译器错误,也不会在正常测试输入下失败。它们利用了代码实际执行开发者意图之间的差距。

准备好保护你的代码了吗?

请求审计 →

常见问题

Move 漏洞与 Solidity 漏洞有何不同?

Sui Move 构建在对象中心模型之上,资产作为一等公民的链上对象存在,拥有显式的所有权,并且类型系统强制它们不能被静默复制或丢弃。因此,Solidity 审计人员熟悉的几类 Bug,例如跨合约重入和资产双花,在 Move 中没有直接的对等情况。Sui 审计中剩下的 Bug 本质不同:它们涉及引用如何解引用、泛型类型参数如何验证、可见性修饰符如何选择等。这些模式可以干净地编译并通过标准测试,因此只有在对抗性条件下才会暴露,这也正是安全审查仍然至关重要的原因。

类型参数不匹配如何导致 Move 协议中的资金被盗?

Move 的泛型类型系统保证 Pool<BTC> 只持有 BTC,但它不验证用户提供的资产索引或配置是否与传入函数的池类型匹配。攻击者可以利用这一点,传入一个高价值池以及另一种代币的资产索引,诱骗协议从一种资产记账,同时从另一种资产释放资金。修复方法是直接从类型参数派生资产索引,或者断言该类型与存储的配置匹配。

为什么 Move 中的 hot potato 凭证本身不足以防止闪电贷漏洞?

Hot potato 结构体(没有 drop、copy、key 或 store 能力)保证在交易完成前必须调用还款函数。然而,它不保证还款被应用于正确的对象。如果凭证的来源 ID 没有针对传入还款函数的对象进行验证,攻击者可以从受害者的订单中借入并还入自己的订单,从而在技术消耗凭证的同时重定向资金。始终将源对象的 ID 存储在凭证中,并在消费时断言匹配。


注意:本材料中的代码片段可能与参考协议的实际代码库不完全匹配。材料仅基于审计报告编写,代码片段已简化以聚焦于漏洞类型。

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

相关文章

0 条评论