Move Smart Contract Access Control: How Capabilities Leak and Authorization Fails

G2MU
G2MU
Security Analyst

Introduction

Part 1 made one claim central to this series: Move’s language-level guarantees do not prevent protocol-level exploits. The VM enforces resource safety and bytecode correctness. It does not enforce who is allowed to do what. That gap is where most real Move exploits live.

Access control failures account for 6.4% of all findings in the Move Vulnerability Database (MVD v3.0). Zhang et al. (2025), scanning 37,302 Aptos and Sui contracts, identify four Move-specific critical vulnerability classes, including TreasuryCap leakage. MoveScan (Song et al., ISSTA 2024) found 97,028 defects across the same corpus at 98.85% precision. Zellic ranks improper access control as the #3 most common finding from its Aptos Move audit practice. At the ecosystem level, Hacken’s 2025 security report attributes roughly 53% of total DeFi losses that year to access control failures.

The cost is concrete. On October 15, 2025, Typus Finance lost $3.44M on Sui because one assert! statement was missing from an oracle update function. The attacker broke no VM invariant. They called a public function that never checked who they were.

This article covers how capabilities leak, signer validation fails, resources break at the protocol level, and why authority boundaries remain the most common source of critical findings in Move audits.

Flowchart comparing Move's capability-based access control with Solidity's msg.sender role system, showing how each model gates function execution.
Solidity checks identity via msg.sender. Move checks possession of a capability struct. Both gate execution, but the mechanism differs fundamentally.

Move does not have Solidity’s msg.sender-based role system, onlyOwner, or any built-in access control modifier. Instead, Move uses capabilities: a privileged struct that grants the holder a specific power. Possession of this struct is the authorization. The Move type system prevents forging a struct defined in another module. It does not prevent a developer from accidentally returning, sharing, or exposing that struct to callers who were never meant to have it.

Key actors and trust boundaries

  • Deployer/protocol admin: trusted at init() to receive capabilities. Trust boundary: the publish transaction.
  • Upgrade admin: trusted to publish new module versions. Trust boundary: UpgradeCap possession and any governance wrapper.
  • User: trusted only with resources they own. Trust boundary: the stored owner address (Aptos) or object::owner(&obj) equality (Sui).
  • Attacker: any account that can submit a transaction. Trusted with nothing. Can call any public/entry function and compose calls in a single PTB (Sui) or script (Aptos).

Sui vs Aptos: Capability/Access Control Primitives

Concern

Sui

Aptos

Token mint authority

TreasuryCap<T> (owned object, one per currency)

0x1::coin::MintCapability<T> (stored resource)

Shared vs global state

Shared objects (transfer::share_object), reachable by any tx

Global storage under an address (borrow_global_mut<T>(addr))

One-shot initialization

fun init(otw: OTW, ctx: &mut TxContext)

fun init_module(deployer: &signer)

Internal cross-module visibility

public(package) (Move 2024 edition)

public(friend) with explicit friend declaration

Caller identity

tx_context::sender(ctx) on &TxContext

signer::address_of(signer) on &signer

Ownership check

Transaction-level for owned objects; explicit object::owner(&obj) for shared objects

Address-scoped via borrow_global<T>(addr); caller must assert addr == signer::address_of(signer)

Object creation handle

UID via object::new(ctx) (no factory ref exposed)

ConstructorRef produces TransferRef, DeleteRef, ExtendRef

Same class of bug, different surface. A missing owner check on Aptos targets the address in borrow_global_mut; on Sui, it targets the object passed as &mut T.

2. Capability & Access Control Vulnerabilities

This section covers the highest-frequency ways Move protocols accidentally grant authority: leaking capability values or references, skipping signer validation, and exposing internal-only entry points through visibility mistakes.

2.1 Capability Leakage via Return Values

A capability leak happens when a public function creates a privileged struct and hands it back to the caller instead of transferring it to a specific, authorized address. The VM treats this as a legitimate transfer of ownership. Any account that calls the function receives minting authority, upgrade rights, or whatever power the capability controls.

Zhang et al. (2025) document TreasuryCap leakage as one of four critical Move-specific vulnerabilities. The Sui currency standard documentation prescribes the init + transfer::public_transfer flow for the same reason.

Diagram showing two TreasuryCap flows in Sui Move: the safe path transfers the cap directly to the deployer via init(), while the capability leak path returns it from a public function, giving any caller unlimited minting rights.

Red path: returning a capability from a public function exposes it to any caller. Green path: transferring directly to the deployer keeps it safe.

// VULNERABLE: Anyone can call this and receive admin privileges
public fun get_admin_cap(): AdminCap {
    AdminCap {}
}

// VULNERABLE: TreasuryCap returned from a public initializer (unlimited minting)
public fun initialize(ctx: &mut TxContext): TreasuryCap<TOKEN> {
    let (treasury_cap, metadata) = coin::create_currency(
        TOKEN {}, 9, b"TKN", b"Token", b"", option::none(), ctx
    );
    transfer::public_freeze_object(metadata);
    treasury_cap  // BUG: Returns to any caller (they can mint unlimited tokens)
}

// SAFE: Send TreasuryCap only to the deployer via init()
fun init(ctx: &mut TxContext) {
    let (treasury_cap, metadata) = coin::create_currency(
        TOKEN {}, 9, b"TKN", b"Token", b"", option::none(), ctx
    );
    transfer::public_freeze_object(metadata);
    transfer::public_transfer(treasury_cap, tx_context::sender(ctx));
    // Only the deployer receives minting authority
}

Capability leak by reference: Public &mut getter exposes inner privileged state (Critical/Sui)

Pattern: a public getter returns &mut T to a privileged inner resource. Any caller who can reference the shared object can now mutate every field of the inner type, so every field becomes a public API.

// Context: a public getter that exposes &mut to the inner privileged
// Account is the same bug class as returning AdminCap by value, but at
// the reference level. Even if downstream privileged operations are not
// public, the &mut handle lets any caller mutate fields directly or
// pass it into any other public function that accepts &mut Account<C>.

module amm::vault {
    use sui::dynamic_object_field as dof;
    use sui::tx_context::TxContext;

    public struct Account<C> has store { collateral: u64, debt: u64 }
    public struct Vault<L, C> has key { id: UID }
    fun account_key(): vector<u8> { b"account" }

    // VULNERABLE: public getter hands out &mut Account<C> to anyone
    // holding a reference to the shared Vault. Turns every field of
    // Account<C> (collateral, debt, risk params) into a public API.
    public fun account_mut<L, C>(vault: &mut Vault<L, C>): &mut Account<C> {
        dof::borrow_mut(&mut vault.id, account_key())
    }

    // SAFE: restrict the &mut capability to the owning package, OR
    // don't expose the reference at all. Expose narrow, capability-
    // gated methods that mutate only what is intended.
    public(package) fun account_mut_safe<L, C>(vault: &mut Vault<L, C>): &mut Account<C> {
        dof::borrow_mut(&mut vault.id, account_key())
    }

    public(package) fun update_position<L, C>(vault: &mut Vault<L, C>, /* params */) {
        let acct = dof::borrow_mut<Account<C>>(&mut vault.id, account_key());
        // mutate only what is intended
    }
}

Witness pattern bypass: Bare T: drop lets anyone mint a Cap<T> (Critical/Sui)

Pattern: a constructor parameterized as T: drop accepts any droppable struct, not just a one-time witness. Attackers define their own droppable type and mint privileged Cap<T> instances at will.

// Context: the witness pattern is meant to ensure only the defining
// module can mint a Cap<T>. `T: drop` is too loose a bound, and many
// types are droppable, so any attacker module can declare its own
// drop-only struct. The fix is `types::is_one_time_witness(&witness)`,
// which checks the witness was constructed in the same module that
// defined T (one-time witnesses are uppercase, drop-only, and produced
// exactly once at module init).

module enclave::caps {
    use sui::object;
    use sui::tx_context::TxContext;
    use sui::types;

    public struct Cap<T> has key { id: UID }

    // VULNERABLE: any droppable T satisfies the bound. An attacker
    // module can declare `struct W has drop {}` and mint unbounded
    // Cap<W> by calling new_cap(W {}, ctx).
    public fun new_cap<T: drop>(_: T, ctx: &mut TxContext): Cap<T> {
        Cap { id: object::new(ctx) }
    }

    // SAFE: enforce one-time witness so only the defining module's OTW
    // can pass the gate.
    public fun new_cap_safe<T: drop>(witness: T, ctx: &mut TxContext): Cap<T> {
        assert!(types::is_one_time_witness(&witness), ENotOneTimeWitness);
        Cap { id: object::new(ctx) }
    }
}

// The defining module supplies the OTW type (conventionally uppercase, drop-only).
module enclave::witness {
    public struct ENCLAVE has drop {}
}

2.2 Missing Signer Authorization


Missing signer authorization is the most direct access control failure: a public entry fun accepts a &signer parameter but never checks who that signer is. Move’s &signer type only proves that someone authenticated the transaction. It does not prove they are the right someone. Unlike Solidity’s implicit msg.sender, Move requires the developer to extract the address with signer::address_of() and then assert it matches a stored owner or role. When that assertion is missing, any account can call the function and operate on any other account’s resources by passing arbitrary addresses as parameters. As the Aptos Move security guide on global storage access control states: “Accepting a &signer is not always sufficient for access control. Be sure to assert that the signer is the expected account.”

Zellic’s #3 most common Aptos Move bug uses a fictional vulnerable AMM (DonkeySwap) to illustrate the class: a cancel_order function accepts a &signer but never verifies that the signer owns the order being cancelled. An attacker can cancel any user’s order and receive the deposited funds. Zellic rates the finding Critical severity, High likelihood. Real protocols have shipped the same pattern.

// VULNERABLE: accepts &signer but never checks ownership of the order
public entry fun cancel_order<CoinType>(
    user: &signer, order_id: u64
) acquires OrderStore {
    let order = get_order(order_id);
    // Missing: assert!(order.user_address == signer::address_of(user), ERR_PERMISSION_DENIED);
    deposit_funds<CoinType>(order_store, signer::address_of(user), order.base);
    // Attacker cancels someone else's order and receives the funds
}
// SAFE: Verify caller owns the order before releasing funds
public entry fun cancel_order<CoinType>(
    user: &signer, order_id: u64
) acquires OrderStore {
    let order = get_order(order_id);
    assert!(order.base_type == type_info::type_of<CoinType>(), ERR_WRONG_COIN_TYPE);
    assert!(order.user_address == signer::address_of(user), ERR_PERMISSION_DENIED);
    deposit_funds<CoinType>(order_store, signer::address_of(user), order.base);
}

Unverified &signer parameter: Admin-named but never asserted (Medium/Aptos)

Pattern: a public entry fun takes a &signer parameter named admin but never asserts the caller is the expected admin address. The parameter name is documentation, not access control.

// Context: the resource account is derived from the *caller's* address,
// but every other function in the module looks up resources at the
// fixed @movedrop-derived address. With no signer assert, any user-
// initiated airdrop permanently strands its funds at an unreachable
// address. The funds are not stolen, but they are inaccessible to the
// real protocol code.

// VULNERABLE: 'admin' signer accepted but never validated.
public entry fun initialize_airdrop(admin: &signer, seed: vector<u8>, total_amount: u64) {
    // Missing: assert!(signer::address_of(admin) == @movedrop, ENotAuthorized);
    let (resource_signer, _cap) = account::create_resource_account(admin, seed);
    let resource_addr = signer::address_of(&resource_signer);
    aptos_account::transfer(admin, resource_addr, total_amount);
}

// SAFE: assert the signer matches the expected protocol admin address.
public entry fun initialize_airdrop(admin: &signer, seed: vector<u8>, total_amount: u64) {
    assert!(signer::address_of(admin) == @movedrop, ENotAuthorized);
    let (resource_signer, _cap) = account::create_resource_account(admin, seed);
    let resource_addr = signer::address_of(&resource_signer);
    aptos_account::transfer(admin, resource_addr, total_amount);
}

Conditional authorization: assert! only fires inside one branch (High/Sui)

Pattern: an authorization assert! is nested inside an if branch, so it only runs on some control-flow paths. Outside the gated branch, anyone passes.

// Context: the deadline check is unconditional, but the driver-identity
// assert lives inside the penalty-period branch. Outside the penalty
// window, the function returns a fulfill ticket without checking the
// caller, so anyone can fulfill the order and sidestep the auction.
// Cognitive bias: a reviewer skims, sees `assert!`, marks it safe;
// always check the *control-flow position* of every authorization assert.

// VULNERABLE: auth assert only fires inside the penalty-period branch.
public fun prepare_fulfill_winner(
    order_item: &Order,
    msg_driver: address,
    clock: &Clock,
    ctx: &TxContext,
): FulfillTicket {
    let clock_now_s = clock.timestamp_ms() / 1000;
    assert!(order_item.deadline() > clock_now_s, EDeadlineIsPassed);
    if (clock_now_s >= order_item.deadline() - (order_item.penalty_period() as u64)) {
        assert!(msg_driver == ctx.sender(), EInvalidDriver);
    };
    build_ticket(order_item, msg_driver)
}

// SAFE: auth assert runs unconditionally, before any branching.
public fun prepare_fulfill_winner(
    order_item: &Order,
    msg_driver: address,
    clock: &Clock,
    ctx: &TxContext,
): FulfillTicket {
    let clock_now_s = clock.timestamp_ms() / 1000;
    assert!(order_item.deadline() > clock_now_s, EDeadlineIsPassed);
    assert!(msg_driver == ctx.sender(), EInvalidDriver);
    build_ticket(order_item, msg_driver)
}

2.3 Internal-Only Code That Becomes Public

The friend declaration in Move grants another module the ability to call public(friend) functions, bypassing normal visibility restrictions. This exists because protocols often need internal cross-module communication (a router calling a vault’s mint function, for example) without exposing that function to the public. Vulnerability arises when the friend list is too broad, includes test modules, or grants access to modules that themselves have public entry points an attacker can invoke.

A related Sui-specific pitfall is the public(package) entry fun misconception: as documented in Monethic’s Sui Move security workshop, developers assume public(package) restricts external access, but the entry modifier makes the function directly callable as a transaction entry point by anyone. CertiK’s Aptos audit notes cover related misconfigurations that appear when developers port Solidity patterns, because Move’s module system has no direct equivalent to Solidity’s internal visibility.

// VULNERABLE: Test module left as friend in production
module protocol::vault {
    friend protocol::vault_tests;  // <-- Should be removed before deploy!
    friend protocol::router;

    public(friend) fun mint_lp(amount: u64): Coin<LP> {
        // vault_tests can call this in production
        // Anyone who deploys a package that imports vault_tests
        // gains indirect access to LP minting
    }
}

// SAFE: Only production modules as friends, minimal surface
module protocol::vault {
    friend protocol::router;  // Only the router needs mint access

    public(friend) fun mint_lp(amount: u64): Coin<LP> {
        // Only router can call this
    }
}

Alternate constructor bypass: parse() re-exposes new() (Informational/Sui)

Pattern: a canonical constructor is restricted to public(package), but a sibling deserializer (parse/from_bytes/decode) sits at public visibility and re-exposes construction at wider visibility.

// Context: `new` is correctly gated to the owning package, but `parse`
// reconstructs the same Ticket from arbitrary bytes at `public` visibility.
// Anyone outside the package can fabricate a Ticket, bypassing the `new`
// restriction entirely. Audit every sibling of a restricted constructor:
// parse, from_bytes, deserialize, unpack, decode.

module nft::ticket {
    public struct Ticket has key, store {
        id: UID,
        event_id: u64,
        seat: u64,
    }

    // Canonical constructor (correctly restricted).
    public(package) fun new(event_id: u64, seat: u64, ctx: &mut TxContext): Ticket {
        Ticket { id: object::new(ctx), event_id, seat }
    }

    // VULNERABLE: deserializer at wider visibility is a second construction
    // path that the `new` restriction doesn't cover.
    public fun parse(bytes: vector<u8>, ctx: &mut TxContext): Ticket {
        let (event_id, seat) = bcs::peel_u64_pair(bytes);
        Ticket { id: object::new(ctx), event_id, seat }
    }

    // SAFE: match `new`'s visibility AND route through `new` so there is
    // exactly one construction path.
    public(package) fun parse_safe(bytes: vector<u8>, ctx: &mut TxContext): Ticket {
        let (event_id, seat) = bcs::peel_u64_pair(bytes);
        new(event_id, seat, ctx)
    }
}

Sibling function asymmetry: update skips the threshold check insert enforces (High/Sui)

Pattern: an “enter” function enforces an invariant; a “modify” sibling that mutates the same state does not. Attacker enters legitimately, then drops below the threshold via the unguarded sibling.

// Context: `insert` (enter the active set) correctly asserts the stake
// is at or above threshold. `update` (modify in place) was supposed to
// enforce the same invariant but skips the assert. Attacker stakes
// above threshold to enter, then calls update to drop below it,
// retaining active-set membership while dodging the stake requirement.

module committee::active_set {
    const EBelowThreshold: u64 = 0;

    struct Set has key {
        id: UID,
        threshold_stake: u64,
        // active: Table<address, u64>
    }

    // SAFE: "enter the set" path enforces the threshold.
    public fun insert(set: &mut Set, node: address, staked_amount: u64) {
        assert!(staked_amount >= set.threshold_stake, EBelowThreshold);
        // table::add(&mut set.active, node, staked_amount);
    }

    // VULNERABLE: "update" sibling skips the threshold check.
    public fun update(set: &mut Set, node: address, new_staked_amount: u64) {
        // Missing: assert!(new_staked_amount >= set.threshold_stake, EBelowThreshold);
        // table::borrow_mut(&mut set.active, node) = new_staked_amount;
    }
}

2.4 When “Encapsulation” Fails on Shared Objects (Sui)

Sui’s object model distinguishes between owned objects (accessible only by their owner) and shared objects (accessible by any transaction). Storing an admin capability inside a shared object collapses that distinction: the capability becomes reachable by anyone who can reference the shared object’s ID, which is everyone. Developers treat struct composition as encapsulation (“the cap is inside the vault, so it’s protected”), but Sui’s runtime provides no field-level access control on shared objects. Any function that takes &mut Vault can read or mutate every field, including embedded capabilities.

The Hacken Move audit checklist flags the broader pattern: “Every Object<T> can potentially be passed to a function by anyone, so the code must validate that object::owner(&obj) equals the caller’s address.” The same exposure applies to capabilities stored in dynamic fields of shared objects.

// VULNERABLE: AdminCap inside a shared Vault (anyone can access)
public struct Vault has key {
    id: UID,
    balance: Balance<USDC>,
    admin_cap: AdminCap,  // Accessible to anyone interacting with the shared vault
}

fun init(ctx: &mut TxContext) {
    let vault = Vault {
        id: object::new(ctx),
        balance: balance::zero(),
        admin_cap: AdminCap { id: object::new(ctx) },
    };
    transfer::share_object(vault);  // Now EVERYONE can reach admin_cap
}
// SAFE: AdminCap held by the admin's owned address, separate from shared state
public struct Vault has key {
    id: UID,
    balance: Balance<USDC>,
}

public struct AdminCap has key {
    id: UID,
    vault_id: ID,  // Links cap to vault without embedding it
}

fun init(ctx: &mut TxContext) {
    let vault = Vault { id: object::new(ctx), balance: balance::zero() };
    let vault_id = object::id(&vault);
    transfer::share_object(vault);
    // AdminCap goes ONLY to deployer, not inside the shared object
    transfer::transfer(AdminCap { id: object::new(ctx), vault_id }, ctx.sender());
}

Type-level cap ≠ runtime authorization: typed cap accepted without revocation check (High/Sui)

Pattern: a privileged function accepts a typed &AuthorityCap<PACKAGE, Role> and never consults the runtime allowlist. A revoked cap holder’s value still satisfies the type check, so revocation has no effect.

// Context: the type system can prove a value has type
// &AuthorityCap<PACKAGE, Role>, but it cannot prove the cap is *still*
// authorized. Allowlists are mutated; roles are revoked; multisigs are
// rotated. If the runtime allowlist isn't consulted on every call, a
// revoked operator who still holds the cap object continues to act as
// a current one. The cap doesn't know it was revoked.

// VULNERABLE: typed cap checked at compile time, runtime allowlist never consulted.
public fun new<Role>(
    wrapper: &mut OracleAggregatorStorkIntegration,
    cap: &AuthorityCap<PACKAGE, Role>,
    feed_id: vector<u8>,
): FeedInfoObject {
    let wrapper_id = wrapper.borrow_mut_id(cap);
    let key = FeedInfoObjectKey(feed_id);
    // Missing: assert!(wrapper.active_assistants.contains(&object::id(cap)), EAssistantRevoked);
    assert!(!derived_object::exists(wrapper_id, key), EFeedInfoObjectAlreadyCreated);
    FeedInfoObject { id: derived_object::claim(wrapper_id, key), feed_id }
}

// SAFE: consult the runtime allowlist before honoring the typed cap.
public fun new<Role>(
    wrapper: &mut OracleAggregatorStorkIntegration,
    cap: &AuthorityCap<PACKAGE, Role>,
    feed_id: vector<u8>,
): FeedInfoObject {
    assert!(wrapper.active_assistants.contains(&object::id(cap)), EAssistantRevoked);
    let wrapper_id = wrapper.borrow_mut_id(cap);
    let key = FeedInfoObjectKey(feed_id);
    assert!(!derived_object::exists(wrapper_id, key), EFeedInfoObjectAlreadyCreated);
    FeedInfoObject { id: derived_object::claim(wrapper_id, key), feed_id }
}

2.5 Leaking Object Control Handles via ConstructorRef (Aptos)

Aptos’s object model generates a ConstructorRef when creating an on-chain object. This ref is a one-time factory that can produce TransferRef, DeleteRef, ExtendRef, and signer capabilities for that object. The design intent is that ConstructorRef is used only within the creating function and then dropped. Vulnerability arises when a developer stores it in a struct with the store ability, returns it from a public function, or passes it to an untrusted module. ConstructorRef can generate any capability for its object, so leaking it is equivalent to root access to that object: the holder can transfer it after a sale, destroy it, or attach arbitrary resources.

The Aptos object security guidelines are explicit: “When creating objects, ensure you never expose the object’s ConstructorRef, as it allows adding resources to an object.” This pattern is especially dangerous in NFT minting functions where developers return ConstructorRef for convenience, not realizing it grants permanent control over every token minted through that path.

// VULNERABLE: ConstructorRef leaked (holder can add resources or re-transfer)
public fun mint(creator: &signer): ConstructorRef {
    let constructor_ref = token::create_named_token(creator, ...);
    constructor_ref // Any caller can now:
    // 1. Generate TransferRef → steal the token back after selling it
    // 2. Generate DeleteRef → destroy the token
    // 3. Add arbitrary resources to the token object
}

// SAFE: Use ConstructorRef internally; never return or store it
public fun mint(creator: &signer) {
    let constructor_ref = token::create_named_token(creator, ...);
    let transfer_ref = object::generate_transfer_ref(&constructor_ref);
    // Store only what's needed; constructor_ref is dropped at function end
    // No external caller ever touches it
}

Deterministic-address frontrunning: non-idempotent init bricks creation (Medium/Aptos)

Pattern: an init/setup function calls a non-idempotent create at a deterministic address. An attacker pre-creates the resource at the predictable address; every legitimate setup call then aborts.

// Context: Aptos derives resource and object addresses deterministically
// from seeds, so the target address of `create_primary_store` is known
// in advance. `create_primary_store` aborts if a store already exists,
// so an attacker who pre-creates one at the predictable address bricks
// every subsequent reward addition. A companion finding does the same
// with `aptos_account::create_account` to brick lending-pair creation.

// VULNERABLE: non-idempotent create at deterministic address.
public entry fun new_reward_fa(manager: &signer, asset_metadata: Object<Metadata>) acquires Farming {
    assert!(manager::is_manager(manager), ERR_FARMING_UNAUTHORIZED);
    primary_fungible_store::create_primary_store(package::package_address(), asset_metadata);
    new_reward_for_farming_internal(fungible_asset::name(asset_metadata));
}

// SAFE: idempotent API tolerates a pre-existing store.
public entry fun new_reward_fa(manager: &signer, asset_metadata: Object<Metadata>) acquires Farming {
    assert!(manager::is_manager(manager), ERR_FARMING_UNAUTHORIZED);
    primary_fungible_store::ensure_primary_store_exists(package::package_address(), asset_metadata);
    new_reward_for_farming_internal(fungible_asset::name(asset_metadata));
}

Case Study 1: Typus Finance ($3.44M, October 2025)

The Typus Finance exploit is the clearest recent example of what happens when access control fails in a Move protocol on Sui.

What happened: Typus Finance deployed a custom oracle module for its TLP (Token Liquidity Pool) contract on November 13, 2024. The module included an update_v2 function for updating price feeds. This function was intended to be callable only by whitelisted oracle operators. The whitelist mechanism existed in the code, but the assert statement enforcing it was missing.

Simplified reconstruction:

// VULNERABLE: update_v2 has no authorization check
public entry fun update_v2(
    oracle: &mut Oracle,
    token_type: u8,
    price: u64,
    timestamp: u64,
    _ctx: &TxContext,
) {
    // Missing: assert!(is_whitelisted(oracle, ctx.sender()), ENotAuthorized);
    // Anyone can set any price for any token
    oracle.prices[token_type] = price;
    oracle.timestamps[token_type] = timestamp;
}

// FIXED: Enforce whitelist before allowing price update
public entry fun update_v2(
    oracle: &mut Oracle,
    token_type: u8,
    price: u64,
    timestamp: u64,
    ctx: &TxContext,
) {
    assert!(table::contains(&oracle.whitelist, ctx.sender()), ENotAuthorized);
    assert!(timestamp > oracle.timestamps[token_type], EStaleUpdate);
    oracle.prices[token_type] = price;
    oracle.timestamps[token_type] = timestamp;
}

Attack sequence:

Sequence diagram of the Typus Finance $3.44M exploit on Sui: attacker calls update_v2 with an inflated SUI price, bypasses the missing authorization check, swaps at the manipulated rate via the TLP pool, drains USDC, xBTC and suiETH, bridges funds to Ethereum, and swaps to DAI.
The attacker called a public oracle update function with no authorization check, set an inflated SUI price, swapped at the fake rate, and bridged the proceeds to Ethereum.

Why the audit missed it? The vulnerable oracle module was deployed in November 2024 but was not included in the scope of the May 2025 audit conducted by MoveBit. The post-mortem explicitly states two compounding process failures: the oracle module was out of scope, and on-chain monitoring was not configured for immediate detection.

This is the audit-scope-as-attack-surface pattern: code that exists in production but was never reviewed becomes the weakest link.

Lessons for auditors:

  • Audit scope must include all deployed modules, not just the “core” protocol.
  • Custom oracle implementations are high-risk by default and should be flagged for priority review.
  • Every public entry fun that writes to shared state needs an explicit authorization check.

Case Study 2: Matrixdock XAUm (Role Confusion + Single-Step UpgradeCap Transfer)

Matrixdock’s XAUm tokenized-gold protocol, audited by Zellic, ships two access-control failures in the same mtoken module: a copy-paste role check and admin-transfer hygiene with no recourse path. Each is a one-line bug; together they cover §2.3 and §2.5 in production.

Operator can revoke an owner-level request (Low/Sui)

XAUm gates privileged actions through pre-issued request objects. SetRevokerReq is owner-level governance (only the owner may issue it, and only the owner should be able to revoke it). The revoke_set_revoker function consumes that owner-level request but calls the wrong gate:

// VULNERABLE: SetRevokerReq is owner-level. The gate must be check_owner, not check_operator
entry fun revoke_set_revoker<T>(state: &State<T>, req: SetRevokerReq, ctx: &TxContext) {
    check_version(state);
    check_operator(state, ctx); // BUG: copy-pasted from an operator-gated sibling
    let SetRevokerReq { id, .. } = req;
    id.delete();
}

// SAFE: the gate matches the role that issued the request
entry fun revoke_set_revoker<T>(state: &State<T>, req: SetRevokerReq, ctx: &TxContext) {
    check_version(state);
    check_owner(state, ctx);
    let SetRevokerReq { id, .. } = req;
    id.delete();
}

The operator role’s intended remit is mint/burn. The one-word slip leaks owner-only revocation to operators. Role-check helpers are not interchangeable; copy-paste between sibling functions is the most common source of role drift in Move audits.

Single-step UpgradeCap transfer (Medium/Sui)

The same module shipped ownership transfer as a one-step push: the current owner calls request_transfer_ownership and then execute_transfer_ownership, and the UpgradeCap ships to the destination address without that destination ever proving control. A typo, a misconfigured multisig, or a compromised current-owner transaction immediately and irrecoverably ships UpgradeCap (the most consequential capability in any Sui protocol) to an address nobody can sign for.

// VULNERABLE: single-step push (destination never proves control)
entry fun execute_transfer_ownership<T>(
    state: &mut State<T>,
    req: TransferOwnershipReq,
    ctx: &TxContext,
) {
    check_owner(state, ctx);
    let TransferOwnershipReq { id, upgrade_cap, new_owner } = req;
    transfer::public_transfer(upgrade_cap, new_owner); // typo here ⇒ UpgradeCap lost forever
    state.owner = new_owner;
    id.delete();
}

// SAFE: two-step pending/claim (recipient must prove control before takeover)
entry fun request_transfer_ownership<T>(
    state: &mut State<T>,
    new_owner: address,
    upgrade_cap: UpgradeCap,
    ctx: &TxContext,
) {
    check_owner(state, ctx);
    state.pending_owner = option::some(new_owner);
    state.pending_upgrade_cap = option::some(upgrade_cap);
}

entry fun accept_ownership<T>(state: &mut State<T>, ctx: &TxContext) {
    let new_owner = option::extract(&mut state.pending_owner);
    let upgrade_cap = option::extract(&mut state.pending_upgrade_cap);
    assert!(ctx.sender() == new_owner, ENotPendingOwner); // destination signs to claim
    transfer::public_transfer(upgrade_cap, new_owner);
    state.owner = new_owner;
}

Lessons from the pair:

  • A request struct’s revocation path must be gated by the role that issued it, not by a copy-pasted sibling check.
  • Admin-transfer functions are too important for one-step assignment. The destination must prove control before takeover.
  • If your repo has transfer_ownership without a paired accept_ownership, you ship this bug. Single-step admin transfer is the most repeated finding across audit firms in the Move corpus.

Access Control Vulnerability Decision Tree

Use this decision tree when reviewing any Move function that performs a privileged operation:

Decision tree for auditing Move functions that perform privileged operations, branching by capability struct usage and signer validation, with three critical finding paths: capability leak via return value or shared object, missing authorization check, and function open to anyone.
Start at the top for each function under review. Red = finding to report. Green = no issue. Three paths lead to critical findings.

3. Resource Safety & Ownership Vulnerabilities

Move guarantees that resources cannot be silently duplicated or destroyed at the language level. Logical duplication, where the same economic value is credited or accessed twice through a protocol’s logic, is still possible, and resource destruction without proper accounting is common. The MoveScan study found 97,028 defects across 37,302 deployed contracts. Resource-related defects are a significant share of that total.

3.1 Missing Ownership Checks on Object Mutations (Sui)

On Sui, when an object is passed as a mutable reference to a function, the contract must explicitly verify that the caller is the object’s rightful owner. The VM enforces ownership for owned objects at the transaction level, but once a function receives &mut Object, no further ownership check occurs automatically. Any logic that mutates the object proceeds unconditionally unless the developer adds an explicit assert!.

// VULNERABLE: Anyone can mutate any NFT
public entry fun update_metadata(nft: &mut NFT, new_name: String) {
    nft.name = new_name;
}

// SAFE: Explicit owner validation
public entry fun update_metadata(
    caller: &signer,
    nft: &mut NFT,
    new_name: String
) {
    assert!(
        object::owner(nft) == signer::address_of(caller),
        E_NOT_OWNER
    );
    nft.name = new_name;
}

Partial equality check: join merges stakes whose activation_epoch differ (Critical/Sui)

Pattern: a resource-merge function asserts equality on most identity fields but silently drops one that affects accounting. Attacker merges an old high-reward stake with a fresh large-principal stake; the combined principal earns at the older rate.

// Context: Walrus stake reward share derives from activation_epoch.
// `join` correctly checks node_id, withdrawing state, and withdraw_epoch,
// but omits activation_epoch. Merging a small old stake (epoch 10)
// with a fresh large stake (epoch 50) reports activation_epoch = 10 on
// the combined principal, so the entire new principal earns rewards as
// if it had been staked since epoch 10. Move's resource-safety
// guarantees hold throughout (no resource is duplicated, no balance
// disappears). The bug is purely at the protocol level: the equality
// assertion was incomplete.

// VULNERABLE: activation_epoch missing from equality check.
public fun join(sw: &mut StakedWal, other: StakedWal) {
    assert!(sw.node_id == other.node_id, EMetadataMismatch);
    assert!(sw.is_withdrawing() && other.is_withdrawing(), EMetadataMismatch);
    assert!(sw.withdraw_epoch() == other.withdraw_epoch(), EMetadataMismatch);
    // Missing: assert!(sw.activation_epoch == other.activation_epoch, EMetadataMismatch);
    let StakedWal { id, principal, .. } = other;
    sw.principal.join(principal);
    id.delete();
}

// SAFE: every field that affects downstream accounting is in the equality check.
public fun join(sw: &mut StakedWal, other: StakedWal) {
    assert!(sw.node_id == other.node_id, EMetadataMismatch);
    assert!(sw.is_withdrawing() && other.is_withdrawing(), EMetadataMismatch);
    assert!(sw.withdraw_epoch() == other.withdraw_epoch(), EMetadataMismatch);
    assert!(sw.activation_epoch == other.activation_epoch, EMetadataMismatch);
    let StakedWal { id, principal, .. } = other;
    sw.principal.join(principal);
    id.delete();
}

3.2 Hidden Mint/Burn Paths

A hidden mint path exists when an internal function creates tokens without checking supply caps or entitlement. A hidden burn path destroys tokens without updating accounting. Both violate the supply invariant that underpins the protocol’s economic model.

// VULNERABLE: Internal mint callable indirectly through a public function
fun internal_mint(treasury: &mut TreasuryCap<TOKEN>, amount: u64, recipient: address) {
    coin::mint_and_transfer(treasury, amount, recipient);
    // No check against supply cap
}

// Called from:
public entry fun claim_reward(caller: &signer, treasury: &mut TreasuryCap<TOKEN>) {
    // Missing: is this user actually entitled to a reward?
    // Missing: has this user already claimed?
    internal_mint(treasury, REWARD_AMOUNT, signer::address_of(caller));
}

Per-call vs cumulative cap: Vesting cap checked per call, never against total claimed (Critical/Sui)

Pattern: a per-call upper bound looks right, but the bound is the cumulative schedule total. Because tokens_transferred is incremented after the assert, the same upper bound passes on every call, so the schedule has no enforcement.

// Context: `unlocked_tokens` is the *cumulative* amount unlocked by the
// schedule, not the amount remaining for this call. The per-call bound
// `amount <= unlocked_tokens` is therefore vacuous: tokens_transferred
// is incremented *after* the assert, so the user can call
// `claim(amount = unlocked_tokens)` on every transaction and drain the
// vesting position in O(1) calls. The missing line is the cumulative
// bound: `amount + self.tokens_transferred <= unlocked_tokens`.

// VULNERABLE: per-call bound correct, cumulative total never enforced.
public fun claim<T>(
    self: &mut Timelock<T>,
    amount: Option<u64>,
    clock: &Clock,
    ctx: &mut TxContext,
): SplitRequest<T> {
    let unlocked_tokens = self.unlocked_at(clock);
    let amount = amount.destroy_or!(unlocked_tokens);
    assert!(amount <= unlocked_tokens, ENotEnoughBalanceUnlocked);
    // Missing: assert!(amount + self.tokens_transferred <= unlocked_tokens, ...)
    self.tokens_transferred = self.tokens_transferred + amount;
    let coin = self.left_balance.split(amount).into_coin(ctx);
    let (token, request) = shared_token::from_coin(coin, ctx);
    token.share();
    request
}

// SAFE: bound the new amount against the cumulative total already claimed.
public fun claim<T>(
    self: &mut Timelock<T>,
    amount: Option<u64>,
    clock: &Clock,
    ctx: &mut TxContext,
): SplitRequest<T> {
    let unlocked_tokens = self.unlocked_at(clock);
    let amount = amount.destroy_or!(unlocked_tokens - self.tokens_transferred);
    assert!(amount + self.tokens_transferred <= unlocked_tokens, ENotEnoughBalanceUnlocked);
    self.tokens_transferred = self.tokens_transferred + amount;
    let coin = self.left_balance.split(amount).into_coin(ctx);
    let (token, request) = shared_token::from_coin(coin, ctx);
    token.share();
    request
}

3.3 Flashloan Hot-Potato Discipline

The “hot potato” pattern is Move’s mechanism for enforcing flashloan repayment: a receipt struct with no abilities (key, store, copy, drop all absent) is returned from the borrow function and must be consumed by the repay function within the same transaction. If the receipt accidentally has the drop ability, the borrower can discard it and keep the funds. Trail of Bits’ analysis of Sui Move flash loan security confirms this as a language-level guarantee that replaces Solidity’s callback-based approach.

// VULNERABLE: Receipt can be discarded if it accidentally has `drop`
struct FlashLoanReceipt has drop {  // BAD: has drop means it can be discarded
    amount: u64,
}

// SAFE: No abilities means the receipt MUST be consumed
struct FlashLoanReceipt {
    amount: u64,
}

public fun borrow(pool: &mut Pool, amount: u64): (Coin<USDC>, FlashLoanReceipt) {
    // ...
}

public fun repay(pool: &mut Pool, payment: Coin<USDC>, receipt: FlashLoanReceipt) {
    let FlashLoanReceipt { amount } = receipt;  // Destructuring consumes the receipt
    assert!(coin::value(&payment) >= amount, E_INSUFFICIENT_REPAYMENT);
    // ...
}

Double-upscaled repayment: Invariant check passes against inflated balances (Critical/Aptos)

Pattern: a metastable flashloan path upscales balances once when reading them, then upscales the post-flashloan balances again *before the invariant assertion. With factor k > 1, the assertion compares k* (k *b + r) against k* b**, so it passes even when *r = 0.

// Context: ThalaSwap V2 metastable pools normalize balances into a peg
// scale before invariant math. The pre-flashloan `balances` are upscaled
// once. The post-flashloan vector is then built from those already-
// upscaled values, and upscaled a second time. With upscale factor k:
//   single-upscale (correct): asserts k*(b + r) >= k*b, requires r >= 0.
//   double-upscale (bug):     asserts k*(k*b + r) >= k*b, passes at r=0.
// The hot potato is consumed correctly, satisfying Move's resource
// discipline; the economic invariant is violated by the math layered on
// top. Hot-potato consumption is necessary but not sufficient.

// VULNERABLE: post-flashloan balances upscaled a second time.
public fun pay_flashloan(assets: vector<FungibleAsset>, loan: Flashloan) acquires Pool, MetaStablePool {
    let (pool_obj, mut borrow_amounts, repay_amounts, fee_amounts) = consume_loan(loan, &assets);
    let mut balances = pool_balances(pool_obj);
    if (pool_is_metastable(pool_obj)) {
        borrow_amounts = upscale_metastable_amounts(pool_obj, borrow_amounts);
        balances = upscale_metastable_amounts(pool_obj, balances); // first upscale
    };

    let mut balances_after_flashloan = vector::empty<u64>();
    let mut i = 0;
    let len = vector::length(&balances);
    while (i < len) {
        let repay_sub_fees = *vector::borrow(&repay_amounts, i) - *vector::borrow(&fee_amounts, i);
        let after = *vector::borrow(&balances, i) + repay_sub_fees;
        vector::push_back(&mut balances_after_flashloan, after);
        i = i + 1;
    };

    if (pool_is_metastable(pool_obj)) {
        // BUG: vector is already in upscaled space; second upscale inflates the invariant.
        balances_after_flashloan = upscale_metastable_amounts(pool_obj, balances_after_flashloan);
    };
    assert_invariant_preserved(pool_obj, balances_after_flashloan);
    deposit_assets(pool_obj, assets);
}

// SAFE: upscale exactly once (invariant checked in the same scale on both sides).
public fun pay_flashloan(assets: vector<FungibleAsset>, loan: Flashloan) acquires Pool, MetaStablePool {
    let (pool_obj, mut borrow_amounts, repay_amounts, fee_amounts) = consume_loan(loan, &assets);
    let mut balances = pool_balances(pool_obj);
    if (pool_is_metastable(pool_obj)) {
        borrow_amounts = upscale_metastable_amounts(pool_obj, borrow_amounts);
        balances = upscale_metastable_amounts(pool_obj, balances);
    };

    let mut balances_after_flashloan = vector::empty<u64>();
    let mut i = 0;
    let len = vector::length(&balances);
    while (i < len) {
        let repay_sub_fees = *vector::borrow(&repay_amounts, i) - *vector::borrow(&fee_amounts, i);
        let after = *vector::borrow(&balances, i) + repay_sub_fees;
        vector::push_back(&mut balances_after_flashloan, after);
        i = i + 1;
    };
    // Already in upscaled space (no second upscaling).
    assert_invariant_preserved(pool_obj, balances_after_flashloan);
    deposit_assets(pool_obj, assets);
}

Full Checklist

The checklist below groups items by the question each one forces an auditor to answer. No static list covers every trust path in every protocol; treat this as the floor of review, not the ceiling.

Capability creation & transfer

  • [ ] fun init() transfers all capabilities to the intended admin, never returned publicly
  • [ ] No public function returns a capability-typed struct as a return value
  • [ ] Re-initialization of privileged state is guarded (cannot call init twice)
  • [ ] Capability uniqueness invariants hold: TreasuryCaps are not duplicable, re-creatable, or logically forkable
  • [ ] Capability ownership transfers use a 2-step claim pattern (request + accept by recipient), never single-push (per Matrixdock XAUm Zellic 3.1)
  • [ ] ConstructorRef (Aptos) is never stored, returned, emitted in events, or passed to external modules

Signer & ownership validation

  • [ ] Every public entry fun that modifies state validates the signer against an expected owner or role
  • [ ] Functions using signer::address_of() compare the result against stored ownership, not just extract it
  • [ ] cancel_order/withdraw/claim functions verify the caller owns the target order, position, or claim
  • [ ] Admin address changes require the current admin capability (no self-promotion path)
  • [ ] acquires/borrow_global_mut in privileged functions operate only on the caller’s address or an explicitly validated delegation
  • [ ] Delegation records (allowances, permits) are protected against forgery and replay
  • [ ] Multi-role modules: each check_* call selects the role this specific function should require, not a sibling’s role (per Matrixdock XAUm Zellic 3.2)

Module visibility & trust boundaries

  • [ ] friend declarations contain no test modules, debug helpers, or unaudited packages
  • [ ] Each public(friend) function exposes only what the specific friend needs
  • [ ] public(package) entry fun (Sui) is reviewed: the entry modifier defeats the package-scope restriction
  • [ ] Internal cross-module helpers (receive_*, notify_*, accrue_*) are gated by friend/public(package), not bare public (per Cetus Zellic 3.1)
  • [ ] No test-only code, bypass flags, or debug entry points remain in the deployed package
  • [ ] Wrapper functions do not re-expose capabilities via alternate return paths

Shared-object exposure (Sui)

  • [ ] No shared object contains a capability-typed field, directly or through nested structs
  • [ ] No capability is stored in a dynamic field of a shared object
  • [ ] Every &mut of a shared object validates the caller against an embedded allowlist, role, or admin ID before mutation
  • [ ] Object IDs for admin capabilities are not leaked in events or returned handles
  • [ ] After upgrades that add fields to shared objects, new fields are reviewed for capability exposure
  • [ ] Generic request/struct types passed alongside State<T> are bound to the same T (phantom type or runtime type-name field)
  • [ ] Sui-specific: any Object<T> that should be shared is actually published via transfer::share_object, not silently returned by value (per Magma Finance Zellic 3.1)

Resource mutation & ownership

  • [ ] All functions accepting &mut Object or Object validate caller ownership (Sui)
  • [ ] Ownership checks precede all state mutation, token transfers, and external calls
  • [ ] Batch functions re-check ownership for each item, never assume from the first
  • [ ] External calls with mutable references preserve invariants, and those invariants are re-validated after the call returns
  • [ ] After Move 2.x function-value callbacks, captured state and resource invariants are re-checked

Aptos chain-specific

Supply & resource accounting

  • [ ] Mint functions are gated against supply caps and double-minting
  • [ ] Burn functions update total supply, user balances, and reward state consistently
  • [ ] total_supply == sum(balances) holds on every mint / burn path, including admin and emergency functions
  • [ ] Vesting, locked, and escrowed balances are counted consistently across all read paths
  • [ ] Cross-chain supply is reconciled: no double-mint across bridged and native representations

Flashloans & hot potato

  • [ ] Flashloan receipts have no abilities (no drop, no copy, no store, no key)
  • [ ] Receipts are consumed via pattern-match destructuring in the repay function
  • [ ] Repayment amount checks are correct and non-bypassable, including fee and interest accrual
  • [ ] When the repayment passes through any scaling/normalization step (oracle upscaling, fixed-point conversion), the invariant check is against values in the same scale, with no double-upscaling (per ThalaSwap V2 OS-THL-ADV-00)
  • [ ] The flashloan amount upper bound uses < (not <=) the pool balance, so repay-time invariants do not see a zero-balance edge case mid-loan

Audit process

  • [ ] All deployed modules are in audit scope, including oracles, periphery, migration helpers, and anything touched after the audit
  • [ ] Custom oracle modules, upgrade hooks, and admin functions receive priority review
  • [ ] On-chain monitoring is configured before deployment, not after an incident
  • [ ] Every claim of “audited” points to a specific commit hash and scope document

Conclusion

Move’s language guarantees solve one half of the security problem. The other half, who is allowed to do what, sits entirely with the developer. Every exploit pattern covered in this article reduces to the same mistake: a missing check on a capability, a signer, or an object’s owner. Typus lost $3.44M to exactly that. Zellic’s Top 10 is dominated by variations of it. MVD v3.0 and the MoveScan corpus confirm the pattern holds across tens of thousands of deployed contracts.

The checklist above is scaffolding for review, not a substitute for it. Capability boundaries are protocol-specific, and no static list catches every trust path in every design. Use it as the opening shape of the questions you ask about each privileged function, not as the whole answer.

Up Next: Arithmetic, Precision and Economic Attacks

In Part 3, we examine the vulnerability class that enabled the $223M Cetus exploit: arithmetic precision failures, overflow, multiplication-division truncation, dust accumulation attacks, and the generics type-checking failures that let attackers swap token types mid-transaction.

Share article:

Read more on HackenProof Blog