Sui Move安全研讨会总结与材料

  • monethic
  • 发布于 2026-01-06 21:12
  • 阅读 9

本文总结了Sui Poland安全研讨会上的七个智能合约漏洞实验,涵盖了访问控制、权限提升、数据处理和时间单位等方面的常见安全问题。通过模拟真实场景,帮助开发者理解攻击者的视角,并学习编写安全代码。

最近,我们为 Sui Poland 举办了一次安全研讨会,会上我们探讨了审计员在 Sui Move 代码中遇到的真实漏洞模式。我们没有仅仅讨论理论问题,而是建立了一个包含七个故意破坏的智能合约的实践漏洞实验室,涵盖了实际可能在代码审查中遗漏并导致项目损失真金白银的各种 Bug。

这些是我们在真实协议的审计过程中看到的模式。这些代码是从头开始编写的,以匿名方式模仿真实案例,目标很简单:为开发人员提供一个安全的环境,让他们自己利用漏洞,了解攻击者的视角,并学习如何编写安全的代码。你可以在下面找到所有“实验室”的总结。代码可在这里找到。

实验室 1:访问控制 — public(package) entry 混淆

漏洞

Sui Move 中最危险的误解之一是假设 public(package) 可见性提供了有意义的访问控制。考虑以下易受攻击的模式:

public(package) entry fun emergency_withdraw(vault: &mut Vault, ctx: &mut TxContext) {
    // Dangerous: anyone can call this!
    // 危险:任何人都可以调用它!
    let balance = balance::value(&vault.balance);
    let coin = coin::take(&mut vault.balance, balance, ctx);
    transfer::public_transfer(coin, tx_context::sender(ctx));
}

由于 public(package) 的可见性,该函数看起来是“内部的”。但是,entry 修饰符使其可以被任何人直接作为交易入口点调用

哪里出了问题

开发人员通常认为:“这个函数只有包可见,所以只有我的模块可以调用它。” 错误。任何外部用户都可以构造一个直接调用此入口函数的交易,绕过所有预期的访问控制。在本实验中,攻击者可以简单地调用:

sui client call \
  --package  $PACKAGE_ID \
  --module   access_control_visibility_confusion \
  --function emergency_withdraw \
  --args     $VAULT_ID \
  --gas-budget 100000000

尽管具有“仅包”可见性,金库还是被清空到了调用者的地址。

修复方法

永远不要一起使用 entrypublic package(以前是 public friend)。但是别担心,这种情况发生在最好的协议中 - 这样的事情已经在一些著名的协议中被发现(第一个可能是 Wormhole)。

实验室 2:可见性与实际授权

漏洞

本实验室通过多个反模式扩展了主题。这些都是非常基础的,但由于演示从基础到高级示例,我们决定简单地提醒大家检查访问控制的方法:

public entry fun withdraw_all_untrusted(
    vault: &mut Vault,
    caller: address,  // Attacker-controlled!
    // 攻击者控制!
    ctx: &mut TxContext
) {
    // Never validates that caller matches sender
    // 永远不会验证调用者是否与发送者匹配
    assert!(caller == vault.admin, ENotAdmin);
    // Withdraw logic...
    // 取款逻辑...
}

该函数接受一个 address 参数,但从不验证它是否与 tx_context::sender(ctx) 匹配。攻击者在从自己的地址调用时,将 vault.admin 作为参数传递。

哪里出了问题

检查 caller == vault.admin 通过,但实际的交易发送者是攻击者。该函数代表实际上没有执行交易的人执行特权操作。另请注意,合法的验证被注释掉了。这种情况比你想象的要频繁发生,在审计期间,代码的重要部分被注释掉,因为有人在代码冻结前搞砸了……

修复方法

始终针对实际的交易发送者进行验证。

实验室 3:Phantom Role Capability 权限提升

漏洞

Sui 带有 phantom parameters 的类型系统可以实现优雅的基于角色的访问控制,直到你让它过于通用:

struct RoleCap<phantom R> has key, store { id: UID }
// Intended roles
// 预期角色
struct UserRole has drop {}
struct ModRole has drop {}
struct AdminRole has drop {}

// THE BUG: Generic over ANY role
// 漏洞:对任何角色都是通用的
public fun moderator_checkout_admin<R>(
    _role: &RoleCap<R>,  // Accepts any role!
    // 接受任何角色!
    ctx: &mut TxContext
): RoleCap<AdminRole> {
    RoleCap<AdminRole> { id: object::new(ctx) }
}

public entry fun sudo_execute(
    _admin: &RoleCap<AdminRole>,
    vault: &mut Vault,
    ctx: &mut TxContext
) {
    // Privileged operation
    // 特权操作
}

具有 RoleCap<UserRole> 的用户可以:

  1. 调用 moderator_checkout_admin(&user_cap)(类型检查通过,因为它是通用的)
  2. 接收 RoleCap<AdminRole>
  3. 调用带有完整权限的 sudo_execute(&admin_cap)

哪里出了问题

泛型 <R> 参数意味着类型系统接受任何 RoleCap,而不管 phantom 类型如何。该函数签名听起来像是要强制执行角色,但实际上接受任何角色。

修复方法

当使用 phantom 类型时,请注意它们实际上是一种“通配符”,并尝试预测后果。

实验室 4:Table Key 冲突和重复存款

漏洞

Sui 的 Table<K, V> 提供了键值存储,但需要小心处理:

use sui::table::{Self, Table};
public entry fun deposit_buggy(
    bank: &mut Bank,
    coin: Coin<SUI>,
    ctx: &mut TxContext
) {
    let sender = tx_context::sender(ctx);
    let amount = coin::value(&coin);

    // ALWAYS calls add, even if key exists
    // 始终调用 add,即使键已存在
    table::add(&mut bank.balances, sender, amount);

    coin::put(&mut bank.reserves, coin);
}

哪里出了问题

如果键已存在,则 table::add 函数会中止。在用户的第一次存款后,所有后续存款都会因“重复键”错误而失败。这会创建一个拒绝服务条件,用户无法存入更多资金。

修复方法

实现正确的“插入或更新”语义。虽然 table 是最基本的示例,但在使用任何数据类型时,请查阅 Sui 本机模块中的文档,以了解它们如何管理条目以及它们的 API 是什么,以便添加、删除和修改内部存储。

实验室 5:对用户控制的数据进行无界迭代

漏洞

这是一个非常基本的示例,演示了存储膨胀问题,该问题对任何生态系统都是通用的。这是因为每个区块链都有一个预定义的区块 gas 限制。这转化为一个区块内可能发生的操作的限制。如果你作为用户打算创建一个 gas 消耗超过该限制的交易,那么它将不会通过。永远不会。

struct Leaderboard has key {
    id: UID,
    scores: vector<Score>,  // User-controlled size
    // 用户控制的大小
}
public fun process_all_scores(board: &mut Leaderboard) {
    let len = vector::length(&board.scores);
    let i = 0;

    // No upper bound check!
    // 没有上限检查!
    while (i < len) {
        let score = vector::borrow(&board.scores, i);
        // Expensive processing per score
        // 每个分数的昂贵处理
        i = i + 1;
    };
}

哪里出了问题

攻击者创建一个包含数千个条目的排行榜。当任何人调用 process_all_scores 时,该函数会迭代所有条目,消耗大量的 gas,并可能达到计算限制。该函数实际上变得无法调用。

修复方法

始终对用户控制的集合强制执行合理的界限。此漏洞的先决条件通常是以下两者中的至少一个:

  • 用户可以控制每次处理时都会完全处理的存储/数据集合(例如,处理整个排行榜以分配奖励)
  • 没有从存储/数据集合中清除项目的方法,并且它们会随着时间的推移而累积,或者可以由用户任意填充。

请注意,这可能是自然发生的,也可能是由于低效的数据管理造成的。这种问题的一个变体,自然发生的,例如,如果合约计算了几十种 coin 类型,但在某些关键操作期间总是迭代所有可用余额,就会出现这种情况。

实验室 6:时间单位 — 秒/毫秒灾难

漏洞

该漏洞很简单,但在多个生态系统中也被多次发现。换句话说,这是处理时间单位时人为的错误。

const STAKE_LOCK_TIME_SECONDS: u64 = 10 * 24 * 60 * 60; // 10 days in seconds
// 10 天,以秒为单位
struct StakePosition has key {
    id: UID,
    seconds: u64,  // Misleading name!
    // 误导性的名称!
    amount: u64,
}

public entry fun stake(vault: &mut Vault, amount: u64, clock: &Clock, ctx: &mut TxContext) {
    let position = StakePosition {
        id: object::new(ctx),
        seconds: clock::timestamp_ms(clock),  // Milliseconds stored in "seconds" field!
        // 毫秒存储在 “seconds” 字段中!
        amount,
    };
    // ...
}

public entry fun unstake(position: &StakePosition, clock: &Clock) {
    let now_seconds = clock::timestamp_ms(clock) / 1000;  // Convert to seconds
    // 转换为秒

    // Comparing seconds against milliseconds!
    // 将秒与毫秒进行比较!
    assert!(
        now_seconds >= position.seconds + STAKE_LOCK_TIME_SECONDS,
        ETooEarly
    );
    // ...
}

哪里出了问题

代码混合了时间单位:

  1. position.seconds 存储毫秒(来自 timestamp_ms
  2. now_seconds 是实际的秒数(毫秒/ 1000)
  3. 比较 now_seconds >= position.seconds + STAKE_LOCK_TIME_SECONDS 将秒与毫秒进行比较

这完全破坏了时间锁。10 天的锁可能只需 10 秒即可解锁(相差 86,400 倍)。

修复方法

仔细检查你是否在整个代码库中使用一致的单位。

实验室 7:Hot Potato — 嵌套操作重入

漏洞

Move 中的“hot potato”模式使用没有能力的结构来强制执行正确的操作顺序。但是,不正确的实现可能会导致类似重入的 Bug:

struct HarvestOp { saved_reserves: u64 }
public fun start_harvest(vault: &mut Vault, ctx: &mut TxContext): HarvestOp {
    // BUG: Can be called while operation already in progress
    // 漏洞:可以在操作已经在进行中时调用
    vault.operation_in_progress = true;

    HarvestOp {
        saved_reserves: balance::value(&vault.reserves)
    }
}

public fun withdraw_for_strategy(
    vault: &mut Vault,
    _op: &HarvestOp,
    amount: u64,
    ctx: &mut TxContext
): Coin<SUI> {
    // Withdraw reserves during harvest
    // 在收获期间提取储备金
    coin::take(&mut vault.reserves, amount, ctx)
}

public fun finish_harvest(vault: &mut Vault, op: HarvestOp, ctx: &mut TxContext) {
    let HarvestOp { saved_reserves } = op;

    // Require at least 98% return
    // 要求至少 98% 的回报
    let current = balance::value(&vault.reserves);
    assert!(current >= saved_reserves * 98 / 100, EInsufficientReturn);

    vault.operation_in_progress = false;
}

哪里出了问题

在可编程交易块 (PTB) 中,攻击者可以:

  1. 调用 start_harvest() — 以 1000 个 Token 保存储备金
  2. 调用 withdraw_for_strategy() — 获取 500 个 Token
  3. 再次调用 start_harvest() — 将 saved_reserves 覆盖为 500 个 Token
  4. 使用第二次操作调用 finish_harvest() — 通过检查 (500 >= 500 * 0.98)

start_harvest 的嵌套调用会在操作中期重置 saved_reserves 快照,从而允许攻击者避开最低回报要求,该要求已重置为零(因为在操作中期,它会将金库余额覆盖为零,然后要求至少返回零 * 0.98)

修复方法

hot potato 是一种处理闪电贷或其他原子操作的典型模式。在此期间,协议通常处于可以被认为是“不寻常”的状态。通常会实施预防措施来调用其他函数,例如,当池不平衡时进行 swap/借贷/放贷。此外,如本例所示 - 保护原子操作本身!

  • 原文链接: medium.com/@monethic/sui...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
monethic
monethic
江湖只有他的大名,没有他的介绍。