Aptos可替代资产:搭车客指南

  • osecio
  • 发布于 2025-02-11 11:56
  • 阅读 10

本文深入探讨了Aptos的可替代资产(fungible assets)的实现,包括其函数、对象和交互的复杂性。新的可替代资产模型旨在解决传统Coin标准的局限性和安全缺陷,但也引入了新的挑战和漏洞,开发者应予以关注。文章还讨论了从Coin迁移到可替代资产的机制以及潜在的风险。

Aptos 可替代资产搭便车指南

我们深入研究 Aptos 对可替代资产的实现,探索隐藏在其函数、对象和交互中的复杂性。虽然可替代资产模型旨在解决传统 Coin 标准的局限性和安全缺陷,但它也引入了开发人员应注意的新的挑战和漏洞。

Aptos 可替代资产搭便车指南的标题图片

Aptos 的可替代资产模型是其生态系统的一个复杂组成部分,旨在解决其前身——coin 标准的局限性。虽然新模型旨在增强功能和安全性,但它也带来了一系列挑战。

在这篇博文中,我们将仔细研究 Aptos 的 coin 和可替代资产模型,探索它们的历史和联系。我们将检查可替代资产框架的关键方面,包括已识别和解决的漏洞的真实示例,目的是提高安全性 和可靠性 - 所有这些都是为了帮助你构建更安全可靠的应用程序。

所有提到的问题都在 Aptos 严格的预发布审计期间被发现和解决,这表明了该项目从第一天起就致力于提供强大而安全的环境。

Aptos Coin 标准

最初,Aptos 使用 Coin。它仍然在使用中,尽管它现在被认为是“遗留”的。Coin 在 Aptos 中 定义 如下:

struct Coin<phantom CoinType> has store {
    value: u64,
}

Aptos 在编译时通过它们的类型 ( CoinType) 来区分 coin。 例如,Coin<Otter>Coin<Weasel> 代表不同的 coin,你不能将 Coin<Weasel> 传递给需要 Coin<Otter> 的函数。

类型签名揭示了为什么 Coin 已经成为一种遗留标准。 Coin 只有 store 能力,并使用 CoinStore 包装器来存储 coin 和元数据:

struct CoinStore<phantom CoinType> has key {
    coin: Coin<CoinType>,
    frozen: bool,
    deposit_events: EventHandle<DepositEvent>,
    withdraw_events: EventHandle<WithdrawEvent>,
}

然而,一位精明的读者会注意到,这并不是唯一可以存储 Coin 的地方。 你可以创建自己的 Coin 钱包,它可能看起来像这样:

struct DefinitelyLegitCoinStore<phantom CoinType> has key {
    coin: Coin<CoinType>
}

CoinStore 包括一个 frozen 字段,允许发行者阻止与该存储的交易。 CoinStore 也是 burn_from 操作所必需的,该操作将 coin 从存储中取出并销毁它。冻结和销毁操作是必不可少的,例如,对于稳定币发行者来说,使用它们作为合规工具来防止未经授权或非法的交易并遵守法律命令。能够通过自定义钱包绕过这些限制是一个问题,并可能导致严重的后果。

从链下可观察性的角度来看,将 coin 存储在自定义钱包中也是一个问题,因为在这种设置中找到存储的 coin 是一项困难的任务。这就是可替代资产 AIP-21 总结 coin 问题的方式:

... 由于 Move 结构的僵化和固有的不良可扩展性,coin 模块已被认为不足以满足当前和未来的需求。

现有的 Coin 结构利用了 store 能力,允许链上资产变得无法追踪。给链下可观察性和链上管理(如冻结或销毁)带来挑战。

并声明:

可替代资产解决了这些问题。

让我们来看看这是否确实如此。

可替代资产

Aptos 将可替代资产设计为一个新的 token 标准来解决这些问题。 FungibleAsset 使用 hot-potato 模式

struct FungibleAsset {
    metadata: Object<Metadata>,
    amount: u64,
}

Coin 不同,FungibleAsset 类型在运行时通过 Metadata 字段定义。 这一变化旨在 增强可扩展性

一个对象可以附加其他资源来提供额外的上下文。例如,元数据可以定义给定类型、颜色、质量和稀有度的宝石,其中所有权表示该类型宝石的数量或总重量。

一个重要的含义是,接受 FungibleAssets 的函数必须验证元数据,以确保资产有效。

让我们考虑一个接受资产的协议的可能实现。

public fun deposit<T: key>(
    sender: &signer, fa: FungibleAsset
) acquires [...] {
    assert_not_paused();

    let fa_amount = fungible_asset::amount(&fa);
    let sender_address = address_of(sender);
    check_compliance(fa_amount, sender_address);

    increase_deposit(get_vault(sender_address), fa_amount);

    primary_fungible_store::deposit(global_vault_address(), fa);

    event::emit(Deposit {sender_address, fa_amount})
}

你看到这里有什么问题吗?该应用程序不使用其元数据验证或区分可替代资产,这导致所有可替代资产存款都被视为相同。

虽然这些 bug 不是很复杂,但它们确实代表了一个必须检查的额外的漏洞类别。

可替代资产存储

如前所述,可替代资产是热土豆,这意味着它们必须在每次交易后被销毁。 如果它们缺乏能力,那么它们如何使用?

认识一下 FungibleStore

struct FungibleStore has key {
    metadata: Object<Metadata>,
    balance: u64,
    frozen: bool,
}

FungibleStore 管理余额和元数据,而不是持有实际的 FungibleAsset(它不能这样做,因为 FungibleAsset 没有 store)。 提款会创建临时的 FungibleAsset 资源,而存款会销毁它们并更新余额。 这种设计可以防止绕过冻结并提高可观察性。


一个好奇的读者可能会想,除了提取、存入或铸造 FungibleAsset 之外,还有其他方法可以创建或销毁它吗? 是的——任何人都可以创建和销毁零值的 FungibleAsset

public fun destroy_zero(fungible_asset: FungibleAsset) {
    let FungibleAsset { amount, metadata: _ } = fungible_asset;
    assert!(amount == 0, error::invalid_argument(EAMOUNT_IS_NOT_ZERO));
}

public fun zero<T: key>(metadata: Object<T>): FungibleAsset {
    FungibleAsset {
        metadata: object::convert(metadata),
        amount: 0,
    }
}

理论上,这不应该构成问题。 毕竟,拥有零数量的东西并不能完全算作所有权。

在实践中,自由铸造和销毁任何类型的零 FungibleAsset 的能力可能会带来巨大的风险。 在我们的审查过程中,我们遇到了许多没有考虑到这种可能性的协议,从而导致算术错误、DoS 逻辑错误或不准确的计算。 请记住这个边缘情况,我们稍后会回到这一点。

主存储和副存储

CoinStore 相比,FungibleStores 不是唯一的。 每个用户对于给定的 token 可以有多个 FungibleStore 对象!

主可替代资产存储通过恰如其名的 primary_fungible_store 模块进行维护。 它是“主”存储,因为它的位置是确定性的,它是使用所有者和可替代资产的 Metadata 地址计算出来的。 用户还可以自己创建许多“副”可替代资产存储。

主可替代资产存储的一个关键特性是它们的无需许可的创建。 这可能会导致令人惊讶的拒绝服务 bug!

public entry fun register(
    user: &signer, [...]
) acquires [...] {
    [...]
    let wallet_store = create_primary_store(signer::address_of(sender), get_metadata());
    [...]
}

create_primary_store 函数可能会引入 DoS 漏洞,因为它会在存储已存在时中止。 建议使用 ensure_primary_store_exists 来避免此类问题。

可替代资产和对象

可替代资产标准不是一个独立的模块。 它高度依赖于一个兄弟模块,即在 AIP-10 中引入的 Object 模块。

AIP-21 提出了使用 Move 对象的可替代资产 (FA) 标准。 在此模型中,任何表示为对象的链上资产也可以表示为可替代资产,从而允许单个对象由许多不同的、可互换的所有权单位表示。

这两个模块紧密相连,它们的联系可能非常复杂。

创建和删除

要创建可替代资源,必须首先创建一个不可删除的对象。 “不可删除”意味着不可能获得删除它的许可。 这在 fungible_asset::add_fungibility 中得到验证:

assert!(!object::can_generate_delete_ref(constructor_ref), error::invalid_argument(EOBJECT_IS_DELETABLE));

此对象充当 FungibleAsset 形式的所有权 token 的基础。 这意味着允许它被删除是没有意义的,并且会影响此类可替代资产的可用性,从而限制用户访问关键功能,例如创建新的存储。 过去,fungible_asset::add_fungibility 缺少此断言,这是我们发现并报告的。

fungible_asset::add_fungibilityMetadata 和相关资源转移到这个新对象。 之后,在获得适当的许可后,可以铸造 FungibleAsset,代表该对象中的所有权份额。

/// 通过添加 Metadata 资源使现有对象变为可替代的。
public fun add_fungibility(
    [...]
): Object<Metadata> {
    [...]
    move_to(metadata_object_signer,
        Metadata {
            name,
            symbol,
            decimals,
            icon_uri,
            project_uri,
        }
    );
[...]
}

即使处理符合删除条件的对象,删除也可能是一个大问题。 例如,FungibleStore 也是一个对象,如果为空,则可以将“副”FungibleStore 创建为可删除的。 问题在于删除可以在可替代资产级别和对象级别发生。

//可替代资产
public fun remove_store(delete_ref: &DeleteRef)

//对象
public fun delete(ref: DeleteRef)

object::deleteFungibleStore 对象中删除 Object 时,FungibleStore 资源将永久不可删除。 这是因为 remove_store 无法在没有底层 Object 的情况下创建 Object<FungibleStore>,从而导致操作失败。

public fun remove_store(delete_ref: &DeleteRef) acquires [...] {
    let store = &object::object_from_delete_ref<FungibleStore>(delete_ref);
    [...]
}

此外,这种“已删除”的 FungibleStore 对象至少在一定程度上仍然可操作。 例如,fungible_asset::deposit 不检查 Object 是否存在。

所有权

每个对象都有一个所有者。 可替代资产依赖于 Object 所有权模型。 例如,在提款操作期间,使用 object::owns 验证签名者,以确认 FungibleStore 对象的所有权。

public(friend) fun withdraw_sanity_check<T: key>(
    owner: &signer,
    store: Object<T>,
    abort_on_dispatch: bool,
) acquires FungibleStore, DispatchFunctionStore {
    assert!(object::owns(store, signer::address_of(owner)), error::permission_denied(ENOT_STORE_OWNER));
    [...]
}

需要注意的是,使用 object::owns 定义所有权可能很棘手。 burn 函数就是其中一个原因。 它允许将对象的所有者更改为 BURN_ADDRESS,同时绕过转移限制:

public entry fun burn<T: key>(owner: &signer, object: Object<T>) acquires ObjectCore {
    let original_owner = signer::address_of(owner);
    assert!(is_owner(object, original_owner), error::permission_denied(ENOT_OBJECT_OWNER));
    let object_addr = object.inner;
    move_to(&create_signer(object_addr), TombStone { original_owner });
    transfer_raw_inner(object_addr, BURN_ADDRESS);
}

unburn 是一种恢复先前对象所有者的方式。 在过去的审计中,可以通过暂时将所有权设置为未列入黑名单的 BURN_ADDRESS 来利用此机制绕过可替代资产存储所有者黑名单。 AIP-99 是一项回滚 burn 功能的提案,但先前 burn 的对象将保持可恢复状态。

此 AIP-99 旨在禁用安全对象 burn,因为它导致额外的复杂性,有时会产生意想不到的后果。 由于此 AIP,用户仍然可以 unburn 其 burn 的对象,但将无法 burn 任何新对象。

另一个重要的事情是,可以使用 fungible_asset::set_untransferable 使此资产的所有新 FungibleStores 不可转移,从而防止所有权变更。 但是,此限制不适用于父对象,即使可转移的父对象拥有不可转移的 FungibleStore,也可以移动该父对象。

我们需要关心这种情况吗? 我们需要关心,因为所有权是可传递的。 如果实体 X 拥有一个拥有 FungibleStore 的对象,则 X 可以从该存储中提取。 这是因为 fungible_asset::withdraw 使用 object::owns 来验证 FungibleStore 对象的直接和间接所有权。

fun verify_ungated_and_descendant(owner: address, destination: address) acquires ObjectCore {
        [...]
    while (owner != current_address) {
        count = count + 1;
        [...]
        assert!(
            exists<ObjectCore>(current_address),
            error::permission_denied(ENOT_OBJECT_OWNER),
        );
        let object = borrow_global<ObjectCore>(current_address);
        current_address = object.owner;
    };
}

这可能允许绕过关于 FungibleStore 真实所有权及其不可转移性的假设。

public fun untransferable_transfer(caller: &signer, receipient: address) {
    let constructor_ref = object::create_object(signer::address_of(caller));
    let object_addr = object::address_from_constructor_ref(&constructor_ref);
    let store = primary_fungible_store::ensure_primary_store_exists(object_addr, get_metadata());

    object::transfer_raw(caller, object_addr, receipient);
    //receipient can interact with store by using their signer
}

在我们审查可替代资产标准期间,所有权转移问题也出现了,我们发现了一个有趣的 边缘情况,涉及不可转移的可替代资产存储的转移。

public fun transfer_with_ref(ref: LinearTransferRef, to: address) acquires ObjectCore {
    assert!(!exists<Untransferable>(ref.self), error::permission_denied(ENOT_MOVABLE));
    let object = borrow_global_mut<ObjectCore>(ref.self);
    assert!(
        object.owner == ref.owner,
        error::permission_denied(ENOT_OBJECT_OWNER),
    );

    [...]

    object.owner = to;
}

用户可以通过创建一个对象和一个转移权限、burn 该对象(将其所有权更改为 BURN_ADDRESS)、将其转移给另一个用户,然后使用该对象注册一个不可转移的可替代资产存储来利用这一点。 虽然由于不可转移的限制,无法再使用所有者的 signer 或转移权限来移动该存储,但可以 unburn 该存储以恢复原始所有权!

引用

References 是一种权限类型资源,用于验证安全关键操作的调用者。 Refs 基于 Object 模型,但它们也被可替代资产采用。 其中一些由 Object 本身定义,而另一些是通过可替代资产模块创建的。 更重要的是,有些是在它们之间共享的,而另一些看起来是共享的,但实际上并非如此。

让我们回到 FungibleStore 删除示例。 object::deletefungible_asset::remove_store 都使用相同的特定于对象的 DeleteRef 权限。 它只能在对象创建期间创建。 可替代资产没有单独的 DeleteRef

//可替代资产
public fun remove_store(delete_ref: &DeleteRef)

//对象
public fun delete(ref: DeleteRef)

另一方面,使用 TransferRef 切换 FungibleStore 的“冻结”状态,TransferRef 在这两种模型中都有定义(并且不可互换)。 它们也只能在对象创建期间创建。

public fun set_frozen_flag<T: key>(
    ref: &TransferRef,
    store: Object<T>,
    frozen: bool,
)

Object TransferRef 用于转移对象所有权:

/// 用于创建 LinearTransferRef,因此用于所有权转移。
struct TransferRef has drop, store {
    self: address,
}

而可替代资产的 TransferRef 管理可替代资产的转移和可替代资产存储的(取消)冻结:

/// TransferRef 可用于允许或禁止可替代资产的所有者转移资产,并允许 TransferRef 的持有人从任何帐户转移可替代资产。
struct TransferRef has drop, store {
    metadata: Object<Metadata>
}

此外,还有特定于可替代资产的引用,例如用于铸造的 MintRef 和用于 burn 的 BurnRef。 这些引用仅由可替代资产模型使用,但它们仍然必须在初始化可替代资产对象时创建。

可分派的可替代资产

可分派的可替代资产通过启用存款和取款等操作的重载来增强可替代资产的功能。

在创建可分派的可替代资产期间注册的 Hook 会覆盖这些操作的默认逻辑,从而允许自定义功能,例如访问控制、费用机制或细粒度的暂停。

重载核心可替代资产函数会引入潜在的安全风险;例如,在存款期间,资金可能最终不会到达预期的地址。 可分派的可替代资产 API 提供了诸如 transfer_assert_minimum_deposit 之类的函数,可以帮助降低此类风险。

可分派的可替代资产的 Hook 函数必须具有正确的类型签名。 它们还必须声明为 public,以确保 它们的签名保持不变。 一个示例实现可能如下所示:

public fun withdraw_hook<T: key>(
    store: Object<T>,
    amount: u64,
    transfer_ref: &TransferRef,
): FungibleAsset {
    //检查是否暂停、收取费用等。
    fungible_asset::withdraw_with_ref(transfer_ref, store, amount)
}

public fun deposit_hook<T: key>(
    store: Object<T>,
    fa: FungibleAsset,
    transfer_ref: &TransferRef,
) {
    //检查是否暂停、收取费用等。
    fungible_asset::deposit_with_ref(transfer_ref, store, fa);
}

为什么 Hook 函数依赖于 *_with_ref 调用? 如果 Hook 函数调用 dispatchable_fungible_asset::withdraw 而不是 fungible_asset::withdraw_with_ref 会发生什么?

A1:Hook 函数依赖于 *_with_ref 调用,因为默认的可替代资产函数会验证可替代资产是否不可分派。

A2:dispatchable_fungible_asset::withdraw 将导致 RUNTIME_DISPATCH_ERROR(代码 4037)错误,错误消息为:“检测到重入”。

在我们的其中一次审查中,我们遇到了一个可分派的可替代资产,其中 Hook 的提款设置了一个“阻止”标志,该标志由相应的存款清除。 此设计用于确保每次提款都与存款相关联,从而有效地防止同时提款。

public fun deposit<T: key>(store: Object<T>, fa: FungibleAsset, transfer_ref: &TransferRef) {
    assert_withdraw_flag(true);
    [...]
    set_withdraw_flag(false);
    fungible_asset::deposit_with_ref(transfer_ref, store, amount);
    [...]
    }

public fun withdraw<T: key>(store: Object<T>, amount: u64, transfer_ref: &TransferRef): FungibleAsset acquires [...] {
    assert_withdraw_flag(false);
    [...]
    set_withdraw_flag(true);
    fungible_asset::withdraw_with_ref(transfer_ref, store, amount)
}

乍一看,此代码似乎有效,但对于一位精明的读者来说并非如此。

你能发现这个 bug 吗? 提示:我们之前提到过根本原因。

开发人员忽略了一个重要的细节,我们之前已经提到过:也可以 burn 值为零的可替代资产! 攻击者可以通过提取 0 个 FungibleAsset(因为提款不验证该值是否大于 0),然后使用 fungible_asset::destroy_zero 对其进行 burn 来利用这一点。 这将完成交易,同时保持“阻止”标志的设置,从而有效地防止进一步提款。

重要的是要了解标准中的所有功能。

从 coin 迁移到可替代资产

如果可替代资产被认为是 Coin 的升级,则转换机制变得必要。 这是通过转换映射来解决的,该映射建立了特定 coin 和可替代资产之间的关系。 这种二元性并非没有挑战。

虽然 Coin API 识别并集成了可替代资产,但可替代资产 API 不知道链接的 Coin

如果尚不存在,coin_to_fungible_asset 转换函数会自动为 Coin 生成相应的可替代资产。 不允许手动创建可替代资产并将其链接到 Coin

public fun coin_to_fungible_asset<CoinType>(
    coin: Coin<CoinType>
): FungibleAsset acquires CoinConversionMap, CoinInfo {
    let metadata = ensure_paired_metadata<CoinType>();
    let amount = burn_internal(coin);
    fungible_asset::mint_internal(metadata, amount)
}

创建可替代资产时,需要几条信息,例如资产的名称、符号或最大供应量。 在我们审计可替代资产标准期间,我们 注意到链接过程中一个被忽略的细节

[...]
primary_fungible_store::create_primary_store_enabled_fungible_asset(
    &metadata_object_cref,
    option::map(coin_supply<CoinType>(), |_| MAX_U128),
    name<CoinType>(),
    symbol<CoinType>(),
    decimals<CoinType>(),
    string::utf8(b""),
    string::utf8(b""),
);
[...]

创建链接的可替代资产时,当前的 Coin 供应量被错误地作为最大可替代资产供应量传递,从而阻止了铸造超出现有 coin 流通量的额外可替代资产。

用户可以手动将其 CoinStore 迁移到主可替代资产存储。 这会为配对的可替代资产创建一个存储(如果不存在一个),并从调用者中删除 <CoinStore<CoinType>>CoinStore 中的所有 coin 都会在迁移期间进行交换并转移到新存储。

/// 如果还没有,则自愿迁移到 `CoinType` 的可替代资产存储。
public entry fun migrate_to_fungible_store<CoinType>(
    account: &signer
) acquires CoinStore, CoinConversionMap, CoinInfo {
    maybe_convert_to_fungible_store<CoinType>(signer::address_of(account));
}

一位好奇的读者可能会想知道迁移期间 CoinStore “冻结”状态的命运。 毫不奇怪,主可替代资产存储的“冻结”状态与 CoinStore 的状态匹配,以确保一致性。

攻击者是否可以将他们的 CoinStore 转换为主可替代资产存储,然后注册另一个 CoinStore 仅为了再次将其转换为操作链接的主可替代资产存储的“冻结”状态?

coin::register 函数首先检查 is_account_registered,如果为 true,则提前退出。 is_account_registered 确定当 CoinStore 不存在时,帐户是否具有链接的可替代资产的主可替代资产存储。 如果已转换可替代资产存储,则已存在主可替代资产存储和链接的可替代资产,从而阻止了重新注册。

结论

Aptos 对可替代资产的实现确实解决了 Coin 的原始问题。

但是,此解决方案带来了一系列挑战,部分原因是彼此交互的层数众多。 在使用可替代资产标准之前,重要的是要了解这些不同的 API 和潜在的陷阱。

作为给读者的最后一个练习,有多少种不同的方式可以提取可替代资产? 1

脚注

  1. 至少有四个函数可以提取一种可替代资产:

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

0 条评论

请先 登录 后评论
osecio
osecio
Audits that protect blockchain ideas.