Aptos Smart Contract Security: Move VM, Resources, and Ownership Model

Smart contract security on Aptos is fundamentally different from Ethereum. The Move language was designed from first principles around asset safety, and its type system encodes ownership invariants that the EVM can only approximate at runtime. Understanding where Move’s guarantees end — and where developer responsibility begins — is the prerequisite for every serious Aptos security review.


The Move Language and Resource Model

Move was originally developed at Meta (then Facebook) for the Diem blockchain, with the explicit goal of making digital assets first-class citizens of the language itself. On Aptos, Move is compiled to bytecode that runs on the Move VM, a stack-based virtual machine that enforces resource semantics at every instruction boundary.

The central abstraction is the resource. A resource in Move is a struct annotated with the key or store ability — or both — and crucially, it cannot be implicitly copied or discarded. This is not a convention enforced by a linter; it is a bytecode-level invariant enforced by the Move bytecode verifier before any code ever executes.

module vault::coin_vault {
    use std::signer;

    /// A resource representing a balance of some token.
    /// Because it has `key`, it can live in global storage under an address.
    /// Because it lacks `copy` and `drop`, it cannot be duplicated or silently discarded.
    struct VaultBalance has key {
        amount: u64,
    }

    public fun initialize(account: &signer) {
        let addr = signer::address_of(account);
        assert!(!exists<VaultBalance>(addr), 1);
        move_to(account, VaultBalance { amount: 0 });
    }

    public fun deposit(account: &signer, value: u64) acquires VaultBalance {
        let addr = signer::address_of(account);
        let balance = borrow_global_mut<VaultBalance>(addr);
        balance.amount = balance.amount + value;
    }

    public fun withdraw(account: &signer, value: u64) acquires VaultBalance {
        let addr = signer::address_of(account);
        let balance = borrow_global_mut<VaultBalance>(addr);
        assert!(balance.amount >= value, 2);
        balance.amount = balance.amount - value;
    }
}

The struct VaultBalance has neither the copy ability nor the drop ability. The Move bytecode verifier will reject any module that tries to copy a value of this type into two binding sites, or that lets a local variable of this type go out of scope without being consumed. The compiler enforces this at the bytecode level, not merely as a surface-language lint.


Linear Types and the Double-Spend Problem

The EVM has no native concept of uniqueness. An ERC-20 balance is an integer in a mapping. Nothing in the language prevents a buggy contract from crediting the same balance twice or from creating tokens from nothing — the prevention relies entirely on correct arithmetic in the application logic.

Move’s linear type system changes this calculus. A value of a resource type follows linear logic: it must be used exactly once. You cannot copy it (unless the type has copy) and you cannot drop it (unless the type has drop). This single constraint eliminates entire families of vulnerabilities at the language level.

What “Exactly Once” Means in Practice

Consider a Coin<T> type in Move:

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

    /// Split a coin into two. The original is consumed; two new coins are produced.
    public fun split<CoinType>(coin: Coin<CoinType>, amount: u64): (Coin<CoinType>, Coin<CoinType>) {
        assert!(coin.value >= amount, error::invalid_argument(EINSUFFICIENT_BALANCE));
        let remainder = coin.value - amount;
        // `coin` is destructured here — it no longer exists.
        let Coin { value: _ } = coin;
        (Coin { value: amount }, Coin { value: remainder })
    }
}

The split function destructures coin and produces two new coins whose values sum to the original. The original Coin is consumed — the bytecode verifier guarantees that the old binding is dead after destructuring. There is no code path that could keep the original alive and also return the two new coins. A double-spend at this level is not a runtime check; it is a type error.

Compare this to an EVM scenario where the equivalent would be a function that takes a uint256 balance by value, does arithmetic on it, and returns two values. Nothing in Solidity prevents the function from returning an inflated sum; the programmer must write the assertion. Move’s type system writes it for you.

The Bytecode Verifier’s Role

The Move bytecode verifier runs before any module is published on-chain. It performs:

  1. Type safety checking — ensuring all operations are well-typed.
  2. Resource safety checking — ensuring every resource value is consumed exactly once on every possible execution path, including aborted paths handled by abort and assert!.
  3. Reference safety checking — ensuring no dangling references exist and that mutable and immutable borrows do not alias.

This verification is sound: a module that passes the verifier is guaranteed not to violate resource safety. The verifier does not need to be trusted at runtime because it is run once at publish time and the result is permanent.


Module and Resource Ownership Model

In Move, global state is organized as a resource store indexed by (address, module, struct type). Resources live under addresses, not inside contract accounts. A VaultBalance resource published by vault::coin_vault lives at user_address::vault::coin_vault::VaultBalance. This is architecturally different from EVM, where all state lives inside the contract’s storage trie.

Ownership Implications

Only the defining module can create, destroy, or unpack a resource of a given type (unless it exposes functions to do so). This is Move’s module boundary rule: the struct type vault::coin_vault::VaultBalance can only be unpacked by code in the vault::coin_vault module. Any attempt to unpack it from another module is a compile error.

module attacker::exploit {
    use vault::coin_vault::VaultBalance;

    // This will NOT compile. VaultBalance has no `drop` ability,
    // and its fields are private to vault::coin_vault.
    // The attacker cannot unpack or discard another account's balance.
    public fun steal(victim_balance: VaultBalance) {
        // error: cannot unpack type `vault::coin_vault::VaultBalance`
        // outside its defining module
        let VaultBalance { amount: _ } = victim_balance; // COMPILE ERROR
    }
}

This module boundary provides structural confidentiality. The internal fields of a struct are private to its module unless explicitly exposed through public accessor functions. No amount of clever bytecode engineering can bypass this, because the bytecode verifier enforces it.

Key vs. Store vs. Copy vs. Drop

Move has four abilities that govern how values can be used:

AbilityMeaning
keyThe type can be used as a top-level resource in global storage (via move_to, borrow_global, move_from).
storeThe type can be stored inside other structs that have key or store.
copyThe type can be copied. Primitive types like u64 and bool have this.
dropThe type can be silently discarded when it goes out of scope.

Resources that model assets should have neither copy nor drop. Having copy would allow duplication (inflation). Having drop would allow silent destruction (burning without authorization).


Signer-Based Access Control

Move has no msg.sender global variable. Instead, privileged operations receive a &signer reference as an argument. The signer is a built-in type whose value can only be produced by the Move VM itself — at transaction entry points — or by specific framework functions. No Move code can forge a signer.

module access::protected_vault {
    use std::signer;
    use aptos_framework::coin::{Self, Coin};
    use aptos_framework::aptos_coin::AptosCoin;

    struct AdminCap has key {
        treasury_address: address,
    }

    /// Only the account that holds AdminCap can call this function.
    public entry fun withdraw_treasury(
        admin: &signer,
        amount: u64
    ) acquires AdminCap {
        let admin_addr = signer::address_of(admin);
        // This will abort if the signer does not have AdminCap.
        let cap = borrow_global<AdminCap>(admin_addr);
        // ... proceed with treasury withdrawal
        let _ = cap; // suppress unused warning in example
    }
}

The critical security property: the Move VM guarantees that a &signer passed to an entry function corresponds to the account that signed the transaction. There is no equivalent of Solidity’s tx.origin / msg.sender confusion. You cannot pass a fabricated signer through a series of internal calls because internal functions receive &signer by reference — they cannot store it or pass it to a different top-level context.

SignerCapability — Delegated Authority

The Aptos framework provides SignerCapability, a resource that allows a module to obtain a signer for a specific address without requiring that address to co-sign every transaction. This is a deliberate, powerful escalation of privilege:

module escrow::manager {
    use aptos_framework::account;
    use std::signer;

    struct EscrowCapability has key {
        cap: account::SignerCapability,
    }

    public fun create_escrow_account(
        creator: &signer,
        seed: vector<u8>
    ) {
        let (escrow_signer, cap) = account::create_resource_account(creator, seed);
        let escrow_addr = signer::address_of(&escrow_signer);
        // Store the capability under the creator's address.
        move_to(creator, EscrowCapability { cap });
        // escrow_signer is consumed here. Only the capability remains.
        let _ = escrow_addr;
    }

    /// Uses the stored capability to act on behalf of the escrow account.
    public fun release_funds(
        manager: &signer,
        recipient: address
    ) acquires EscrowCapability {
        let manager_addr = signer::address_of(manager);
        let cap_holder = borrow_global<EscrowCapability>(manager_addr);
        // Obtain a signer for the resource account WITHOUT a transaction signature.
        let escrow_signer = account::create_signer_with_capability(&cap_holder.cap);
        // ... transfer assets from escrow using escrow_signer
        let _ = escrow_signer;
    }
}

SignerCapability is powerful and must be protected like a private key. It is the primary target for capability-abuse attacks, discussed below.


Common Vulnerability Patterns in Move Contracts

Because Move eliminates entire classes of EVM bugs, auditors must shift their mental model. The vulnerabilities that survive Move’s type system are different in kind, not just in form.

1. Resource Leaks

A resource without drop must be consumed on every execution path. However, “consuming” a resource by storing it in an unreachable location is semantically equivalent to a leak. If a module initializes a resource and then the only function that destroys it has a logic error that prevents it from being called, assets can be permanently locked.

module leaky::vault {
    use std::signer;

    struct LockedFunds has key {
        amount: u64,
        unlock_time: u64,    // Compared against block timestamp
        owner: address,
    }

    public fun lock(account: &signer, amount: u64, unlock_time: u64) {
        move_to(account, LockedFunds {
            amount,
            unlock_time,
            owner: signer::address_of(account),
        });
    }

    /// BUG: If `unlock_time` is set incorrectly (e.g., far in the future),
    /// or if the timestamp oracle is manipulated, funds are permanently locked.
    public fun unlock(account: &signer) acquires LockedFunds {
        let addr = signer::address_of(account);
        let funds = borrow_global<LockedFunds>(addr);
        // If this assert fires every time, funds can never be retrieved.
        assert!(aptos_framework::timestamp::now_seconds() >= funds.unlock_time, 1);
        let LockedFunds { amount: _, unlock_time: _, owner: _ } = move_from<LockedFunds>(addr);
    }
}

The type system guarantees the resource is not accidentally discarded. It does not guarantee there exists a valid execution path to destroy it. Resource lock analysis — verifying that every resource type has a reachable destruction path under all realistic conditions — is a distinct audit step with no EVM equivalent.

2. Phantom Type Confusion

Move allows phantom type parameters — generic type parameters that appear in a struct’s definition but are not stored as values. They are used as compile-time tags to distinguish otherwise identical structs. When phantom types are used as security boundaries, confusion between types becomes a vulnerability class.

module tokens::multi_token {
    /// A generic balance. The phantom type T distinguishes token kinds.
    struct Balance<phantom T> has key {
        amount: u64,
    }

    struct TokenA {}
    struct TokenB {}

    public fun get_balance_a(addr: address): u64 acquires Balance<TokenA> {
        borrow_global<Balance<TokenA>>(addr).amount
    }

    /// VULNERABLE: If access control is based only on the amount field
    /// and not on the phantom type, a function that accepts Balance<T>
    /// for any T could be called with a worthless token to pass a
    /// value check meant for a valuable token.
    public fun requires_valuable_token<T>(balance: &Balance<T>, threshold: u64) {
        // This check does not care about T!
        // Balance<TokenB> (worthless) satisfies the same check as Balance<TokenA>.
        assert!(balance.amount >= threshold, 1);
        // Proceed with privileged operation...
    }
}

The phantom type is erased at runtime — Balance<TokenA> and Balance<TokenB> have identical in-memory representations. Any function that accepts Balance<T> for an unconstrained T and makes security decisions based only on the amount field is vulnerable to being called with an unintended type. Auditors must verify that phantom type parameters are always constrained at security-sensitive call sites.

3. Signer Capability Abuse

SignerCapability grants the ability to produce a signer for an address without a transaction signature. If a capability is stored in a publicly accessible resource, or if the module that holds it exposes insufficiently guarded functions to use it, an attacker can hijack the capability to act on behalf of the victim address.

module dangerous::shared_cap {
    use aptos_framework::account;

    struct SharedCapability has key {
        cap: account::SignerCapability,
    }

    // BAD: This resource is stored under a well-known address with no access control.
    // Anyone can call use_capability to obtain a signer for the shared account.
    public fun use_capability(requester: &signer): signer acquires SharedCapability {
        // No check on `requester`! Any signer can call this.
        let holder = borrow_global<SharedCapability>(@shared_account);
        account::create_signer_with_capability(&holder.cap)
    }
}

The correct pattern is to gate capability use behind a resource the caller must own:

module safe::gated_cap {
    use aptos_framework::account;
    use std::signer;

    struct ManagerRole has key {}
    struct ManagedCapability has key {
        cap: account::SignerCapability,
    }

    public fun use_capability(manager: &signer): signer acquires ManagerRole, ManagedCapability {
        let addr = signer::address_of(manager);
        // Only addresses that hold ManagerRole can proceed.
        assert!(exists<ManagerRole>(addr), 403);
        let holder = borrow_global<ManagedCapability>(@protocol_account);
        account::create_signer_with_capability(&holder.cap)
    }
}

4. Entry Function Access Control

Move distinguishes between public functions (callable from any module), public(friend) functions (callable from declared friend modules), and entry functions (callable as transaction entry points from off-chain). A critical mistake is marking a function public entry when it should only be callable under specific conditions.

module admin::protocol {
    use std::signer;

    struct ProtocolConfig has key {
        fee_bps: u64,
        paused: bool,
    }

    /// BAD: Any account can pause the protocol. There is no admin check.
    public entry fun pause_protocol(caller: &signer) acquires ProtocolConfig {
        let config = borrow_global_mut<ProtocolConfig>(@protocol_address);
        config.paused = true;
        // `caller` is never checked!
        let _ = caller;
    }

    /// CORRECT: Only the address holding AdminCap can pause.
    struct AdminCap has key {}

    public entry fun pause_protocol_safe(caller: &signer) acquires ProtocolConfig, AdminCap {
        let caller_addr = signer::address_of(caller);
        assert!(exists<AdminCap>(caller_addr), 403);
        let config = borrow_global_mut<ProtocolConfig>(@protocol_address);
        config.paused = true;
    }
}

Entry function access control bugs are among the most common findings in Move audits. The entry keyword makes a function directly invocable from any transaction, making every public entry function an attack surface that must be individually justified.

5. Reentrancy in Move — A Different Shape

Move does not have the EVM’s call-return model where a called contract can re-enter the caller mid-execution. However, Aptos supports Move scripts and multi-agent transactions that can invoke multiple modules in a single transaction. The reentrancy risk in Move is not the classic EVM re-entry but rather cross-module state inconsistency: module A modifies state, calls a function in module B, which reads or modifies state that module A has not yet finalized.

module defi::swap {
    use std::signer;

    struct Pool has key {
        reserve_a: u64,
        reserve_b: u64,
    }

    public fun swap(
        user: &signer,
        amount_in: u64,
    ) acquires Pool {
        let pool = borrow_global_mut<Pool>(@pool_address);
        // State is partially updated here.
        pool.reserve_a = pool.reserve_a + amount_in;
        // If an external callback is invoked here (e.g., via a hook),
        // and that callback reads reserve_b before it is updated,
        // it sees an inconsistent pool state.
        let amount_out = calculate_out(pool.reserve_a, pool.reserve_b, amount_in);
        pool.reserve_b = pool.reserve_b - amount_out;
        // transfer amount_out to user...
        let _ = user;
    }

    fun calculate_out(reserve_a: u64, reserve_b: u64, amount_in: u64): u64 {
        // Constant-product formula
        (amount_in * reserve_b) / (reserve_a + amount_in)
    }
}

The mitigation is the same check-effects-interactions pattern from EVM development: complete all state updates before invoking any external module.


Auditing Move Contracts vs. EVM Contracts

An EVM auditor approaching a Move codebase must unlearn several reflexes and acquire new ones.

What You Stop Looking For

  • Integer overflow — Move’s arithmetic aborts on overflow by default. There is no unchecked block. The only arithmetic vulnerability is logical (wrong formula), not representational.
  • Reentrancy via .call() — Move has no low-level call primitive. You cannot call an arbitrary address at runtime. All inter-module communication is statically resolved at compile time.
  • tx.origin phishing — Move has no tx.origin. The only way to obtain a signer is through the VM entry point or through framework-provided capability functions.
  • Storage collision — Move’s typed global storage is indexed by (address, type). There are no raw storage slots and no proxy storage layout collisions.
  • ABI encoding bugs — Move functions are called with typed arguments, not ABI-encoded calldata. There is no equivalent of calldata manipulation or function selector collision.

What You Start Looking For

EVM FocusMove Focus
Reentrancy via external callsCross-module state inconsistency
Integer overflow/underflowResource leak (no destruction path)
Access control via msg.senderSigner capability storage and delegation
Proxy storage layoutPhantom type tag correctness
Selfdestruct / delegatecallSignerCapability lifecycle
ERC-20 approval front-runningentry function exposure audit

Structural Differences in the Audit Process

Module boundary analysis replaces interface analysis. In Solidity, the primary attack surface is the ABI — the set of callable functions. In Move, the attack surface also includes the set of resource types that can be obtained from the module, because resource values are passed between modules by value. An auditor must trace every path through which a privileged resource type (a capability, a governance token, a vesting schedule) can be obtained, passed, stored, and destroyed.

Ability audit is unique to Move. For every struct, the auditor must ask: does this type have the correct ability set? Should it have copy? Almost certainly not if it represents an asset. Should it have drop? Only if it is explicitly designed to be discardable. Incorrect ability annotations are architectural vulnerabilities that the type system will happily enforce — in the wrong direction.

acquires annotation correctness is a Move-specific control flow check. Every function that accesses global storage must declare the types it acquires. If a function’s acquires annotation is incorrect or missing, the compiler will catch it — but the presence of an acquires annotation is a signal to the auditor that the function modifies global state and must be analyzed for all callers.

Upgrade authority on Aptos is controlled by the upgrade_policy set at module publication. The policies range from immutable (no upgrades possible) to compatible (upgrades allowed within ABI compatibility constraints) to arbitrary (any bytecode change allowed). An arbitrary upgrade policy is effectively a god-mode backdoor. The auditor must confirm that production modules use an upgrade policy appropriate for the protocol’s trust model.

// When publishing a module, the upgrade policy is set in the Move.toml
// or via the aptos CLI. Auditors should verify:
// - Core financial logic uses `immutable` or `compatible`
// - No module with `arbitrary` policy has authority over protocol assets
// - Upgrade capability is guarded by multi-sig, not a single EOA

Formal Verification with the Move Prover

Move ships with a specification language and an integrated formal verifier, the Move Prover, that can prove functional correctness properties about Move code. This is not available on the EVM without third-party tools. Auditors should check whether the codebase includes Prover specs and whether those specs are meaningful.

module verified::balance {
    struct Balance has key {
        amount: u64,
    }

    spec module {
        /// The total supply is conserved across all transfers.
        invariant forall addr: address where exists<Balance>(addr):
            global<Balance>(addr).amount >= 0;
    }

    public fun transfer(
        from: &signer,
        to: address,
        amount: u64
    ) acquires Balance {
        use std::signer;
        let from_addr = signer::address_of(from);
        let from_balance = borrow_global_mut<Balance>(from_addr);
        assert!(from_balance.amount >= amount, 1);
        from_balance.amount = from_balance.amount - amount;
        let to_balance = borrow_global_mut<Balance>(to);
        to_balance.amount = to_balance.amount + amount;
    }

    spec transfer {
        /// Post-condition: balances sum to the same value before and after.
        ensures global<Balance>(signer::address_of(from)).amount ==
            old(global<Balance>(signer::address_of(from)).amount) - amount;
        ensures global<Balance>(to).amount ==
            old(global<Balance>(to).amount) + amount;
        aborts_if global<Balance>(signer::address_of(from)).amount < amount;
    }
}

The presence of Move Prover specs does not eliminate the need for manual review, but it significantly raises the assurance level for the properties that are specified. The absence of specs in a production financial protocol is itself a finding.


Aptos Security Checklist

Use this checklist as a structured walkthrough for every Move module under review. It does not replace deep analysis but ensures no category of risk is skipped.

Resource and Type Safety

  • Every struct that represents an asset or privilege has neither copy nor drop.
  • Every struct that represents a transferable capability has store but not copy.
  • Phantom type parameters used as security tags are constrained at all security-sensitive call sites.
  • Every resource type has at least one reachable destruction path (via move_from + struct destructuring) under all realistic conditions.
  • No resource is stored in a location from which it cannot be retrieved (e.g., an address with no controlling key).

Access Control

  • Every public entry function either (a) accepts any signer and has no privileged effect, or (b) explicitly asserts that the signer holds a required capability resource.
  • Admin and governance functions check for a capability resource (has key) rather than comparing against a hardcoded address literal.
  • SignerCapability resources are stored under an address controlled by a multi-sig or governance mechanism, not a single EOA.
  • Functions that use SignerCapability to produce a signer verify caller authorization before calling create_signer_with_capability.
  • No public(friend) declaration creates an unintended trust relationship. All friend modules are reviewed for their ability to call privileged functions.

Module Boundaries and Upgrade Policy

  • The upgrade policy (immutable, compatible, or arbitrary) for each module is appropriate for its role in the protocol.
  • No module with arbitrary upgrade policy has custody of user assets or protocol capabilities.
  • If the protocol uses a multi-module architecture, the trust model between modules is explicit and documented. Friend declarations are minimal.
  • Internal struct fields are not exposed through public accessor functions unless there is a deliberate reason to do so.

Arithmetic and Logic

  • All arithmetic that could overflow in context (even with Move’s default abort-on-overflow) is analyzed for the economic impact of an abort rather than silent overflow.
  • Division operations check for zero denominators before dividing.
  • Multiplication before division (rather than division before multiplication) is used to avoid precision loss in fixed-point arithmetic.
  • Off-by-one errors in comparisons (>= vs >) are verified, especially in time-lock and vesting logic.

State Consistency

  • Functions that modify multiple interdependent resources complete all reads, then all updates, before invoking any external module functions (check-effects-interactions).
  • The acquires annotation on every function is correct and complete. Functions that call other acquires-annotated functions transitively acquire those types.
  • Global invariants (total supply conservation, monotonicity of time-locks, etc.) hold after every public function.

Initialization and Lifecycle

  • Every resource type has a guarded initialization path that prevents double-initialization (checked with exists<T>(addr)).
  • Protocol-wide singleton resources (config, treasury, admin cap) are initialized exactly once, in the module’s init_module function or via a one-shot initializer.
  • Destroy/deactivation paths for protocol resources are present, access-controlled, and tested.

Move Prover and Testing

  • Move Prover specifications exist for all invariants that can be expressed as postconditions, preconditions, or global invariants.
  • Prover runs clean with no unverified conditions.
  • Unit tests cover all abort conditions (incorrect signer, insufficient balance, double-init, etc.).
  • Integration tests cover multi-module interactions that could produce cross-module state inconsistency.

Oracle and External Dependencies

  • Price oracles and timestamp reads are identified and their manipulation surface is analyzed (TWAP vs. spot, minimum staleness, etc.).
  • The protocol correctly handles the case where an oracle read aborts (e.g., due to staleness), rather than using a stale default value.
  • External module dependencies (framework modules, third-party libraries) are pinned to reviewed versions.

Move’s security model is a genuine improvement over the EVM for asset-centric applications. Its guarantees are real and meaningful: you will not find classic double-spend bugs, storage layout collisions, or tx.origin phishing in a well-formed Move codebase. But Move does not make smart contracts secure by default — it shifts the attack surface. The vulnerabilities that remain are architectural: how capabilities are stored and delegated, how phantom types are used as security tags, how entry functions are guarded, and how resources are initialized and destroyed. An auditor who approaches Move with these categories in mind, rather than mechanically applying an EVM checklist, will find the real risks.