Aptos Move Smart Contract Security: How Object Refs Leak and Global Storage Breaks

Introduction
This part of the Move Smart Contract Security Guide series covers chain-specific security pitfalls for Aptos (account-based global storage with Block-STM parallel execution) and Supra (a MoveVM-based L1 with native oracles, verifiable randomness, automation, and batching). It is the account-model counterpart to the object-model deep dive in the companion Sui article.
Where Sui isolates state inside owned and shared objects, Aptos keeps state in typed resources stored under account addresses and reads or writes them with borrow_global and borrow_global_mut. That single difference reshapes the entire attack surface. Most Aptos-only bugs live at the boundary between global storage access patterns, the Aptos Object model, framework standards (Coin, Fungible Asset, randomness), and the concurrency assumptions introduced by Block-STM.
The Aptos storage and execution model
The diagram below traces a single transaction from the moment it is authorized to the moment its effects land in storage. Almost every Aptos-specific bug hides somewhere along that path, so it is worth walking it from top to bottom.
flowchart TD
A["Transaction (signer)"] --> B["Account address"]
B --> C["Resources under account<br>borrow_global / borrow_global_mut"]
B --> D["Objects (ObjectCore)<br>ExtendRef / TransferRef / DeleteRef"]
C --> E["Global storage"]
D --> E
A --> F["Block-STM<br>optimistic parallel execution"]
F --> G["Conflict detection<br>and re-execution at commit"]
G --> E
style C fill:#1565C0,color:#fff,stroke:#0D47A1
style D fill:#1565C0,color:#fff,stroke:#0D47A1
style F fill:#EF6C00,color:#fff,stroke:#E65100
style E fill:#2E7D32,color:#fff,stroke:#1B5E20Everything starts with a signer. A transaction carries the authority of one or more accounts, and for most entry functions the signer's address is the natural answer to "who is allowed to do this." From there, the transaction resolves to an account address, and this is where Aptos diverges from contract-centric chains: state is anchored under addresses rather than living inside a contract. An address is effectively the namespace a resource sits in, so the first question to ask of any handler is whether the address it operates on was proven to belong to the signer or simply passed in as an argument.
From the address, the flow forks into the two ways code can touch state. The left branch is the classic storage path, where resources are read and written with borrow_global and borrow_global_mut: the former for reads, the latter for writes. This is where most day-to-day logic happens, and also where the most common critical bug lives: when addr is caller-controlled and unchecked, the same call that appears to edit your own vault can just as easily edit someone else's.
The right branch is the object model. Objects still end up in global storage, but you no longer reach them by naming an address; you hold a capability reference that grants a specific power over the object. A TransferRef can move it, an ExtendRef can attach new state to it, and a DeleteRef can destroy it. Because these refs are the authority, leaking one of these (or leaking a ConstructorRef that can mint the others) is the Aptos equivalent of handing out an admin key.
Both branches converge on the same global storage. That convergence is the point: "a resource under an address" and "an object" are just two different naming and authorization schemes layered over one shared pool of persistent state.
Running alongside all of this is Block-STM, the execution engine. Instead of processing transactions strictly one after another, it runs many of them optimistically in parallel, watches for read/write conflicts, and re-executes whatever clashed before committing. The committed result is always deterministic and equivalent to some serial order, so the engine itself is not the risk. The risk is in application code: any "check, then act" sequence that quietly assumes nothing else could touch the same state in between has to enforce that assumption with real state (a per-user flag, a lock, an epoch counter) rather than trusting execution to be serial.
Read the arrows together, and a simple audit lens falls out. The path through borrow_global* is the address-based write path, the path through objects is the capability path, and the Block-STM branch wraps around both. Most high-severity Aptos findings map cleanly onto one of three failures: the wrong target address, a leaked capability, or an exclusivity assumption that spans more than one transaction.
1. Aptos-Specific Attack Surface
Aptos runs the same Move language as Sui, but it stores and executes that code differently enough that the two bug profiles barely overlap. The findings below start with the most fundamental storage-access mistakes and work outward to framework standards and type-system pitfalls. Code samples are simplified to isolate each bug: helper functions and error constants are illustrative, not framework APIs.
1.1 Unchecked borrow_global_mut Target (Critical/Aptos)
borrow_global_mut<T>(addr) produces a mutable reference to resource T under addr. If addr is caller-controlled and unvalidated, the caller can mutate resources belonging to arbitrary accounts. Aptos calls this out explicitly under global storage access control.
// Context: an entry function that mutates a Vault chosen by the caller.
// VULNERABLE: addr is caller-supplied and unchecked
public entry fun update_vault(caller: &signer, addr: address, amount: u64) acquires Vault {
let vault = borrow_global_mut<Vault>(addr);
vault.balance = vault.balance - amount;
}
// SAFE: derive the target from the signer, never accept an arbitrary addr
public entry fun update_vault(caller: &signer, amount: u64) acquires Vault {
let vault = borrow_global_mut<Vault>(signer::address_of(caller));
vault.balance = vault.balance - amount;
}For every borrow_global_mut<T>(addr), confirm addr is derived from signer::address_of(...) or otherwise proven correct. If the design intends "operator modifies user state", require explicit authorization (a capability, allowlist, or signature scheme).
1.2 &mut Resources Across Module Boundaries (High/Aptos)
Passing &mut Resource to another module is narrower than it looks, but still dangerous. Field access is private to the defining module, so the callee cannot write vault.balance directly. What it can do is overwrite the whole value with std::mem::swap or std::mem::replace (given any other instance of the type it can obtain through your public API), and pass the reference back into any of your public functions that take &mut Vault, which often skip checks because they assume a trusted caller. This is not reentrancy in the EVM sense, but it is still an invariant escape hatch.
// Context: handing our vault to an external processor.
// VULNERABLE: the external module can mem::swap the vault or feed it to our public mutators
public entry fun process(caller: &signer) acquires Vault {
let vault = borrow_global_mut<Vault>(signer::address_of(caller));
external_processor::process(vault);
}
// SAFE: pass copies of primitive data, re-validate invariants after the call
public entry fun process(caller: &signer) acquires Vault {
let addr = signer::address_of(caller);
let amount = borrow_global<Vault>(addr).pending;
let result = external_processor::compute(amount);
let vault = borrow_global_mut<Vault>(addr);
assert!(result <= vault.balance, E_INVARIANT);
vault.balance = vault.balance - result;
}Treat &mut arguments across module boundaries as a trust boundary, prefer passing amounts and addresses over mutable references to core state, and audit every public function that accepts &mut to your core types as if an attacker holds the reference.
1.3 Resource Groups: Shared Invariants and Write Contention (Medium to High/Aptos)
Resource groups store several resources in one storage slot under a single address. That is a storage optimization, but it has two audit consequences. First, grouped members usually share invariants, and those invariants are easy to break because each function reads as if it touches only one resource. Second, a write to any member rewrites the whole group, so transactions that touch different members still conflict under Block-STM and serialize: grouping frequently written state with state that many transactions read turns the group into a contention hotspot.
In the simplified example below, two group members share an implicit invariant (the treasury may never reserve more than is staked), but the function that breaks it reads as if it only touches one resource:
// Context: a hypothetical staking protocol; two resources in one group
// share an implicit invariant: Treasury.reserved <= Staking.total_staked.
#[resource_group(scope = global)]
struct ProtocolGroup {}
#[resource_group_member(group = ProtocolGroup)]
struct Staking has key { total_staked: u64 }
#[resource_group_member(group = ProtocolGroup)]
struct Treasury has key { reserved: u64 }
// VULNERABLE: reads like "only touches Staking", but silently breaks
// the shared invariant with Treasury (reserved may now exceed total_staked)
public entry fun emergency_unstake(admin: &signer, amount: u64) acquires Staking {
assert!(signer::address_of(admin) == @admin, E_NOT_ADMIN);
let staking = borrow_global_mut<Staking>(@protocol);
staking.total_staked = staking.total_staked - amount;
// Treasury.reserved is untouched: the group is now insolvent on paper
}
// SAFE: re-assert the cross-resource invariant whenever any member changes
public entry fun emergency_unstake(admin: &signer, amount: u64) acquires Staking, Treasury {
assert!(signer::address_of(admin) == @admin, E_NOT_ADMIN);
let staking = borrow_global_mut<Staking>(@protocol);
staking.total_staked = staking.total_staked - amount;
let treasury = borrow_global<Treasury>(@protocol);
assert!(treasury.reserved <= staking.total_staked, E_INVARIANT_BROKEN);
}Enumerate all resources in a group, document their shared invariants, and verify that admin or upgrade pathways cannot indirectly mutate resources that are assumed isolated. Also check that hot, frequently written state is not grouped with state most transactions only read.
1.4 Block-STM and Multi-Transaction Check-Then-Act (Medium to High/Aptos)
Aptos executes transactions optimistically in parallel with Block-STM and resolves conflicts at commit time (Block-STM paper). When two transactions in a block touch the same state, Block-STM detects the read/write conflict and re-executes the later one against the earlier one's writes, so the committed result is always equivalent to the block's preset serial order. Two concurrent transactions that each "check there is no loan, then open one" therefore cannot both succeed: the second is re-executed, sees the first loan, and aborts. Parallel execution does not create races inside a single transaction or between two conflicting ones.
What Block-STM cannot protect is an invariant that spans several transactions. Each transaction is serializable on its own, but nothing makes a check in one transaction still true when a later transaction acts on it. The bug appears when a protocol validates state the user can still change, and the action that depends on that check happens in a different transaction.
// Context: governance voting weighted by the voter's current token balance.
// VULNERABLE: weight comes from a balance the voter can move between transactions
public entry fun vote(voter: &signer, proposal_id: u64, support: bool) acquires Proposals {
let addr = signer::address_of(voter);
let p = table::borrow_mut(&mut borrow_global_mut<Proposals>(@gov).items, proposal_id);
assert!(!table::contains(&p.voted, addr), E_ALREADY_VOTED);
let weight = primary_fungible_store::balance(addr, gov_token());
table::add(&mut p.voted, addr, weight);
if (support) { p.yes = p.yes + weight } else { p.no = p.no + weight };
}
// Tx 1: A votes with 100. Tx 2: A transfers the 100 tokens to B. Tx 3: B votes with 100.
// Every check passed when it ran, and the same 100 tokens were counted twice.
// SAFE: escrow the voting tokens until the proposal closes, so the checked
// balance cannot move before the action that depends on it is final
public entry fun vote(voter: &signer, proposal_id: u64, support: bool, amount: u64) acquires Proposals {
let addr = signer::address_of(voter);
let p = table::borrow_mut(&mut borrow_global_mut<Proposals>(@gov).items, proposal_id);
assert!(!table::contains(&p.voted, addr), E_ALREADY_VOTED);
primary_fungible_store::transfer(voter, gov_token(), p.escrow, amount); // returned after close
table::add(&mut p.voted, addr, amount);
if (support) { p.yes = p.yes + amount } else { p.no = p.no + amount };
}flowchart LR
V1["Tx 1: A votes<br>weight = 100"] --> T["Tx 2: A transfers<br>100 tokens to B"]
T --> V2["Tx 3: B votes<br>weight = 100"]
V2 --> R["Proposal counts 200<br>from 100 tokens"]
style R fill:#C62828,color:#fff,stroke:#B71C1CThe same shape shows up in balance-gated airdrop claims, quote-then-execute swaps, request-then-claim withdrawal queues, and epoch-based reward snapshots. For every check, ask which transaction relies on it. If that is a later transaction, the checked value has to be locked, escrowed, or snapshotted so it cannot change in between.
1.5 Randomness: Test-and-Abort and Undergasing (Critical to High/Aptos)
The Aptos randomness API documents two pitfalls, also covered in the security guidelines.
A) Test-and-abort (composability). If another module can call a function that consumes randomness, it can inspect the outcome and abort when it is unfavorable, retrying until it wins. Aptos guards against this at two levels: the compiler rejects public functions that reach the randomness API, and at runtime the VM requires the outermost function of the transaction to be a private #[randomness] entry function; otherwise the draw aborts with E_API_USE_IS_BIASIBLE. The attack is still possible when a developer silences the compiler with #[lint::allow_unsafe_randomness] on a public helper, because the attacker can call that helper from their own private #[randomness] entry function, which satisfies the runtime check.
// Context: an on-chain lottery that reads randomness inline.
// VULNERABLE: a public helper that consumes randomness, with the compiler lint silenced
#[lint::allow_unsafe_randomness]
public fun lottery_draw(user: &signer) {
let result = aptos_framework::randomness::u64_range(0, 100);
if (result == 42) { award_jackpot(user); }
}
// Attacker module: its own private #[randomness] entry passes the runtime check,
// and it aborts on a loss so every losing draw is free to retry
#[randomness]
entry fun exploit(attacker: &signer) {
lottery::lottery_draw(attacker);
assert!(has_jackpot(signer::address_of(attacker)), 0);
}
// SAFE: randomness is consumed only inside a private #[randomness] entry function
#[randomness]
entry fun lottery_draw(user: &signer) {
let result = aptos_framework::randomness::u64_range(0, 100);
if (result == 42) { award_jackpot(user); }
}B) Undergasing. If the winning branch costs less gas than the losing branch, an attacker sets a gas limit that only lets the cheap (winning) path complete. Losing outcomes abort, so the attacker retries until they hit the win.
flowchart LR
A["Attacker calls<br>randomness fn"] --> B{"Outcome?"}
B -->|"Win (cheap branch)"| C["Commit: claim prize"]
B -->|"Lose (expensive branch)"| D["Out of gas<br>tx aborts"]
D --> A
style C fill:#C62828,color:#fff,stroke:#B71C1C
style D fill:#EF6C00,color:#fff,stroke:#E65100Consume randomness only in private #[randomness] entry functions, treat any #[lint::allow_unsafe_randomness] on a value-gating function as a finding, normalize gas across branches when randomness gates value, and prefer commit-reveal for high-value outcomes.
1.6 CoinStore Registration Abort DoS (Medium/Aptos)
Accounts must register CoinStore<T> before receiving a legacy coin type. If the recipient is unregistered, coin::transfer aborts, which becomes a practical DoS in reward distribution, airdrops, and any pay-anyone primitive. Zellic ranks this among the top recurring Aptos bugs. Since the Coin-to-FA migration (AIP-63) (§1.8), coins paired with a fungible asset can be deposited into the recipient's primary fungible store, which is created on demand, so the abort is now largely limited to coin types without an FA pairing. Confirm the coin's migration status before rating it.
// Context: pushing rewards from a treasury resource account to many recipients.
// VULNERABLE: one recipient without CoinStore<REWARD_TOKEN> aborts the whole batch
public entry fun send_rewards(admin: &signer, recipients: vector<address>, amount: u64) acquires Treasury {
assert!(signer::address_of(admin) == @admin, E_NOT_ADMIN);
let treasury = treasury_signer(); // from a stored SignerCapability
vector::for_each(recipients, |r| coin::transfer<REWARD_TOKEN>(&treasury, r, amount));
}
// SAFE: pull model, so a missing store only blocks the recipient's own claim
public entry fun claim_reward(user: &signer) acquires Rewards, Treasury {
let addr = signer::address_of(user);
let amount = take_pending(addr); // zeroes the pending balance before paying
if (!coin::is_account_registered<REWARD_TOKEN>(addr)) {
coin::register<REWARD_TOKEN>(user);
};
coin::transfer<REWARD_TOKEN>(&treasury_signer(), addr, amount);
}Restructure distribution as a pull model where recipients register and claim for themselves, or check registration and skip (rather than abort on) unregistered recipients in push batches. One unregistered account should never be able to stall an entire batch.
1.7 Object Capability Ref Leaks: ConstructorRef, ExtendRef, TransferRef (High/Aptos)
Aptos Objects expose capability refs at creation. A ConstructorRef can generate a TransferRef, an ExtendRef, or even a signer for the object. Leaking it (for example, by returning it from a public function) lets anyone move the object after it is sold, add unauthorized resources, or regain control later. This is the ConstructorRef leak class.
// Context: minting an object for a caller.
// VULNERABLE: leaking ConstructorRef lets the caller derive TransferRef / signer later
public fun mint_item(creator: &signer): ConstructorRef {
let ctor = object::create_object(signer::address_of(creator));
ctor // caller can now generate TransferRef, ExtendRef, or a signer
}
// SAFE: consume the ConstructorRef internally, expose only the typed Object handle
public fun mint_item(creator: &signer): Object<Item> {
let ctor = object::create_object(signer::address_of(creator));
let item_signer = object::generate_signer(&ctor);
move_to(&item_signer, Item { /* fields */ });
object::object_from_constructor_ref<Item>(&ctor)
}Store long-lived refs (ExtendRef, TransferRef) behind explicit access control, and never return a ConstructorRef across a public boundary.
1.8 Fungible Asset Migration and Dispatchable Hooks (High to Medium/Aptos)
Aptos migrated from the Coin standard to the Fungible Asset (FA) standard, and legacy coin calls now silently route to FA. OtterSec's deep dive on Aptos fungible assets shows the new model introduces its own pitfalls, notably dispatchable hooks that run custom logic on transfer. Code that assumes a plain balance read plus a plain transfer can be blindsided by a hook that re-enters or alters balances.
// Context: charging a fee from a user balance.
// VULNERABLE: assumes legacy Coin<T> semantics; a dispatchable FA hook may alter state mid-call
public entry fun charge_fee(user: &signer, amount: u64) {
let bal = coin::balance<USDC>(signer::address_of(user)); // may route to FA silently
assert!(bal >= amount, E_INSUFFICIENT);
coin::transfer<USDC>(user, @treasury, amount);
}
// SAFE: use FA APIs, read via primary_fungible_store, and re-validate after a dispatchable transfer
public entry fun charge_fee(user: &signer, metadata: Object<Metadata>, amount: u64) {
let addr = signer::address_of(user);
assert!(primary_fungible_store::balance(addr, metadata) >= amount, E_INSUFFICIENT);
dispatchable_fungible_asset::transfer(user, primary_store(addr, metadata), treasury_store(metadata), amount);
// a hook may have executed: re-check protocol invariants here
}When reviewing ported code, confirm balances are read through the FA stores, and treat any dispatchable transfer as a potential callback site.
1.9 Module Upgrade Authority and Compatibility (High/Aptos)
On Aptos, the deployer holds upgrade authority, and with account publishing that authority is effectively permanent and non-transferable. A resource account stores a SignerCapability; if that capability is left unprotected, whoever reaches it controls the code account, its upgrades, and its resources. Object code deployment makes upgrade authority an ownable, transferable object instead.
// Context: initializing a module published under a resource account.
// VULNERABLE: an unprotected SignerCapability is permanent god-mode over the code account
struct AdminStore has key { cap: account::SignerCapability }
public fun init(admin: &signer, cap: account::SignerCapability) {
move_to(admin, AdminStore { cap }); // any path that reaches `cap` controls upgrades
}
// SAFE: gate the capability behind explicit, reviewed authorization
public entry fun admin_only_action(admin: &signer) acquires AdminStore {
assert!(signer::address_of(admin) == @governance, E_NOT_AUTHORIZED);
let store = borrow_global<AdminStore>(@governance);
let _s = account::create_signer_with_capability(&store.cap);
// perform the privileged action
}Review who holds upgrade authority, whether it can be revoked or transferred, and whether an upgrade could silently change compatibility-sensitive struct layouts.
1.10 Generic Type Confusion and Missing Type Checks (High/Aptos)
Generic functions that trust a caller-supplied type argument can pair the wrong type with the wrong state. Move's phantom type parameters help bind a resource to its type, and type_info lets you assert the expected type at runtime.
// Context: a generic pool withdrawal.
// VULNERABLE: nothing binds CoinType to the pool being mutated
public fun withdraw<CoinType>(pool_addr: address, amount: u64): Coin<CoinType> acquires Pool {
let pool = borrow_global_mut<Pool>(pool_addr);
pool.reserve = pool.reserve - amount; // wrong pool/type can be paired
coin::extract(&mut pool.funds, amount)
}
// SAFE: pin the type via a typed resource and assert the expected type_info
public fun withdraw<CoinType>(pool_addr: address, amount: u64): Coin<CoinType> acquires Pool {
let pool = borrow_global_mut<Pool<CoinType>>(pool_addr);
assert!(type_info::type_of<CoinType>() == pool.expected_type, E_TYPE_MISMATCH);
pool.reserve = pool.reserve - amount;
coin::extract(&mut pool.funds, amount)
}Bind every generic to the state it governs, either through a phantom parameter on the resource itself or an explicit type_info assertion. A caller should never be able to choose the type independently of the state it operates on.
1.11 Dynamic Dispatch via Stored Function Values (High/Aptos)
Move on Aptos now supports first-class functions and dynamic dispatch (AIP-112): function values can be passed as arguments, stored in structs, and kept in persistent storage (only public or #[persistent] functions can be stored). A stored function value is executable authority. If an attacker can set the handler that a privileged routine later invokes, their code runs inside your protocol's logic and decides its outcome, a pattern close in spirit to the Fungible Asset dispatchable hooks in §1.8.
// Context: a protocol-wide config stores a fee hook that a keeper routine invokes.
struct Config has key { fee_hook: |u64| u64 has copy + store }
// VULNERABLE: any module can install its own hook (function values cannot be
// transaction arguments, so the setter is a public fun called from attacker code)
public fun set_fee_hook(_caller: &signer, hook: |u64| u64 has copy + store) acquires Config {
borrow_global_mut<Config>(@protocol).fee_hook = hook;
}
public entry fun settle(_keeper: &signer) acquires Config, Pool {
let hook = borrow_global<Config>(@protocol).fee_hook;
let pool = borrow_global_mut<Pool>(@protocol);
let fee = hook(pool.accrued); // attacker-chosen logic now sets the fee for every position
pool.accrued = pool.accrued - fee;
pool.treasury = pool.treasury + fee;
}
// SAFE: governance picks from a vetted, enumerated set, and the result is bounded
struct Config has key { fee_model: u8 }
public entry fun set_fee_model(admin: &signer, id: u8) acquires Config {
assert!(signer::address_of(admin) == @governance, E_NOT_AUTHORIZED);
assert!(id < NUM_FEE_MODELS, E_UNKNOWN_MODEL);
borrow_global_mut<Config>(@protocol).fee_model = id;
}
public entry fun settle(_keeper: &signer) acquires Config, Pool {
let model = borrow_global<Config>(@protocol).fee_model;
let pool = borrow_global_mut<Pool>(@protocol);
let fee = compute_fee(model, pool.accrued); // match over vetted implementations
assert!(fee <= pool.accrued * MAX_FEE_BPS / 10000, E_FEE_TOO_HIGH);
pool.accrued = pool.accrued - fee;
pool.treasury = pool.treasury + fee;
}The VM does provide a backstop: a runtime reentrancy checker governs dispatch through function values, and #[module_lock] blocks indirect reentrancy into a module entirely (Dedaub). Neither stops a hook that was installed through a missing authorization check, because that is an access-control bug, not reentrancy. Prefer an enumerated, vetted set of code paths over arbitrary stored function values, gate any setter with strict authorization, and bound or re-check results after every dispatch.
1.12 Account Abstraction: Custom Authentication Logic (High/Aptos)
Account Abstraction (AIP-104) lets an account authorize transactions with its own Move authentication function instead of a native signature scheme. The function receives the account's signer and an AbstractionAuthData carrying the transaction's signing digest and an authenticator blob. If it returns the signer, the transaction is authorized; if it aborts, the transaction is rejected. Returning the signer is the authorization, so this one function is the account's entire security boundary.
The framework still enforces normal replay protection (sequence numbers or orderless nonces), so you do not need your own nonce. What the framework cannot enforce is that your function verifies a signature over this transaction's digest. Verifying anything else means a signature from one transaction works for every other transaction.
// Context: an AIP-104 authentication function registered for an account.
// VULNERABLE: the signature is verified over a fixed message, not the transaction digest
public fun authenticate(account: signer, signing_data: AbstractionAuthData): signer acquires KeyStore {
let addr = signer::address_of(&account);
let pk = borrow_global<KeyStore>(addr).public_key;
let sig = ed25519::new_signature_from_bytes(*auth_data::authenticator(&signing_data));
// one signature over the address is visible on-chain after the first transaction,
// and anyone can attach it to their own transaction for this account
assert!(ed25519::signature_verify_strict(&sig, &pk, bcs::to_bytes(&addr)), E_INVALID_SIGNATURE);
account
}
// VULNERABLE: anyone can replace the key the authenticator trusts
public entry fun set_key(_caller: &signer, account: address, pk: vector<u8>) acquires KeyStore {
borrow_global_mut<KeyStore>(account).public_key = ed25519::new_unvalidated_public_key_from_bytes(pk);
}
// SAFE: verify over the digest the framework supplies, and gate key rotation on the account itself
public fun authenticate(account: signer, signing_data: AbstractionAuthData): signer acquires KeyStore {
let addr = signer::address_of(&account);
let pk = borrow_global<KeyStore>(addr).public_key;
let sig = ed25519::new_signature_from_bytes(*auth_data::authenticator(&signing_data));
assert!(ed25519::signature_verify_strict(&sig, &pk, *auth_data::digest(&signing_data)), E_INVALID_SIGNATURE);
account
}
public entry fun set_key(owner: &signer, pk: vector<u8>) acquires KeyStore {
let addr = signer::address_of(owner);
borrow_global_mut<KeyStore>(addr).public_key = ed25519::new_unvalidated_public_key_from_bytes(pk);
}Review the authentication function as you would a private key:
- Every success path must verify a signature over
auth_data::digest. - Every failure path must abort, never return the signer.
- Any state it trusts, such as keys, allowlists, or session permissions, must only be writable by the account itself.
- For derivable (domain) accounts, confirm the derived identity actually matches the account address being authorized.
2. The Aptos Move VM and Framework Guarantees
Much of Aptos security rests on guarantees enforced below the application layer. Knowing what the VM does (and does not) promise tells you which invariants you must still enforce yourself.
flowchart LR
S["Move source"] --> K["Aptos Move compiler"]
K --> BC["Bytecode"]
BC --> V["Bytecode verifier<br>type / resource / reference / ability"]
V --> P["Published module"]
P --> VM["MoveVM execution<br>via Block-STM"]
VM --> ST["Global storage state"]
V -.->|"optional"| MP["Move Prover<br>formal verification"]
style V fill:#1565C0,color:#fff,stroke:#0D47A1
style MP fill:#2E7D32,color:#fff,stroke:#1B5E20
style ST fill:#EF6C00,color:#fff,stroke:#E651002.1 What the Verifier and Move Prover Guarantee
The Move bytecode verifier statically enforces type safety, resource linearity (no double-spend, no accidental drop of resources without drop), reference safety, and ability rules before a module is published. On top of that, Aptos ships the Move Prover, which lets teams write formal specifications and prove properties about their code. These are strong guarantees, but they are structural: they do not know your business invariants (solvency, access control, price bounds). Those remain your responsibility.
2.2 When the VM Itself Has a Bug (Critical/Aptos)
The root of trust can itself have flaws. Zellic's "Billion Dollar Bug" was a control-flow-graph flaw in the Move bytecode verifier that let attackers drop values without drop, bypass reference safety, and defeat paranoid type checks. It affected the shared Move toolchain used by both Sui and Aptos and was fixed in collaboration with Mysten Labs. Separately, Numen Cyber documented an early critical Aptos MoveVM bug that could crash nodes and cause denial of service.
More recently, in February 2026, Hexens disclosed a stale type-tag cache bug in the Aptos Move VM that enabled storage-level type confusion: by abusing a caching path added to speed up execution, an attacker could hijack and overwrite an arbitrary on-chain struct they never had access to. Hexens reported it on February 25, 2026. In simulation, the proof of concept succeeded roughly 90% of the time for about $3,000 in server cost, without validator privileges. Hexens estimated the systemic exposure at up to $70B across bridges, stablecoins, and exchange pathways connected to Aptos. That is a downstream-risk estimate rather than Aptos TVL (closer to $250M by one assessment), and Aptos was cautious about the figure. The fix shipped quickly, with the public patch landing on February 27 (Crypto Briefing). The bug is doubly instructive: it lived in the performance-optimization layer that underpins parallel execution (Section 2.3), and it defeated the very type-safety guarantee the verifier is supposed to provide (Section 2.1). A structural guarantee is only ever as strong as its implementation.
So assume the layer beneath you can fail. Keep explicit assert! checks and invariant validation even where the language "should" make a class of bug impossible, and add formal verification wherever the value at risk justifies the effort.
2.3 Block-STM and the Execution Layer
Aptos Block-STM executes a pre-ordered block of transactions optimistically in parallel and re-executes on conflict, reaching up to roughly 160k tps in benchmarks while keeping gas near $0.00005 per transaction. Block-STM v2 extends this to larger core counts and more contended workloads. For developers, none of this changes the rule: the final state is deterministic and equivalent to the preset serial order, so any exploitable concurrency risk lives in application-layer assumptions (Section 1.4), not in the engine itself.
Real-World Case Study: Thala Labs ($25.5M, November 2024)
On November 15, 2024, Thala Labs, a major Aptos DeFi protocol (a rebalancing AMM plus the MOD stablecoin), lost roughly $25.5M in liquidity pool tokens through an isolated vulnerability in a recent update to its v1 farming contracts. Because the contracts were built in Move and could be paused, Thala froze about $11.5M in related assets within hours, traced the exploiter, and negotiated a $300K bounty for the full return of user funds.
The bug: an unstake that never checked the staked balance
The technical post-mortems pin the root cause on inadequate parameter validation in the farming contract's unstake path: the caller-supplied withdraw amount was never checked against the amount the caller had actually staked. The attacker added liquidity to obtain THALA-LP tokens, staked and unstaked until their recorded balance was zero, then called unstake with a massive amount (on the order of 1E12) they had never staked. With no bound tying the request to real state, the contract released LP tokens the attacker was never entitled to, which they swapped to lzUSDC and drained across the farms (Halborn analysis).
// Context: v1 farming unstake; `amount` is the caller-supplied withdraw size.
// VULNERABLE: `amount` is never bounded by the caller's real staked balance
public entry fun unstake(user: &signer, amount: u64) acquires Stake {
let addr = signer::address_of(user);
let stake = borrow_global_mut<Stake>(addr);
// no assert that amount <= stake.balance
stake.balance = stake.balance - amount;
transfer_lp_to(addr, amount); // releases LP with no stake behind it
}
// SAFE: bound the withdrawal by the caller's actual staked balance
public entry fun unstake(user: &signer, amount: u64) acquires Stake {
let addr = signer::address_of(user);
let stake = borrow_global_mut<Stake>(addr);
assert!(amount <= stake.balance, E_INSUFFICIENT_STAKE);
stake.balance = stake.balance - amount;
transfer_lp_to(addr, amount);
}This is the account-model version of the very first finding in Section 1: where §1.1 warns about trusting a caller-supplied address, Thala trusted a caller-supplied amount. In both cases, a borrow_global_mut write path let an operation exceed what the caller's on-chain state justified because a bounds check was skipped. It also shows precisely where §2.1's guarantees stop: Move's bytecode verifier guaranteed type and resource safety, but it had no way to know the protocol invariant "you cannot withdraw more than you staked." That invariant is the developer's job, and a single missing assert! turned it into a $25.5M loss. One postmortem also notes that Move bytecode is comparatively easy to decompile, so an unshipped safety check is not obscure to an attacker: treat every state-mutating entry point as public and adversarially reachable.
3. Supra-Specific Considerations
Supra is a full-stack L1 with MoveVM semantics and native services (oracles, dVRF, automation, cross-chain). Chain features expand the attack surface mainly through composition, batching, and native-service trust assumptions.
3.1 Atomic Composition and Batched Calls (High/Supra)
Supra's batching, like Aptos Move scripts, lets a user compose several calls into one atomic transaction. Calls inside a transaction run sequentially, so a guard that checks state written by the call before it still holds. The risk is narrower: a guard that checks state the earlier call did not update, or an invariant that is only enforced at some later step in the batch.
// Context: a lending module that defers per-user accounting to a settle step.
// VULNERABLE: the guard reads a counter that only settle() updates
public entry fun open_loan(user: &signer, collateral: u64) acquires Book {
let addr = signer::address_of(user);
let book = borrow_global_mut<Book>(@lending);
assert!(*table::borrow_with_default(&book.loan_count, addr, &0) == 0, E_HAS_LOAN);
vector::push_back(&mut book.pending, Loan { owner: addr, collateral });
}
// One atomic batch: open_loan(user, A); open_loan(user, B); settle();
// Both open_loan calls see loan_count == 0, and the user ends up with two loans.
// SAFE: write the guard state in the same call the guard protects
public entry fun open_loan(user: &signer, collateral: u64) acquires Book {
let addr = signer::address_of(user);
let book = borrow_global_mut<Book>(@lending);
assert!(!table::contains(&book.has_loan, addr), E_HAS_LOAN);
table::add(&mut book.has_loan, addr, true); // guard and update move together
vector::push_back(&mut book.pending, Loan { owner: addr, collateral });
}The second pattern is transient state. Because later calls in a batch see the intermediate state left by earlier ones, an attacker can manipulate state, exploit it, and revert the manipulation in one atomic unit with no capital at risk between steps. A typical case is pushing pool reserves with a swap, borrowing against the resulting spot price, then swapping back. Defenses include:
- Price from an oracle or TWAP rather than spot reserves.
- Check invariants inside every value-moving call, not only in a closing step.
- Where a protocol genuinely needs one action per transaction, record
transaction_context::get_transaction_hash()and reject a repeat.
3.2 Dust Accumulation Amplified by Micro-Transactions (Medium/Supra)
Batching and cheap micro-transactions amplify dust and rounding edge cases. Any design that forgives dust per operation becomes exploitable when an attacker can cheaply execute thousands of operations.
// Context: charging a percentage fee that rounds down; dust is forgiven per call.
// VULNERABLE: fee rounds to 0 for tiny amounts, so many micro-ops extract value for free
public entry fun charge_fee(user: &signer, amount: u64) acquires Pool {
let fee = amount * FEE_BPS / 10000; // integer division rounds down; 0 for small amount
let pool = borrow_global_mut<Pool>(@protocol);
pool.collected = pool.collected + fee;
settle(user, amount - fee);
}
// SAFE: enforce a minimum amount, round fees up, and reject zero-fee operations
public entry fun charge_fee(user: &signer, amount: u64) acquires Pool {
assert!(amount >= MIN_AMOUNT, E_BELOW_MIN);
let fee = (amount * FEE_BPS + 9999) / 10000; // round up so dust cannot be farmed
assert!(fee > 0, E_ZERO_FEE);
let pool = borrow_global_mut<Pool>(@protocol);
pool.collected = pool.collected + fee;
settle(user, amount - fee);
}Review every rounding path (fees, interest accrual, reward distribution) and confirm minimum-amount checks are enforced on every path, not just the common one.
3.3 MoveVM Version and Framework Divergence (Medium/Supra)
Porting Move code between chains is risky even when the language is nominally the same. Differences in VM version, framework APIs, and feature flags can invalidate assumptions.
// Context: fixed-point pricing ported from another Move chain.
// VULNERABLE: silently assumes the origin chain's fixed-point scale and rounding
public fun price(amount: u64): u64 {
fixed_point::multiply(amount, RATE) // scale/rounding may differ across VM versions
}
// SAFE: assert the framework assumptions the code depends on before relying on them
public fun price(amount: u64): u64 {
assert!(fixed_point::scale() == EXPECTED_SCALE, E_SCALE_MISMATCH);
let out = fixed_point::multiply(amount, RATE);
assert!(out >= MIN_OUT, E_ROUNDING_DIVERGENCE);
out
}Check arithmetic and fixed-point library semantics, standard library availability and signatures, and any chain-specific natives pulled in implicitly by dependencies.
3.4 Native Service Trust: dVRF Callbacks and Automation (High/Supra)
Supra's dVRF delivers randomness through a request-and-callback flow, and its automation network triggers registered tasks. Both are trust boundaries. The dVRF callback is a public entry function with a fixed parameter list and no signer, so anyone can call it with arguments of their choosing. The only thing that authenticates the payload is supra_vrf::verify_callback, which checks the signature and returns the verified random numbers. The handler must also bind the nonce to a request you actually made and consume it once. Automated task functions need the same treatment: idempotent and access-controlled so a triggered task cannot be replayed or front-run.
// Context: settling a game round from a Supra dVRF callback.
// VULNERABLE: trusts the callback arguments without verification and settles any nonce
public entry fun settle_round(nonce: u64, message: vector<u8>, signature: vector<u8>,
caller_address: address, rng_count: u8, client_seed: u64) acquires Rounds {
let rng = decode_numbers(message); // anyone can call this with a chosen message
finalize(nonce, rng);
}
// SAFE: verify through supra_vrf, bind to a pending request, and settle each nonce once
public entry fun settle_round(nonce: u64, message: vector<u8>, signature: vector<u8>,
caller_address: address, rng_count: u8, client_seed: u64) acquires Rounds {
let rounds = borrow_global_mut<Rounds>(@game);
assert!(table::contains(&rounds.pending, nonce), E_UNKNOWN_REQUEST);
let rng: vector<u256> = supra_vrf::verify_callback(nonce, message, signature, caller_address, rng_count, client_seed);
table::remove(&mut rounds.pending, nonce); // consumed: a replay now fails the pending check
finalize(nonce, rng);
}Treat every native-service entry point as attacker-callable: verify the payload through the service's own verification function, bind it to a request you actually made, and consume the request so a replayed or duplicated trigger cannot settle the same round twice. Supra also offers native on-chain randomness that follows the same private #[randomness] entry rule as Aptos, so §1.5 applies there as well.
Aptos and Supra Audit Checklist
Global Storage & References
- Every
borrow_global_mut<T>(addr):addris validated or derived from the signer - No critical invariant can be violated via
&mutpassed to external modules - Resource group members are reviewed together, with explicit shared invariants and no hot write state grouped with widely read state
Concurrency
- Any check that a later transaction relies on is backed by locked, escrowed, or snapshotted state
- Balance- or ownership-gated actions cannot be repeated by moving assets between accounts
Randomness
- Randomness is consumed only in private
#[randomness]entry functions; any#[lint::allow_unsafe_randomness]is justified and reviewed - Randomness-gated branches have comparable gas usage (undergasing resistance)
Coin & Fungible Assets
- Legacy coin distribution uses a pull model or skips unregistered recipients instead of aborting the batch
- FA balances are read via the fungible stores; dispatchable transfers re-validate invariants after the call
Objects & Capabilities
- No
ConstructorRefis returned across a public boundary;ExtendRef/TransferRefare access-controlled
Upgrades & Types
- Upgrade authority (SignerCapability or code object) is protected, and its holder and transferability are documented
- Generic functions bind types via phantom parameters or assert
type_info
Dynamic Dispatch & Account Abstraction
- Stored function values can only be set by authorized parties or come from a vetted enumerated set, with results bounded after dispatch
- AA authentication functions verify a signature over
auth_data::digeston every success path, abort on every failure, and protect any key or permission state they trust
Supra
- Guards write their own state in the call they protect, and no invariant is checked only at the end of a batch or against transient spot state
- dVRF callbacks call
supra_vrf::verify_callback, only settle pending nonces, and consume each nonce once; automation tasks are idempotent
Conclusion
Aptos-specific issues cluster around global storage access control, object capability refs, framework standards (Coin, Fungible Asset, randomness), and check-then-act assumptions that span multiple transactions. Supra adds batching, composition, and native-service trust (dVRF, automation) on top of MoveVM. In both ecosystems the verifier and, on Aptos, the Move Prover give strong structural guarantees, but they do not know your business invariants. Thala lost $25.5M to a single missing comparison that no verifier was ever going to catch on its behalf. Those are the checks you have to write yourself, and prove the ones you cannot afford to get wrong.





