Back to Blog

Move Smart Contract Security: What Sui and Aptos Developers Need to Know

|Odin Scan Team
Move Smart Contract Security: What Sui and Aptos Developers Need to Know

Move was designed from the ground up with safety in mind. Resources cannot be copied or implicitly dropped. Integer arithmetic aborts on overflow. There is no reentrancy because there are no dynamic dispatch calls to unknown code during execution.

If you come from Solidity, this sounds like the end of smart contract vulnerabilities. It is not. Move eliminates entire vulnerability classes, but the ones that remain are subtle, Move-specific, and increasingly relevant as Sui and Aptos TVL grows.

These are the patterns that show up in Move security reviews.


What Move Eliminates

Before covering what can go wrong, it is worth understanding what Move gets right by construction:

No reentrancy. Move does not have dynamic dispatch to arbitrary code. When you call a function, you know exactly what code will execute. There is no equivalent of Solidity's call to an unknown address.

No integer overflow. All arithmetic operations in Move abort on overflow. There is no wrapping behavior, no unchecked blocks. If a + b exceeds u64::MAX, the transaction aborts.

No null references. Resources either exist or they do not. There is no null pointer equivalent.

Linear resource safety. Resources cannot be copied or implicitly discarded. If a function receives a resource, it must explicitly store it, transfer it, or destroy it. You cannot accidentally lose tokens by forgetting to save them.

These guarantees are enforced at the bytecode level by the Move verifier, not at the source level by a linter. They cannot be circumvented.


Vulnerability 1: Access Control on Entry Functions

Move modules expose functionality through entry functions and public functions. The distinction matters for access control.

// Vulnerable: any address can call this
public entry fun withdraw_admin_funds(
    vault: &mut Vault,
    ctx: &mut TxContext,
) {
    let balance = balance::withdraw_all(&mut vault.balance);
    transfer::public_transfer(coin::from_balance(balance, ctx), tx_context::sender(ctx));
}
// Safe: verify the caller is the admin
public entry fun withdraw_admin_funds(
    admin_cap: &AdminCap, // possession of this object proves authority
    vault: &mut Vault,
    ctx: &mut TxContext,
) {
    let balance = balance::withdraw_all(&mut vault.balance);
    transfer::public_transfer(coin::from_balance(balance, ctx), tx_context::sender(ctx));
}

In Move (especially Sui Move), access control is idiomatically enforced through capability objects. Possessing an AdminCap object proves you have admin authority. The capability pattern is more robust than address-based checks because capabilities can be transferred, split, and destroyed with explicit semantics.

Common mistakes:

  • entry functions that skip capability checks
  • public functions that should be public(package) (visible only within the same package)
  • Address-based checks using tx_context::sender() that can be impersonated through composed transactions

Vulnerability 2: Object Ownership and Shared State

Sui's object model introduces a new dimension of access control: object ownership. Objects can be owned (only the owner can use them in transactions), shared (anyone can use them), or immutable (frozen, read-only).

// Dangerous pattern: sharing an object that should be owned
public fun create_vault(ctx: &mut TxContext) {
    let vault = Vault { id: object::new(ctx), balance: balance::zero() };
    transfer::share_object(vault); // anyone can now mutate this vault
}

// Safer: transfer to a specific owner
public fun create_vault(ctx: &mut TxContext) {
    let vault = Vault { id: object::new(ctx), balance: balance::zero() };
    transfer::transfer(vault, tx_context::sender(ctx)); // only owner can use
}

When to share vs. own:

  • Share objects that need to be accessed by multiple users (DEX pools, lending markets)
  • Own objects that belong to a single user (user positions, admin capabilities)
  • Make objects immutable when they should never change (configuration, published metadata)

Common mistakes:

  • Sharing objects that should be owned, exposing them to unauthorized mutation
  • Missing assertions on shared object state (anyone can pass a shared object to any function)
  • Not verifying that a passed object ID matches the expected context

Vulnerability 3: Incorrect Coin and Balance Handling

Move's linear type system prevents you from accidentally copying or dropping coins. But it does not prevent you from handling them incorrectly.

// Vulnerable: leftover balance is silently destroyed with abort
public fun swap(
    pool: &mut Pool,
    coin_in: Coin<SUI>,
    min_out: u64,
    ctx: &mut TxContext,
): Coin<USDC> {
    let amount_in = coin::value(&coin_in);
    let amount_out = calculate_output(pool, amount_in);
    assert!(amount_out >= min_out, ESlippageExceeded);

    // Put coin_in into the pool
    balance::join(&mut pool.sui_balance, coin::into_balance(coin_in));

    // Return output
    coin::from_balance(balance::split(&mut pool.usdc_balance, amount_out), ctx)
}
// What if the caller sent more than needed? The excess is in the pool permanently.
// Safe: return excess to the caller
public fun swap(
    pool: &mut Pool,
    coin_in: Coin<SUI>,
    exact_amount_in: u64,
    min_out: u64,
    ctx: &mut TxContext,
): (Coin<USDC>, Coin<SUI>) {
    let amount_out = calculate_output(pool, exact_amount_in);
    assert!(amount_out >= min_out, ESlippageExceeded);

    // Split exact amount, return remainder
    let payment = coin::split(&mut coin_in, exact_amount_in, ctx);
    balance::join(&mut pool.sui_balance, coin::into_balance(payment));

    let output = coin::from_balance(
        balance::split(&mut pool.usdc_balance, amount_out), ctx
    );
    (output, coin_in) // return leftover to caller
}

Vulnerability 4: Flash Loan Equivalents in Move

Move does not have flash loans in the Aave sense, but it has hot potato patterns that achieve the same effect. A hot potato is a struct with no drop ability, meaning it must be consumed by a specific function before the transaction ends.

// Hot potato flash loan pattern
struct FlashLoanReceipt {
    pool_id: ID,
    amount: u64,
}
// FlashLoanReceipt has no 'drop', so it MUST be passed to repay_flash_loan

public fun flash_borrow(pool: &mut Pool, amount: u64, ctx: &mut TxContext): (Coin<SUI>, FlashLoanReceipt) {
    let coin = coin::from_balance(balance::split(&mut pool.balance, amount), ctx);
    let receipt = FlashLoanReceipt { pool_id: object::id(pool), amount };
    (coin, receipt)
}

public fun repay_flash_loan(pool: &mut Pool, payment: Coin<SUI>, receipt: FlashLoanReceipt) {
    let FlashLoanReceipt { pool_id, amount } = receipt;
    assert!(object::id(pool) == pool_id, EWrongPool);
    assert!(coin::value(&payment) >= amount, EInsufficientRepayment);
    balance::join(&mut pool.balance, coin::into_balance(payment));
}

This pattern is sound. The vulnerability appears when protocols do not account for the possibility that users have temporary access to large capital within a single transaction, the same economic amplification that flash loans enable on EVM.

Defense: Same as EVM: do not use spot prices for any economic decision. Use TWAPs or external oracles. Design protocol logic so single-transaction capital cannot influence outcomes.


Vulnerability 5: Unchecked Arithmetic on Custom Types

Move's built-in types (u8, u64, u128, u256) abort on overflow. But custom fixed-point math libraries, fee calculations, and price computations can still produce incorrect results through truncation or division-before-multiplication.

// Vulnerable: division before multiplication truncates
public fun calculate_fee(amount: u64, fee_rate: u64): u64 {
    (amount / 10000) * fee_rate
    // For amount = 9999, result is 0 regardless of fee_rate
}

// Safe: multiply first
public fun calculate_fee(amount: u64, fee_rate: u64): u64 {
    // Cast to u128 to prevent intermediate overflow
    (((amount as u128) * (fee_rate as u128)) / 10000 as u128) as u64
}

Defense: Always multiply before dividing. Use u128 or u256 for intermediate calculations when inputs can be large. Test with boundary values.


Vulnerability 6: Package Upgrade Risks

Sui and Aptos allow package upgrades under certain conditions. On Sui, a package can be upgraded if the UpgradeCap is available. The upgrade can add new modules and change existing function implementations, but cannot change struct layouts or remove public functions.

// If an attacker gains access to the UpgradeCap,
// they can change the implementation of any function in the package
// while maintaining the same interface

// Defense: restrict the UpgradeCap
public fun lock_upgrades(cap: UpgradeCap) {
    // Make the package immutable - no more upgrades possible
    package::make_immutable(cap);
}

Common mistakes:

  • Leaving the UpgradeCap in a shared object accessible to anyone
  • Not restricting upgrade policy (allowing any change instead of only additive changes)
  • Trusting that the current implementation will remain in effect forever without checking upgrade capability status

The Move Security Checklist

Before deploying any Move module:

  • Every entry function that modifies state has a capability check or ownership verification
  • Shared objects are only shared when multiple users genuinely need access
  • Coin and balance handling returns excess to the caller (no silent absorption)
  • All arithmetic that involves user amounts uses intermediate u128/u256 and multiplies before dividing
  • Price and value calculations are resistant to single-transaction manipulation
  • The UpgradeCap is either restricted to additive-only upgrades or made immutable
  • Visibility modifiers are minimal: public(package) over public where possible

Move is a safer language than Solidity or raw Rust for smart contracts. That does not mean Move contracts are automatically safe. The bugs are fewer, but they are also less expected, which means developers are less likely to look for them.


Odin Scan is adding Move/Sui support. Join the waitlist to be notified when it launches. In the meantime, Odin Scan covers EVM, Solana, and CosmWasm with full AI-powered security scanning on every PR.