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

最近,我们为 Sui Poland 举办了一次安全研讨会,会上我们探讨了审计员在 Sui Move 代码中遇到的真实漏洞模式。我们没有仅仅讨论理论问题,而是建立了一个包含七个故意破坏的智能合约的实践漏洞实验室,涵盖了实际可能在代码审查中遗漏并导致项目损失真金白银的各种 Bug。
这些是我们在真实协议的审计过程中看到的模式。这些代码是从头开始编写的,以匿名方式模仿真实案例,目标很简单:为开发人员提供一个安全的环境,让他们自己利用漏洞,了解攻击者的视角,并学习如何编写安全的代码。你可以在下面找到所有“实验室”的总结。代码可在这里找到。
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
尽管具有“仅包”可见性,金库还是被清空到了调用者的地址。
永远不要一起使用 entry 和 public package(以前是 public friend)。但是别担心,这种情况发生在最好的协议中 - 这样的事情已经在一些著名的协议中被发现(第一个可能是 Wormhole)。
本实验室通过多个反模式扩展了主题。这些都是非常基础的,但由于演示从基础到高级示例,我们决定简单地提醒大家检查访问控制的方法:
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 通过,但实际的交易发送者是攻击者。该函数代表实际上没有执行交易的人执行特权操作。另请注意,合法的验证被注释掉了。这种情况比你想象的要频繁发生,在审计期间,代码的重要部分被注释掉,因为有人在代码冻结前搞砸了……
始终针对实际的交易发送者进行验证。
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> 的用户可以:
moderator_checkout_admin(&user_cap)(类型检查通过,因为它是通用的)RoleCap<AdminRole>sudo_execute(&admin_cap)泛型 <R> 参数意味着类型系统接受任何 RoleCap,而不管 phantom 类型如何。该函数签名听起来像是要强制执行角色,但实际上接受任何角色。
当使用 phantom 类型时,请注意它们实际上是一种“通配符”,并尝试预测后果。
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 是什么,以便添加、删除和修改内部存储。
这是一个非常基本的示例,演示了存储膨胀问题,该问题对任何生态系统都是通用的。这是因为每个区块链都有一个预定义的区块 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 类型,但在某些关键操作期间总是迭代所有可用余额,就会出现这种情况。
该漏洞很简单,但在多个生态系统中也被多次发现。换句话说,这是处理时间单位时人为的错误。
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
);
// ...
}
代码混合了时间单位:
position.seconds 存储毫秒(来自 timestamp_ms)now_seconds 是实际的秒数(毫秒/ 1000)now_seconds >= position.seconds + STAKE_LOCK_TIME_SECONDS 将秒与毫秒进行比较这完全破坏了时间锁。10 天的锁可能只需 10 秒即可解锁(相差 86,400 倍)。
仔细检查你是否在整个代码库中使用一致的单位。
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) 中,攻击者可以:
start_harvest() — 以 1000 个 Token 保存储备金withdraw_for_strategy() — 获取 500 个 Tokenstart_harvest() — 将 saved_reserves 覆盖为 500 个 Tokenfinish_harvest() — 通过检查 (500 >= 500 * 0.98)对 start_harvest 的嵌套调用会在操作中期重置 saved_reserves 快照,从而允许攻击者避开最低回报要求,该要求已重置为零(因为在操作中期,它会将金库余额覆盖为零,然后要求至少返回零 * 0.98)
hot potato 是一种处理闪电贷或其他原子操作的典型模式。在此期间,协议通常处于可以被认为是“不寻常”的状态。通常会实施预防措施来调用其他函数,例如,当池不平衡时进行 swap/借贷/放贷。此外,如本例所示 - 保护原子操作本身!
- 原文链接: medium.com/@monethic/sui...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!