跳转到内容

Move 安全指南

Move 语言天生具备安全性,内置了类型系统和线性逻辑等多种特性.尽管如此,由于其新颖性以及部分业务逻辑的复杂性,开发者未必总能熟悉 Move 的安全编码模式,进而导致漏洞.

本指南旨在弥补这一空白,详细介绍常见的反模式及其安全替代方案.通过实际示例说明安全问题的产生方式,并推荐安全编码最佳实践.希望本指南能帮助开发者加深对 Move 安全机制的理解,确保智能合约的健壮开发.

每个 Object<T> 都可以被任何人访问,这意味着任何人都可以将 Object<T> 传递给任意函数,即使调用者并不拥有该对象. 因此,务必验证 signer 是否为对象的真正所有者.

在此模块中,用户需先购买订阅才能执行某些操作.用户调用注册函数以获取 Object<Subscription>,之后可用其执行操作.

module 0x42::example {
struct Subscription has key {
end_subscription: u64
}
entry fun registration(user: &signer, end_subscription: u64) {
let price = calculate_subscription_price(end_subscription);
payment(user,price);
let user_address = address_of(user);
let constructor_ref = object::create_object(user_address);
let subscription_signer = object::generate_signer(&constructor_ref);
move_to(&subscription_signer, Subscription { end_subscription });
}
entry fun execute_action_with_valid_subscription(
user: &signer, obj: Object<Subscription>
) acquires Subscription {
let object_address = object::object_address(&obj);
let subscription = borrow_global<Subscription>(object_address);
assert!(subscription.end_subscription >= aptos_framework::timestamp::now_seconds(),1);
// Use the subscription
[...]
}
}

在此不安全示例中,execute_action_with_valid_subscription 未验证用户是否拥有传入的 obj.因此,任何人都可以使用他人的订阅,绕过付费要求.

确保 signer 拥有该对象.

module 0x42::example {
struct Subscription has key {
end_subscription: u64
}
entry fun registration(user: &signer, end_subscription: u64) {
let price = calculate_subscription_price(end_subscription);
payment(user,price);
let user_address = address_of(user);
let constructor_ref = object::create_object(user_address);
let subscription_signer = object::generate_signer(&constructor_ref);
move_to(&subscription_signer, Subscription { end_subscription });
}
entry fun execute_action_with_valid_subscription(
user: &signer, obj: Object<Subscription>
) acquires Subscription {
//ensure that the signer owns the object.
assert!(object::owner(&obj)==address_of(user),ENOT_OWNWER);
let object_address = object::object_address(&obj);
let subscription = borrow_global<Subscription>(object_address);
assert!(subscription.end_subscription >= aptos_framework::timestamp::now_seconds(),1);
// Use the subscription
[...]
}
}

仅接受 &signer 作为参数并不足以实现访问控制.务必断言 signer 是否为预期账户,尤其是在执行敏感操作时.

如果未做授权校验,任何用户都可执行特权操作.

此代码片段允许任何调用 delete 函数的用户删除 Object,未校验调用者权限.

module 0x42::example {
struct Object has key{
data: vector<u8>
}
public fun delete(user: &signer, obj: Object) {
let Object { data } = obj;
}
}

更好的做法是利用 Move 提供的全局存储,直接通过 signer::address_of(signer) 访问数据.这样可确保只操作 signer 拥有的数据,极大降低访问控制出错风险.

module 0x42::example {
struct Object has key{
data: vector<u8>
}
public fun delete(user: &signer) {
let Object { data } = move_from<Object>(signer::address_of(user));
}
}

遵循最小权限原则:

  • 始终以私有函数为起点,按业务需求逐步提升可见性.
  • 仅为 Aptos CLI 或 SDK 使用的函数加 entry.
  • 仅允许特定模块访问的函数使用 friend.
  • 仅读取存储,不修改状态的函数使用 #[view] 装饰器.注意,#[view] 函数可被间接调用,此时可能会修改存储.

函数可见性决定了谁可以调用该函数,是强制访问控制的重要手段,对智能合约安全至关重要:

  • private 函数仅能在定义它的模块内调用,外部模块及 CLI/SDK 无法访问,防止合约内部被意外调用.
module 0x42::example {
fun sample_function() { /* ... */ }
}
  • public(friend) 允许指定 friends 模块调用,实现合约间受控交互,同时限制一般访问.
module 0x42::example {
friend package::mod;
public(friend) fun sample_function() { /* ... */ }
}
  • public 函数可被任意已发布模块或脚本调用.
module 0x42::example {
public fun sample_function() { /* ... */ }
}
  • #[view] 装饰的函数不能修改存储,仅用于安全读取信息.
module 0x42::example {
#[view]
public fun read_only() { /* ... */ }
}
  • Move 中的 entry 修饰符用于标记交易入口点.带有 entry 的函数作为区块链交易的执行起点.
module 0x42::example {
entry fun f(){}
}

总结如下:

模块自身其他模块Aptos CLI/SDK
private
public(friend)✅ if friend
⛔ otherwise
public
entry

这种分层可见性确保只有授权实体能执行特定函数,大大降低因过度暴露导致的漏洞或攻击风险.

注意,可将 entrypublicpublic(friend) 组合使用:

module 0x42::example {
public(friend) entry fun sample_function() { /* ... */ }
}

此时 sample_function 可被 Aptos CLI/SDK 及所有声明为 friend 的模块调用.

遵循最小权限原则可防止函数过度暴露,将访问范围限制在业务所需范围内.

泛型可用于定义适用于不同输入数据类型的函数和结构体.使用泛型时,务必确保泛型类型有效且符合预期.详细了解 泛型.

未检查的泛型可能导致未授权操作或交易中止,危及协议完整性.

下方代码为简化版闪电贷.

flash_loan<T> 函数中,用户可借出指定类型 T 的币及记录应还金额(含手续费)的 Receipt.

repay_flash_loan<T> 接收 ReceiptCoin<T>,仅断言还款币值大于等于 Receipt,但未校验还款币种是否与借出币种一致,允许用价值较低的币还款.

module 0x42::example {
struct Coin<T> {
amount: u64
}
struct Receipt {
amount: u64
}
public fun flash_loan<T>(user: &signer, amount: u64): (Coin<T>, Receipt) {
let (coin, fee) = withdraw(user, amount);
( coin, Receipt {amount: amount + fee} )
}
public fun repay_flash_loan<T>(rec: Receipt, coins: Coin<T>) {
let Receipt{ amount } = rec;
assert!(coin::value<T>(&coin) >= rec.amount, 0);
deposit(coin);
}
}

Aptos Framework 示例中,创建了由泛型 KV 组成的键值表.相关 add 函数参数为 Table<K, V>,keyvalue,类型分别为 KV.phantom 语法确保 key 和 value 类型与表一致,防止类型不匹配.详细了解 phantom 泛型参数.

module 0x42::example {
struct Table<phantom K: copy + drop, phantom V> has store {
handle: address,
}
public fun add<K: copy + drop, V>(table: &mut Table<K, V>, key: K, val: V) {
add_box<K, V, Box<V>>(table, key, Box { val })
}
}

得益于 Move 语言的类型检查机制,我们可优化闪电贷协议代码.如下代码确保 repay_flash_loan 传入的币与借出币一致.

module 0x42::example {
struct Coin<T> {
amount: u64
}
struct Receipt<phantom T> {
amount: u64
}
public fun flash_loan<T>(_user: &signer, amount:u64): (Coin<T>, Receipt<T>) {
let (coin, fee) = withdraw(user, amount);
(coin,Receipt { amount: amount + fee})
}
public fun repay_flash_loan<T>(rec: Receipt<T>, coins: Coin<T>) {
let Receipt{ amount } = rec;
assert!(coin::value<T>(&coin) >= rec.amount, 0);
deposit(coin);
}
}

高效的资源管理与无界执行预防对协议安全和 gas 效率至关重要,设计合约时需重点考虑:

  1. 避免对允许无限添加条目的公开结构体进行遍历.
  2. 用户资产(如币,NFT)应存储在各自账户下.
  3. 模块或包相关信息应存储于 Object,与用户数据分离.
  4. 用户操作应分散存储,避免全部集中于全局空间.

忽视上述原则,攻击者可消耗大量 gas 并导致交易中止,进而阻断应用功能.

下方代码遍历所有订单,攻击者可通过注册大量订单阻塞操作:

module 0x42::example {
public fun get_order_by_id(order_id: u64): Option<Order> acquires OrderStore {
let order_store = borrow_global_mut<OrderStore>(@admin);
let i = 0;
let len = vector::length(&order_store.orders);
while (i < len) {
let order = vector::borrow<Order>(&order_store.orders, i);
if (order.id == order_id) {
return option::some(*order)
};
i = i + 1;
};
return option::none<Order>()
}
//O(1) in time and gas operation.
public entry fun create_order(buyer: &signer) { /* ... */ }
}

推荐将订单管理系统设计为每个用户的订单存储在其账户下,而非全局订单池.这样既能隔离用户数据提升安全性,也能分散数据负载提升可扩展性.应通过用户账户访问订单,而非 borrow_global_mut<OrderStore>(@admin).

module 0x42::example {
public fun get_order_by_id(user: &signer, order_id: u64): Option<Order> acquires OrderStore {
let order_store = borrow_global_mut<OrderStore>(signer::address_of(user));
if (smart_table::contains(&order_store.orders, order_id)) {
let order = smart_table::borrow(&order_store.orders, order_id);
option::some(*order)
} else {
option::none<Order>()
}
}
}

同时建议根据操作需求选用高效数据结构,如 SmartTable.

Move 的能力是一组权限,用于控制数据结构的操作.开发者需谨慎分配能力,仅在必要时赋予,并理解其安全影响,防止安全漏洞.

能力描述
copy允许值被复制,可在合约内多次使用.
drop允许值被丢弃,防止资源泄漏.
store允许数据存储于全局存储,实现跨交易持久化.
key允许数据作为全局存储的 key,便于数据检索与操作.

详细了解 能力.

能力使用不当可能导致安全问题,如敏感数据被复制(copy),资源泄漏(drop),全局存储误用(store).

module 0x42::example {
struct Token has copy { }
struct FlashLoan has drop { }
}
  • Token 拥有 copy 能力,允许复制,可能导致双花和通胀,币值贬损.
  • FlashLoan 拥有 drop 能力,允许借款人销毁贷款,逃避还款.

算术运算因向下取整导致精度损失,可能使协议低估计算结果.

Move 支持六种无符号整数类型:u8,u16,u32,u64,u128,u256.Move 中的除法操作会截断小数部分,向下取整,可能导致协议低估结果.

精度误差可能带来广泛影响,如财务失衡,数据不准确,决策失误,甚至造成损失或安全风险.准确计算对系统可靠性和用户信心至关重要.

module 0x42::example {
public fun calculate_protocol_fees(size: u64): (u64) {
return size * PROTOCOL_FEE_BPS / 10000
}
}

size 小于 10000 / PROTOCOL_FEE_BPS,则手续费将被向下取整为 0,用户可零手续费与协议交互.

以下为两种缓解方案:

  • 设置最小订单规模阈值,大于 10000 / PROTOCOL_FEE_BPS,确保手续费不为零.
module 0x42::example {
const MIN_ORDER_SIZE: u64 = 10000 / PROTOCOL_FEE_BPS + 1;
public fun calculate_protocol_fees(size: u64): (u64) {
assert!(size >= MIN_ORDER_SIZE, 0);
return size * PROTOCOL_FEE_BPS / 10000
}
}
  • 检查手续费非零,若为零则设定最小手续费或拒绝交易.
module 0x42::example {
public fun calculate_protocol_fees(size: u64): (u64) {
let fee = size * PROTOCOL_FEE_BPS / 10000;
assert!(fee > 0, 0);
return fee;
}
}

Move 中,整数运算具备安全性,防止溢出和下溢,避免异常行为或漏洞:

  • 加法(+)和乘法(*)若结果超出类型范围会直接中止程序.
  • 减法(-)若结果小于零会中止.
  • 除法(/)若除数为零会中止.
  • 左移(<<)不会因溢出中止,超出位数会产生错误值或不可预测行为.

详细了解 运算.

不当运算可能导致合约执行异常或数据错误.


创建对象时,切勿暴露对象的 ConstructorRef,否则可被用于向对象添加资源.ConstructorRef 还能生成其他能力(或 “Refs”),用于更改或转移对象所有权.详细了解 对象能力.

例如,若 mint 函数返回 NFT 的 ConstructorRef,可被转换为 TransferRef 并存储于全局,允许原持有者在 NFT 售出后转回.

module 0x42::example {
use std::string::utf8;
public fun mint(creator: &signer): ConstructorRef {
let constructor_ref = token::create_named_token(
creator,
string::utf8(b"Collection Name"),
string::utf8(b"Collection Description"),
string::utf8(b"Token"),
option::none(),
string::utf8(b"https://mycollection/token.jpeg"),
);
constructor_ref
}
}

mint 函数不返回 CostructorRef:

module 0x42::example {
use std::string::utf8;
public fun mint(creator: &signer) {
let constructor_ref = token::create_named_token(
creator,
string::utf8(b"Collection Name"),
string::utf8(b"Collection Description"),
string::utf8(b"Token"),
option::none(),
string::utf8(b"https://mycollection/token.jpeg"),
);
}
}

在 Aptos Framework 中,多个具备 key 能力的资源可存储于同一 object 账户.

但对象应隔离存储于不同账户,否则对一个对象的修改会影响整个集合.

例如,转移一个资源会导致所有组成员一同转移,因为 transfer 操作针对 ObjectCore,即账户下所有资源的通用标签.

详细了解 Aptos Objects.

mint_two 允许 sender 为自己创建 Monkey 并向 recipient 发送 Toad.

由于 MonkeyToad 属于同一 object 账户,结果是两者都归 recipient 所有.

module 0x42::example {
#[resource_group(scope = global)]
struct ObjectGroup { }
#[resource_group_member(group = 0x42::example::ObjectGroup)]
struct Monkey has store, key { }
#[resource_group_member(group = 0x42::example::ObjectGroup)]
struct Toad has store, key { }
fun mint_two(sender: &signer, recipient: &signer) {
let constructor_ref = &object::create_object_from_account(sender);
let sender_object_signer = object::generate_signer(constructor_ref);
let sender_object_addr = object::address_from_constructor_ref(constructor_ref);
move_to(sender_object_signer, Monkey{});
move_to(sender_object_signer, Toad{});
let monkey_object: Object<Monkey> = object::address_to_object<Monkey>(sender_object_addr);
object::transfer<Monkey>(sender, monkey_object, signer::address_of(recipient));
}
}

应将对象分别存储于不同 object 账户:

module 0x42::example {
#[resource_group(scope = global)]
struct ObjectGroup { }
#[resource_group_member(group = 0x42::example::ObjectGroup)]
struct Monkey has store, key { }
#[resource_group_member(group = 0x42::example::ObjectGroup)]
struct Toad has store, key { }
fun mint_two(sender: &signer, recipient: &signer) {
let sender_address = signer::address_of(sender);
let constructor_ref_monkey = &object::create_object(sender_address);
let constructor_ref_toad = &object::create_object(sender_address);
let object_signer_monkey = object::generate_signer(&constructor_ref_monkey);
let object_signer_toad = object::generate_signer(&constructor_ref_toad);
move_to(object_signer_monkey, Monkey{});
move_to(object_signer_toad, Toad{});
let object_address_monkey = signer::address_of(&object_signer_monkey);
let monkey_object: Object<Monkey> = object::address_to_object<Monkey>(object_address_monkey);
object::transfer<Monkey>(sender, monkey_object, signer::address_of(recipient));
}
}

抢先交易是指利用对未来已提交操作的预知,提前执行交易以获利.这种行为会破坏公平性,使抢先者获得不正当优势.

抢先交易会破坏 DApp 的公平性和完整性,导致资金损失,游戏不公,市场价格操纵,甚至平台信任丧失.

以彩票为例,用户选择 1~100 的数字参与.管理员调用 set_winner_number 设置中奖号码,随后通过 evaluate_bets_and_determine_winners 评奖.

抢先者可在 set_winner_number 后观察中奖号码,提交或修改投注以匹配中奖号码,在 evaluate_bets_and_determine_winners 执行前获利.

module 0x42::example {
struct LotteryInfo {
winning_number: u8,
is_winner_set: bool,
}
struct Bets { }
public fun set_winning_number(admin: &signer, winning_number: u8) {
assert!(signer::address_of(admin) == @admin, 0);
assert!(winning_number < 10,0);
let lottery_info = LotteryInfo { winning_number, is_winner_set: true };
move_to<LotteryInfo>(admin, lottery_info);
}
public fun evaluate_bets_and_determine_winners(admin: &signer) acquires LotteryInfo, Bets {
assert!(signer::address_of(admin) == @admin, 0);
let lottery_info = borrow_global<LotteryInfo>(admin);
assert(lottery_info.is_winner_set, 1);
let bets = borrow_global<Bets>(admin);
let winners: vector<address> = vector::empty();
let winning_bets_option = smart_table::borrow_with_default(&bets.bets, lottery_info.winning_number, &vector::empty());
vector::iter(winning_bets_option, |bet| {
vector::push_back(&mut winners, bet.player);
});
distribute_rewards(&winners);
}
}

可通过实现 finalize_lottery,在单笔交易中公布答案并结束游戏,并将其他函数设为私有.这样一旦答案公布,系统即不再接受新答案,杜绝抢先交易.

module 0x42::example {
public fun finalize_lottery(admin: &signer, winning_number: u64) {
set_winning_number(admin, winning_number);
evaluate_bets_and_determine_winners(admin);
}
fun set_winning_number(admin: &signer, winning_number: u64) { }
fun evaluate_bets_and_determine_winners(admin: &signer) acquires LotteryInfo, Bets { }
}

在 Defi 应用中,若价格预言机通过流动性池中代币比例定价,易被大户操纵.大户可通过调整持仓影响流动性比例,进而操纵价格,甚至耗尽资金池.

建议采用多预言机定价.

Thala 采用分层预言机设计,主备双预言机,具备智能切换逻辑,能应对对抗性场景,始终提供高精度价格.详细了解.

处理 Token 时,确保用于比较 Token 结构体以确定顺序的方法不会导致冲突.将地址,模块,结构体名拼接为 vector 不足以区分同名但应唯一的 Token.

否则协议可能因冲突错误拒绝合法交易对,危及资金安全.

get_pool_address 为交易对生成唯一池地址.其通过拼接符号生成种子,但用户可自定义 Object<Metadata> 符号,可能伪造已存在实例,导致种子冲突.

module 0x42::example {
public fun get_pool_address(token_1: Object<Metadata>, token_2: Object<Metadata>): address {
let token_symbol = string::utf8(b"LP-");
string::append(&mut token_symbol, fungible_asset::symbol(token_1));
string::append_utf8(&mut token_symbol, b"-");
string::append(&mut token_symbol, fungible_asset::symbol(token_2));
let seed = *string::bytes(&token_symbol);
object::create_object_address(&@swap, seed)
}
}

object::object_address 为每个 Object<Metadata> 返回唯一标识符.

module 0x42::example {
public fun get_pool_address(token_1: Object<Metadata>, token_2: Object<Metadata>): address {
let seeds = vector[];
vector::append(&mut seeds, bcs::to_bytes(&object::object_address(&token_1)));
vector::append(&mut seeds, bcs::to_bytes(&object::object_address(&token_2)));
object::create_object_address(&@swap, seed)
}
}

协议应具备高效暂停操作的能力.不可升级协议需内置暂停功能;可升级协议可通过合约或升级实现暂停.团队应具备自动化工具以快速执行.

缺乏暂停机制会导致漏洞暴露时间过长,带来重大损失.高效暂停功能可及时应对安全威胁,漏洞等,保障用户资产和协议安全.

集成暂停功能示例:

module 0x42::example {
struct State {
is_paused: bool,
}
public entry fun pause_protocol(admin: &signer) {
assert!(signer::address_of(admin)==@protocol_address, ERR_NOT_ADMIN);
let state = borrow_global_mut<State>(@protocol_address);
state.is_paused = true;
}
public entry fun resume_protocol(admin: &signer) {
assert!(signer::address_of(admin)==@protocol_address, ERR_NOT_ADMIN);
let state = borrow_global_mut<State>(@protocol_address);
state.is_paused = false;
}
public fun main(user: &signer) {
let state = borrow_global<State>(@protocol_address);
assert!(!state.is_paused, 0);
// ...
}
}

测试网和主网共用账户存在安全风险.测试网私钥常存储于不安全环境(如笔记本),易泄露.攻击者若获取测试网私钥,可升级主网合约.

关于随机性及其防止可预测性的作用,详见:随机性指南.


Aptos 始终以安全为先.编译时会确保无 randomness API 被 public 函数调用.但用户可通过 #[lint::allow_unsafe_randomness] 允许 public 函数调用.

public 函数直接或间接调用 randomness API,恶意用户可利用函数可组合性,在结果不理想时中止交易,反复尝试直至获利,破坏随机性.

module user::lottery {
fun mint_to_user(user: &signer) {
move_to(user, WIN {});
}
#[lint::allow_unsafe_randomness]
public entry fun play(user: &signer) {
let random_value = aptos_framework::randomness::u64_range(0, 100);
if (random_value == 42) {
mint_to_user(user);
}
}
}

此例中,playpublic,可被其他模块组合.攻击者可调用后检查是否中奖,未中奖则中止并重试.

module attacker::exploit {
entry fun exploit(attacker: &signer) {
@user::lottery::play(attacker);
assert!(exists<@user::lottery::WIN>(address_of(attacker)));
}
}

解决方法:所有调用 randomness API 的函数(直接或间接)应设为 entry,而非 publicpublic entry.

module user::lottery {
fun mint_to_user(user: &signer) {
move_to(user, WIN {});
}
#[lint::allow_unsafe_randomness]
entry fun play(user: &signer) {
let random_value = aptos_framework::randomness::u64_range(0, 100);
if (random_value == 42) {
mint_to_user(user);
}
}
}

若函数不同分支消耗 gas 不同,攻击者可通过设置 gas 上限偏向结果.如下例,不同路径消耗 gas 不同.

module user::lottery {
//transfer 10 aptos from admin to user
fun win(user: &signer) {
let admin_signer = &get_admin_signer();
let aptos_metadata = get_aptos_metadata();
primary_fungible_store::transfer(admin_signer, aptos_metadata, address_of(user),10);
}
//transfer 10 aptos from user to admin, then 1 aptos from admin to fee_admin
fun lose(user: &signer) {
//user to admin
let aptos_metadata = get_aptos_metadata();
primary_fungible_store::transfer(user, aptos_metadata, @admin, 10);
//admin to fee_admin
let admin_signer = &get_admin_signer();
primary_fungible_store::transfer(admin_signer, aptos_metadata, @fee_admin, 1);
}
#[randomness]
entry fun play(user: &signer) {
let random_value = aptos_framework::randomness::u64_range(0, 100);
if (random_value == 42) {
win(user);
} else {
lose(user);
}
}
}

此彩票示例中,winlose 消耗 gas 不同. lose 消耗更多 gas.攻击者可设置 gas 上限,仅足够 win,不足以执行 lose,从而确保永远不会走 lose 分支.攻击者可反复调用直至中奖.

可通过以下方式防护:

  1. 保证更优结果消耗的 gas 不少于较差结果.
  2. 仅允许管理员调用 randomness API.
  3. 保证 entry 函数无论随机结果如何都能正常执行.可先提交随机结果,再用随机结果在另一交易中执行操作,避免立即基于随机性分支,确保 gas 消耗一致.

未来将提供更多功能,支持更复杂代码安全防护 undergasing 攻击.