Move 语言智能合约安全:10 个经典漏洞深度剖析

Move 是一种专为资产安全和可信赖性而设计的编程语言,它的核心理念是通过其独特的**资源类型系统(Resource Type System)**来从根本上防止资产被复制、意外销毁或丢失。它在编译时强制执行这些规则,从而在很大程度上避免了许多传统区块链语言中常见的致命漏洞,比如代币重复发行或意外丢失。

然而,尽管 Move 提供了强大的安全保障,但合约的安全性最终还是取决于开发者的设计和实现。不正确的逻辑、对 Move 特性的误解或编程上的疏忽,仍然可能引入严重的漏洞。

本文件将对 Move 智能合约中 10 个常见且潜在的漏洞类型进行深度剖析,旨在帮助开发者更好地理解语言陷阱,并编写出更健壮、更安全的合约。


1. 资产权限管理不当 (Improper Capability Management)

核心原理:

在 Move 中,能力 (Capability) 是一种特殊的资源类型,它代表了对特定操作的权限。这是一种强大的访问控制机制,例如,一个 AdminCap 资源可以授予持有者调用管理函数的权限。这种模式比依赖于硬编码的地址更安全,因为能力可以像普通资产一样被转移或销毁。

漏洞描述:

当开发者没有正确地管理这些能力资源时,就会导致权限泄露或被滥用。这通常发生在以下几种情况:

  • 能力泄露 (Capability Leakage): 这是最危险的情况。当一个能力资源被错误地存储在公共可访问的结构体中,或者在一个不安全的函数中被意外返回,任何用户都可以获取并滥用该权限。
  • 非预期的能力转移 (Unintended Capability Transfer): 在函数调用中,开发者意外地将能力作为参数传递给一个不应拥有此权限的模块或函数。这使得该模块获得了超越其设计权限的能力,可能被用于恶意目的。
  • 未撤销的能力 (Unrevoked Capabilities): 在合约升级或状态变更后,旧的能力没有被正确地销毁或撤销。这可能导致旧的、不再有效的能力仍然可以被用来执行过时的或不安全的操作,造成合约状态的不一致。

伪代码示例 (能力泄露):

module my_contract::cap_leak {
use std::signer;

// 错误的实现: AdminCap 被作为公共资源存储在用户账户下
// 任何拥有该 AdminCap 资源的用户都可以调用特权函数
struct AdminCap has key, store { }

// ❌ 错误:这个函数应该只在模块初始化时被调用一次,但它却是公共的
public fun give_admin_cap(recipient: &signer) {
let cap = AdminCap {};
// 将 AdminCap 资源转移到用户的账户下
move_to(recipient, cap);
}

// ❌ 错误:这个特权函数没有检查调用者的身份,只依赖于 `AdminCap`
public fun mint_tokens(admin_cap: &AdminCap, amount: u64) {
// ...铸造代币的逻辑...
}
}

防范措施:

  • 将初始化逻辑放在 init_module 中: init_module 函数只在模块发布时自动执行一次,是创建和分发初始能力的最安全场所。
  • 使用签名者进行权限验证: 始终将需要权限的函数用 signer 参数进行限制,并检查其地址是否为管理员地址,以进行双重验证。
  • 私有化特权函数: 将需要能力才能调用的函数声明为 private,并只在模块内部的公共函数中调用,从而限制外部访问。

2. 外部合约调用中的可重入性 (Re-entrancy)

核心原理:

可重入性攻击在以太坊等区块链中非常常见。Move 的资源类型和原子性在很大程度上缓解了典型的可重入攻击,因为资源所有权转移是原子的。然而,如果一个函数在更新其内部状态之前,调用了另一个外部函数,而这个外部函数又可以再次调用回原函数,就可能导致状态不一致。

漏洞描述:

这种攻击通常被称为**循环回调 (Recursive Calls)**。例如,在一个借贷协议中,一个函数在给用户转账之后、但在更新其借贷状态之前,意外地被一个恶意合约再次调用。这使得攻击者可以在一次交易中多次取款,造成协议资金的损失。

伪代码示例 (借贷协议中的可重入):

module my_contract::reentrancy_risk {
use std::signer;
use a_coin_module::Coin; // 假设的一个代币模块

struct UserBalance has key {
balance: u64,
is_locked: bool,
}

// ❌ 错误:在转账后才更新状态
public fun withdraw(account: &signer, amount: u64) acquires UserBalance {
let user_address = signer::address_of(account);
let user_balance = borrow_global_mut<UserBalance>(user_address);

assert!(user_balance.balance >= amount, 101);

// ❌ 风险点:在更新余额前调用了外部函数
// 这里可以调用一个恶意合约的函数,该恶意合约会再次调用 withdraw
// 恶意合约::re_call_me(user_address, amount);

// 假设 `A_COIN_MODULE::transfer` 是一个外部调用,
// 恶意合约可能在 transfer 中重新进入此函数
a_coin_module::transfer(user_address, amount);

user_balance.balance = user_balance.balance - amount;
}
}

防范措施:

  • “检查-影响-交互”模式: 始终遵循这个安全模式。检查(Check)所有的前提条件,如余额是否足够。然后影响(Effect)合约内部的状态,例如更新用户的余额。最后,再进行交互(Interaction),如调用外部函数或进行转账。
  • 使用锁或状态标志: 在复杂的交互中,可以使用一个布尔标志(如 is_locked)来防止一个函数在执行过程中被再次调用。

3. 未受保护的初始化函数 (Unprotected Initialization Functions)

核心原理:

合约的初始化是其生命周期中至关重要的一步,通常用于设置管理员地址、初始代币供应量或其他关键配置参数。Move 提供了 init_module 函数,它只在模块发布时被 Move VM 自动执行一次。

漏洞描述:

当开发者没有利用 init_module 的特性,而是编写了一个公共的、没有权限控制的初始化函数时,就会引入巨大的安全风险。

  • 重复初始化 (Re-initialization): 如果初始化函数没有检查一个全局状态变量(如 is_initialized)来防止重复调用,任何用户都可以在合约部署后反复执行该函数,从而覆盖掉初始配置。
  • 权限劫持 (Privilege Hijacking): 恶意用户在合约部署后抢先调用初始化函数,将合约的所有权或管理员权限转移到自己的地址,从而完全控制合约。

伪代码示例 (权限劫持):

module my_contract::unprotected_init {
use std::signer;
struct Config has key { admin: address }

// ❌ 错误:这个公共函数可以被任何人在任何时候调用
public fun set_initial_config(admin: &signer) {
// 如果 `Config` 资源不存在,则创建并移动
if (!exists<Config>(@self)) {
move_to(@self, Config { admin: signer::address_of(admin) });
} else {
// 否则更新管理员地址
let config = borrow_global_mut<Config>(@self);
config.admin = signer::address_of(admin);
}
}
}

防范措施:

  • 使用 init_module 将所有初始化逻辑放在 init_module 函数中,它是最安全的初始化方式。
  • 使用单次执行标志: 如果确实需要一个公共的初始化函数,那么必须使用一个全局的布尔标志来确保它只能被调用一次。

4. 资源销毁不当 (Improper Resource Destruction)

核心原理:

Move 的资源(resource)类型是其最重要的安全特性之一,它强制所有权模型,确保资源不能被复制或隐式丢弃。然而,开发者仍然需要明确地管理资源的生命周期,否则可能导致问题。

漏洞描述:

  • 资源意外丢失 (Unintended Resource Loss): 一个函数接收一个资源,但没有正确地处理它(例如,既没有将其返回,也没有 move_to 到某个账户下),导致资源被意外丢弃。尽管 Move 的编译器会检查这种未使用的资源,但在复杂的逻辑中仍可能发生。
  • 非空资源被丢弃 (Dropping a non-empty Resource): 如果一个资源结构体包含其他嵌套资源,而开发者在不检查其内部状态的情况下销毁了外部资源,可能导致内部的宝贵资源被永久销毁。

伪代码示例:

module my_contract::resource_loss {
use std::signer;

struct ImportantNFT has key, store { id: u64 }
struct NFTBox has key { nft: ImportantNFT }

// ❌ 错误:NFTBox 被销毁,导致内部的 NFT 也被销毁
public fun destroy_box_and_nft(account: &signer) acquires NFTBox {
let box = move_from<NFTBox>(signer::address_of(account));
// box 在函数结束时被销毁,其内部的 `nft` 资源也跟着被销毁
}
}

防范措施:

  • 强制资源生命周期: 确保所有接收资源的函数都遵循清晰的生命周期:要么将其作为返回值返回,要么将其 move_to 到另一个账户。
  • 使用 drop 能力: 谨慎使用 drop 能力,它允许资源被显式销毁。只在确实需要销毁资源时才使用。

5. 整数溢出和下溢 (Integer Overflow and Underflow)

核心原理:

在 Move 2024 版本中,大多数算术运算默认都进行了溢出和下溢检查,并在发生时直接 panic(中止)。这从语言层面提供了强大的安全保障。然而,在一些特定场景或使用旧版代码时,开发者仍需手动检查。

漏洞描述:

  • 显式溢出 (Explicit Overflow): 如果算术操作的结果超出了其类型可以表示的最大值,就会发生溢出,导致结果变为一个很小的值。
  • 显式下溢 (Explicit Underflow): 如果从一个值中减去一个更大的值,结果超出了其类型可以表示的最小值,就会发生下溢,导致结果变为一个巨大的正数。

伪代码示例 (下溢):

module my_contract::underflow {
use std::signer;

struct UserBalance has key { balance: u64 }

// ❌ 错误:未检查余额就进行减法
public fun withdraw_tokens(account: &signer, amount: u64) acquires UserBalance {
let user_balance = borrow_global_mut<UserBalance>(signer::address_of(account));
// 如果 user_balance.balance < amount,就会发生下溢
user_balance.balance = user_balance.balance - amount;
}
}

防范措施:

  • 始终进行检查: 在进行减法或任何可能导致下溢的操作之前,使用 assert!if 语句检查前提条件。
  • 使用 Move 2024+: 使用最新的 Move 编译器,它包含了默认的溢出检查,这能极大地提高代码的安全性。

6. 权限分离不当 (Improper Separation of Privileges)

核心原理:

良好的合约设计应严格区分特权操作(如管理配置)和非特权操作(如存款或查询)。在 Move 中,权限通常通过签名者 (signer) 或能力 (capability) 来管理。

漏洞描述:

  • 不必要的 signer 参数: 一个函数本应是公共可调用的(例如查询余额),但却不必要地要求一个 signer 参数,导致只有拥有私钥的签名者才能调用,降低了合约的可用性和互操作性。
  • 特权操作未受限: 一个本应只被管理员调用的函数,却没有使用 signer 或能力进行访问控制,这允许任何用户执行特权操作。

伪代码示例:

module my_contract::privilege_issue {
use std::signer;

// ❌ 错误:查询操作不应需要签名者权限
public fun get_balance_of(user: &signer): u64 {
// ...
}

// 修复方案:
public fun get_balance_of_safe(user_address: address): u64 {
// ...
}
}

防范措施:

  • 按需使用 signer 只有当函数需要执行特权操作或验证调用者身份时,才使用 signer 作为参数。
  • 使用能力进行访问控制: 对于复杂的权限系统,使用能力资源是更优的选择,它能提供更细粒度的权限控制。

7. 逻辑错误导致的状态不一致 (Logic Errors Leading to Inconsistent State)

核心原理:

Move 的类型系统可以防止许多低级错误,但无法检查合约的业务逻辑。开发者在设计和实现合约时,如果逻辑考虑不周,就可能导致合约状态进入一个非预期的、不一致的状态。

漏洞描述:

  • 条件竞态 (Race Conditions): 多个用户或交易几乎同时执行某个操作,且没有正确地通过原子性操作来管理,可能导致状态在其中一个操作完成之前被另一个操作改变。
  • 状态机设计不当 (Improper State Machine Design): 合约的状态机(例如:Initialized -> Active -> Paused)设计有缺陷,允许从一个非法的状态转换到另一个状态。

伪代码示例 (拍卖合约中的逻辑错误):

module my_contract::logic_error {
struct AuctionState has key {
is_active: bool,
}

// ❌ 错误:在拍卖结束后仍然可以出价
public fun place_bid(amount: u64) acquires AuctionState {
let state = borrow_global_mut<AuctionState>(@self);
// 如果开发者忘记检查 `state.is_active`,那么在拍卖结束后,用户仍然可以出价
// assert!(state.is_active, 1); // 缺少了这行关键的检查
// ...出价逻辑...
}
}

防范措施:

  • 状态机建模: 在设计合约时,首先绘制一个清晰的状态转换图,并确保每个函数都严格检查并只允许合法的状态转换。
  • 单元测试和形式化验证: 编写全面的单元测试来覆盖所有可能的逻辑路径,并考虑使用形式化验证工具来证明关键属性的正确性。

8. signeraddress 的混淆 (Confusion between signer and address)

核心原理:

在 Move 中,signer 类型代表一个拥有私钥并能够签名交易的实体。address 类型则是一个不拥有私钥的地址,它只是一个数据值。

漏洞描述:

  • 不正确的身份验证: 一个函数需要验证调用者的身份,但开发者错误地使用 address 类型作为身份验证,而不是 signer。这使得任何知道该地址的人都可以伪造调用,因为 address 可以作为一个常量被硬编码或传递。

伪代码示例:

module my_contract::signer_address_confusion {
use std::signer;

const ADMIN_ADDRESS: address = @0x1;

// ❌ 错误:仅使用地址进行权限检查
public fun set_config_unsecure(config: u64) {
// 这里的 `@sender` 是一个地址值,任何人都可以通过一个脚本传入
assert!(@sender == ADMIN_ADDRESS, 0);
}

// 修复方案:
public fun set_config_secure(admin: &signer, config: u64) {
// `signer` 类型强制调用者必须是拥有私钥的实体
assert!(signer::address_of(admin) == ADMIN_ADDRESS, 0);
// ...
}
}

防范措施:

  • 理解类型语义: 牢记 signer 代表权限,而 address 只是一个数据值。在所有需要权限验证的场景下,都必须使用 signer

9. 特权函数中的不安全 public 声明 (Unsafe public Declaration)

核心原理:

在 Move 中,函数可以被声明为 privatepublicscriptentry。当一个函数被声明为 public 时,它可以在模块外部被调用。

漏洞描述:

如果一个特权操作函数被错误地声明为 public 且没有其他权限限制,那么任何外部模块或脚本都可以调用它。这暴露了合约的关键功能,可能导致资产被盗或配置被篡改。

伪代码示例:

module my_contract::unsafe_public {
struct AdminCap has key {}

// ❌ 错误:这个特权函数没有权限控制
public fun set_admin(new_admin_address: address) {
// 如果没有其他权限检查,任何人都可以调用此函数
// 来改变管理员地址
}

// 修复方案:
public fun set_admin_safe(admin: &signer, new_admin_address: address) acquires AdminCap {
let admin_cap = borrow_global<AdminCap>(signer::address_of(admin));
// ...
}
}

防范措施:

  • 最小权限原则: 只给函数必要的权限。如果一个函数只应该被模块内部的其他函数调用,就应该将其声明为 private
  • 使用 entry 函数: 对于需要外部调用的特权函数,将其声明为 entry 函数,并使用 signer 参数进行严格的权限检查。

10. signer 的意外使用 (signer as a Data Source)

核心原理:

signer 的主要作用是验证调用者的权限。然而,开发者有时会意外地将它当作一个普通的数据源,这不仅没有增加安全性,反而可能导致代码变得复杂。

漏洞描述:

  • 不必要的 signer 操作: 在不需要验证调用者权限的情况下,过度地使用 signer 类型,使得合约的调用方式变得复杂。例如,一个只读函数不应该要求 signer 参数。

伪代码示例:

module my_contract::signer_abuse {
use std::signer;
struct UserData has key { balance: u64 }

// ❌ 错误:这是一个只读函数,不应要求签名者权限
public fun get_balance_of(user: &signer): u64 acquires UserData {
let user_address = signer::address_of(user);
let data = borrow_global<UserData>(user_address);
return data.balance;
}

// 修复方案:
public fun get_balance_of_safe(user_address: address): u64 acquires UserData {
let data = borrow_global<UserData>(user_address);
return data.balance;
}
}

防范措施:

  • 按需使用 signer 只有当函数需要进行状态变更或验证调用者身份时,才使用 signer 类型。对于只读查询,使用 address 类型即可。