Sui’s design philosophy is rooted in a single observation: most smart contract vulnerabilities stem from uncontrolled access to shared mutable state. The Ethereum Virtual Machine exposes a flat key-value store to every contract, and the original Move language on Aptos still channels state through global storage addressed by account. Sui takes a harder turn — objects are first-class values with explicit owners, and sharing is opt-in. That choice eliminates entire vulnerability classes while simultaneously creating new ones. This article dissects the Sui object model, traces the security implications of every ownership variant, and catalogs the vulnerability patterns that auditors encounter most frequently on Sui.
The Sui Object Model vs. EVM and Aptos Move
EVM: A Flat, Account-Keyed World
On Ethereum, state lives in a single global trie keyed by (contract_address, storage_slot). Every transaction that touches a contract reads and writes from that shared store. The consequnces are well-known: reentrancy, storage-collision in proxy patterns, front-running, and race conditions between concurrent transactions. The EVM provides no native concept of ownership — contracts simulate it with mapping(address => uint256) balances and require(msg.sender == owner) guards.
Aptos Move: Global Storage, but Typed
Aptos Move improves on EVM by introducing a type-safe resource model. Resources are stored in global storage under address, and acquiring them requires move_from<T>(addr) or borrow_global_mut<T>(addr). This eliminates ABI-decoding errors and enforces linear type semantics (no duplication, no implicit destruction), but the global storage table is still the fundamental unit of state. Two transactions that both read or write borrow_global_mut<Pool>(pool_addr) are sequenced serially.
Sui Move: Objects as First-Class Values
Sui discards the global storage model entirely. Every piece of state is an object — a struct with the key ability that carries a unique 32-byte UID. Objects are not stored “inside” any account; the Sui ledger maintains a global object store keyed by ObjectID, and each object carries an explicit owner field that the runtime enforces. A transaction declares exactly which objects it will read or write in its input set; the runtime rejects any access not declared upfront.
module example::token {
use sui::object::{Self, UID};
use sui::tx_context::TxContext;
/// A simple owned object.
public struct Token has key, store {
id: UID,
value: u64,
}
public fun mint(ctx: &mut TxContext): Token {
Token {
id: object::new(ctx),
value: 100,
}
}
}
Three differences are immediate security consequences:
| Dimension | EVM | Aptos Move | Sui Move |
|---|---|---|---|
| State location | Global trie by slot | Global store by address | Object store by ObjectID |
| Ownership | Simulated in code | Simulated in code | Enforced by runtime |
| Parallelism | Serial per contract | Serial per account | Parallel for independent objects |
| Reentrancy surface | High | Low | Very low for owned objects |
Ownership Variants and Their Security Implications
Every Sui object has one of four ownership states. Understanding each is prerequisite to reasoning about security.
1. Address-Owned Objects
The object belongs to a single address (an end-user key or another object’s ID used as an address). Only a transaction signed by the owner can use the object as a mutable input. This is the default for objects returned from transfer::transfer(obj, recipient).
Security properties:
- No front-running: no other party can race to consume the object.
- No reentrancy through this object: the object cannot be modified by a second transaction before the first completes.
- Griefing resistance: an adversary cannot lock or block the object.
Residual risks:
- The owner themselves can equivocate (sign two conflicting transactions for the same object) in a single-owner setting, but validators will only accept one.
- Accidentally transferring to
@0x0or a contract address that has no signing capability permanently locks the object.
public fun safe_transfer(token: Token, recipient: address) {
// Irrevocable — verify recipient carefully before calling.
transfer::transfer(token, recipient);
}
2. Shared Objects
transfer::share_object(obj) makes an object accessible to any transaction. The runtime must sequence all transactions that touch a shared object through consensus, which has two implications: it is slower (no optimistic parallelism), and any user in the world can interact with it.
Security properties:
- Enables open, permissionless protocols (DEXes, lending pools, NFT marketplaces).
- Requires explicit access-control logic because the runtime enforces nothing beyond “you declared it as an input.”
Residual risks (covered in depth below):
- Shared object contention and denial-of-service.
- Missing access guards on mutating entry points.
- Epoch-boundary race conditions in time-sensitive protocols.
public fun create_pool(ctx: &mut TxContext) {
let pool = Pool {
id: object::new(ctx),
reserves: 0,
};
// Now any transaction can reference pool as an input.
transfer::share_object(pool);
}
3. Immutable Objects
transfer::freeze_object(obj) permanently prevents any mutation. Immutable objects can be read by any transaction without going through consensus because no sequencing is needed for read-only inputs.
Security properties:
- Safe to use as shared configuration: currency metadata, protocol parameters, verifier keys.
- No race conditions; no front-running through the object.
Residual risks:
- Immutability is permanent and irreversible. Freezing an object with incorrect configuration is catastrophic.
- A contract that reads an immutable config object must still validate its type and
idto avoid substitution attacks (passing a different immutable object of the same type).
4. Object-Owned Objects (Dynamic Ownership)
An object can be owned by another object, forming ownership trees. The parent object must be presented to access the child. This is the foundation of the object capability pattern (explored next).
public fun wrap_into_vault(token: Token, vault: &mut Vault) {
// token is now owned by vault's UID — only vault's owner can access it.
object::add_to_object(&mut vault.id, token);
}
The Transaction Model and Sponsored Transactions
Programmable Transaction Blocks (PTBs)
Sui transactions are Programmable Transaction Blocks: a sequence of Move calls, transfers, and coin operations composed into a single atomic unit. Every input object must be declared upfront; the runtime validates ownership and locks the object set before execution begins.
PTB inputs: [SharedPool@0xABC (mutable), UserCoin@0xDEF (owned)]
Commands:
0: pool::swap(&mut SharedPool, UserCoin) → OutputCoin
1: transfer::transfer(OutputCoin, sender)
Security implication: Because inputs are declared before execution, there is no equivalent to EVM’s dynamic CALL reentrancy via an unknown callee. A function can only operate on objects it received — it cannot reach out and grab new objects mid-execution.
Sponsored Transactions
Sui supports gas sponsorship: a separate sponsor address pays the gas fee on behalf of the sender. The sponsor co-signs the transaction but does not control its inputs.
Vulnerability surface:
-
Sponsor as hidden authorizer. A malicious dApp could construct a PTB where the “sponsor” is actually a privileged admin account whose signature is required for an admin action. A user who signs what they believe is a simple transfer is inadvertently co-authorizing a privileged operation.
-
Replay across epochs. If a sponsored transaction is constructed with a far-future expiration and the sponsor key is later compromised, adversaries can replay gas-free transactions on behalf of users.
-
Griefing via sponsorship withdrawal. A sponsor can refuse to broadcast, leaving the user’s signed transaction in limbo. This is a liveness issue, not a funds-loss issue, but it matters for UX and for time-sensitive operations like liquidations.
// Always verify that sponsored entry points do not embed privileged logic
// gated only on the sponsor's signature presence.
public fun user_action(
_cap: &AdminCap, // BAD: AdminCap should not be a required input for user flows
pool: &mut Pool,
coin: Coin<SUI>,
ctx: &mut TxContext,
) { /* ... */ }
Object Capability Patterns and Their Abuse
The capability pattern is idiomatic Sui Move: a “capability” object (often called Cap or AdminCap) confers authority on whoever holds it. The pattern is powerful but abusable in several ways.
Correct Capability Usage
module example::vault {
use sui::object::{Self, UID};
use sui::tx_context::TxContext;
public struct AdminCap has key, store { id: UID }
public struct Vault has key {
id: UID,
balance: u64,
}
/// Only the holder of AdminCap can withdraw.
public fun withdraw(
_cap: &AdminCap,
vault: &mut Vault,
amount: u64,
): u64 {
assert!(vault.balance >= amount, 0);
vault.balance = vault.balance - amount;
amount
}
}
The runtime enforces that the caller actually owns AdminCap (it is an address-owned object in their wallet). No msg.sender == owner check needed in code; possession is authorization.
Capability Abuse Pattern 1: Transferable Capability Leak
If AdminCap has the store ability, it can be placed inside any other object or transferred freely. A contract that mints AdminCap inside a public init function and returns it to ctx.sender() is safe at genesis, but if AdminCap is accidentally transferred to a shared object or wrapped inside a publicly accessible object, authority escapes.
// DANGEROUS: returning a cap with `store` from a public entry function
// allows the caller to arbitrarily transfer it.
public entry fun initialize(ctx: &mut TxContext) {
let cap = AdminCap { id: object::new(ctx) };
transfer::transfer(cap, tx_context::sender(ctx));
// If the deployer immediately calls transfer::share_object(cap) —
// anyone in the world can use it.
}
Mitigation: Use one-time witness (OTW) patterns for capability creation. Never give AdminCap the store ability unless transferability is intentionally designed.
Capability Abuse Pattern 2: Capability Forgery via Phantom Type Parameters
A function gated on _cap: &Cap<T> is only as secure as the distinctness of T. If T is not constrained to a module-local witness type, an attacker can instantiate Cap<AttackerModule::Witness> and pass it wherever Cap<T> is accepted generically.
// VULNERABLE: T is unconstrained
public fun admin_action<T>(_cap: &Cap<T>, pool: &mut Pool) { /* ... */ }
// SAFE: T must have `drop`, and only the defining module can create Witness
public struct Witness has drop {}
public fun admin_action(_cap: &Cap<Witness>, pool: &mut Pool) { /* ... */ }
Capability Abuse Pattern 3: Hot-Potato Without Enforcement
A “hot potato” is a struct with no abilities (no key, drop, copy, or store) that must be consumed within the same PTB. It is used to enforce multi-step flows. A common mistake is adding drop for testing convenience and forgetting to remove it, which lets callers discard the potato without completing the required step.
// Receipt must be returned to finalize — no abilities means it cannot be dropped.
public struct Receipt { amount: u64 }
// MISTAKE: adding `drop` for tests breaks the invariant in production
// public struct Receipt has drop { amount: u64 }
public fun borrow(pool: &mut Pool, amount: u64): (Coin<SUI>, Receipt) { /* ... */ }
public fun repay(pool: &mut Pool, coin: Coin<SUI>, receipt: Receipt) { /* ... */ }
Shared Object Contention and Security Implications
When multiple transactions reference the same shared object as a mutable input, Sui’s consensus layer must totally order them. This creates contention that adversaries can exploit.
Front-Running via Contention
Unlike EVM’s mempool-based front-running (where validators reorder transactions), Sui’s consensus is generally fair-ordered. However, a validator set that is not perfectly Byzantine-fault-tolerant can still exhibit ordering bias. More practically, an attacker who submits a high volume of competing transactions against a shared AMM pool creates a statistical front-running advantage through sheer volume.
Denial-of-Service via Shared Object Spam
Because any transaction can declare a shared object as input and will be sequenced through consensus, an adversary can spam low-gas transactions against a critical shared object, congesting the sequencing pipeline and degrading throughput for legitimate users.
// A protocol that charges zero fees for interacting with a shared object
// is trivially DoSable.
public entry fun free_ping(state: &mut GlobalState) {
state.counter = state.counter + 1;
// No fee, no rate limit — attacker submits millions of these.
}
Mitigations:
- Charge meaningful fees for any shared-object mutation.
- Use rate-limiting objects (owned per-user) that must be co-presented with the shared object. The rate-limiting object is owned, so each user can only submit one transaction at a time.
- Prefer object-local state (address-owned receipts, tickets) over shared counters wherever possible.
Epoch-Boundary Races
Sui divides time into epochs during which the validator committee is fixed. Certain protocol actions (staking, unstaking, governance votes) are only valid within specific epochs. A transaction submitted just before epoch rollover may be ordered in either epoch, depending on network conditions.
// Checking epoch inline is insufficient for time-critical operations
public fun time_locked_action(
state: &mut State,
ctx: &TxContext,
) {
let current_epoch = tx_context::epoch(ctx);
assert!(current_epoch >= state.unlock_epoch, E_TOO_EARLY);
// An attacker can submit this in the last milliseconds of unlock_epoch - 1
// and have it land in unlock_epoch due to network latency.
}
Dynamic Fields and Their Risks
Sui’s dynamic fields allow objects to be extended with key-value pairs at runtime, where keys and values are themselves typed Move values. This is fundamentally more expressive than fixed struct fields, but it introduces several risks.
Dynamic Field Basics
use sui::dynamic_field as df;
use sui::dynamic_object_field as dof;
public fun attach_metadata(
parent: &mut MyObject,
metadata: Metadata,
) {
// key is the type `vector<u8>` with value b"metadata"
df::add(&mut parent.id, b"metadata", metadata);
}
public fun read_metadata(parent: &MyObject): &Metadata {
df::borrow(&parent.id, b"metadata")
}
Risk 1: Key Collision
Dynamic field keys are typed; the key is (type_tag, bcs_bytes). Two modules can add fields with the same runtime key if the types are structurally identical. An adversary who controls a “plugin” module might add a field that shadows or corrupts a field expected by the core module.
// Core module adds a field with key of type u64, value 0
df::add(&mut obj.id, 0u64, CoreData { important: true });
// Attacker module also adds key of type u64, value 0 — ABORTS because
// field already exists, but if the attacker goes first, core logic fails.
df::add(&mut obj.id, 0u64, AttackerData { important: false });
Mitigation: Use a module-specific struct as the key type, not primitive types:
public struct CoreFieldKey has copy, drop, store {}
df::add(&mut obj.id, CoreFieldKey {}, CoreData { important: true });
Risk 2: Orphaned Dynamic Fields
If an object is deleted without removing its dynamic fields, those child objects become permanently inaccessible — effectively burned. The Move runtime does not automatically clean up dynamic fields on parent deletion.
// WRONG: deleting parent without cleaning children
public fun destroy_pool(pool: Pool) {
let Pool { id, .. } = pool;
object::delete(id); // Any dynamic fields on `id` are now orphaned forever.
}
// CORRECT: remove all dynamic fields before deletion
public fun destroy_pool(pool: Pool) {
let Pool { id, .. } = pool;
// Remove every known dynamic field first.
let _config: Config = df::remove(&mut id, ConfigKey {});
object::delete(id);
}
Risk 3: Type Confusion on Borrow
df::borrow<K, V> panics if the value stored under key K is not of type V. If field values can be populated by untrusted callers, a type mismatch causes an abort, which can be used as a denial-of-service vector in shared-object contexts.
Risk 4: Dynamic Object Fields and Ownership Confusion
dynamic_object_field::add stores a child object under a parent. The child acquires object-owned status. If the parent is later transferred, the new owner gains implicit authority over all its dynamic object field children — a behavior that may surprise protocol designers who assumed field contents were independently owned.
Common Vulnerability Patterns Specific to Sui Contracts
1. Missing Object Identity Validation
Because objects of the same type are interchangeable from a type-system perspective, functions must explicitly check object::id(&obj) == expected_id when a specific object instance is required.
// VULNERABLE: any Pool object passes the type check
public fun privileged_action(pool: &mut Pool, _cap: &AdminCap) { /* ... */ }
// SAFE: verify the pool is the expected one
public fun privileged_action(
pool: &mut Pool,
registry: &Registry,
_cap: &AdminCap,
) {
assert!(object::id(pool) == registry.canonical_pool_id, E_WRONG_POOL);
}
2. Inconsistent Coin Handling
Sui’s Coin<T> is an owned object representing a fungible balance. Because coins are objects, they can be split, merged, and transferred. A common mistake is accepting a Coin<SUI> input and not validating its value, trusting the caller to pass the right amount.
// VULNERABLE: does not assert coin.value() == required_fee
public fun pay_fee(fee_coin: Coin<SUI>, ctx: &mut TxContext) {
transfer::transfer(fee_coin, FEE_RECIPIENT);
// Attacker passes a coin with value 1 MIST.
}
// SAFE
public fun pay_fee(fee_coin: Coin<SUI>, ctx: &mut TxContext) {
assert!(coin::value(&fee_coin) >= REQUIRED_FEE, E_INSUFFICIENT_FEE);
transfer::transfer(fee_coin, FEE_RECIPIENT);
}
3. Privilege Escalation Through Object Wrapping
Wrapping an object inside another changes its ownership chain. A contract that allows arbitrary objects to be wrapped into a privileged container may inadvertently grant the container owner authority over those objects.
// VULNERABLE: any Token can be wrapped into an AdminVault
public fun wrap(vault: &mut AdminVault, token: Token) {
dof::add(&mut vault.id, object::id(&token), token);
}
// The AdminVault owner can now extract any Token ever deposited here.
4. Unchecked transfer::public_transfer
transfer::public_transfer can transfer any object with the store ability without requiring a contract-specific transfer function. Protocol designers who expect to control transfer through a custom function must not give sensitive objects the store ability.
// If MembershipNFT has `store`, anyone can call
// transfer::public_transfer(nft, @anyone) — bypassing royalty or
// access-control hooks.
public struct MembershipNFT has key { // No `store` — transfers must go through our contract.
id: UID,
tier: u8,
}
5. Event Spoofing
Sui events are emitted via event::emit<T>(value). Because events are not consensus-validated state, a contract that emits a Deposited { amount: X } event without actually performing the deposit creates an off-chain indexing discrepancy that bridges and custodians may trust incorrectly.
6. Randomness Misuse
sui::random provides on-chain randomness, but consuming randomness in a shared-object context creates a subtlety: if the random outcome determines whether a transaction proceeds, an adversary who knows the randomness epoch seed (after the fact) can selectively submit only beneficial transactions. Use commit-reveal schemes or ensure that random outcomes are consumed atomically within a single PTB.
use sui::random::{Self, Random};
// PROBLEMATIC in shared-object lotteries: the caller can observe the seed
// and choose not to submit if the outcome is unfavorable.
public fun lottery_spin(
r: &Random,
pool: &mut LotteryPool,
ctx: &mut TxContext,
) {
let mut gen = random::new_generator(r, ctx);
let roll = random::generate_u8_in_range(&mut gen, 1, 100);
if (roll > 50) {
// payout
}
}
7. Version Guard Omission in Upgradeable Contracts
Sui supports package upgrades. A contract that does not embed a version field in its shared objects cannot enforce that callers use the latest package version. Old entry points in deprecated package versions remain callable indefinitely unless explicitly guarded.
public struct Protocol has key {
id: UID,
version: u64,
}
const CURRENT_VERSION: u64 = 2;
public fun assert_version(protocol: &Protocol) {
assert!(protocol.version == CURRENT_VERSION, E_WRONG_VERSION);
}
Sui Security Checklist
Use this checklist before auditing or deploying any Sui Move contract.
Object Model
- Every object has a clearly defined and intentional ownership mode (owned, shared, immutable, object-owned).
- Objects that should not be publicly transferable do not have the
storeability. - Immutable objects contain validated, correct configuration before
freeze_objectis called. - Dynamic fields use module-specific key types, not bare primitives.
- All dynamic fields are removed before a parent object is deleted.
Access Control
- Capability objects are created via one-time witness (OTW) or equivalent uniqueness mechanism.
- Capabilities that should not be transferable do not have the
storeability. - Generic capability functions constrain type parameters to module-local witness types.
- Hot-potato structs have zero abilities; verify no abilities are accidentally added.
- Privileged entry points validate object identity (
object::id) when a specific instance is required.
Shared Objects
- Every mutable entry point on a shared object has explicit access-control logic.
- Interactions with shared objects charge non-trivial fees to deter spam.
- Rate-limiting is enforced via per-user owned objects, not counters on the shared object.
- Epoch-boundary conditions are handled with sufficient safety margins.
Sponsored Transactions
- Sponsor signatures are not used as implicit authorization for privileged actions.
- Sponsored transactions have appropriate expiration epochs.
- User-facing sponsored flows are clearly separated from admin flows.
Coin and Token Handling
- All
Coin<T>inputs are validated for exact or minimum required value. - Coin splits and merges are balanced; no value is silently created or destroyed.
- Zero-value coins are handled gracefully (or rejected explicitly).
Upgrades and Versioning
- Shared objects embed a
versionfield checked againstCURRENT_VERSIONon every entry. - Deprecated entry points in old package versions either abort or are documented as intentionally retained.
- Migration functions for shared object state are protected by
AdminCap.
Events and Off-Chain Integrity
- Every event emission is causally coupled to the corresponding state change in the same transaction.
- Off-chain indexers are not trusted as the sole source of truth for on-chain balances.
Randomness
- On-chain randomness is consumed within a single atomic PTB; callers cannot selectively abort.
- Commit-reveal schemes are used wherever the caller has an incentive to abort on unfavorable outcomes.
General Move Safety
- Integer arithmetic uses checked operations or the Move prover is used to establish absence of overflow.
-
assert!error codes are distinct, documented, and not reused across modules. -
public(friend)is used sparingly; friend declarations are reviewed for privilege escalation paths. - Test-only code paths (functions with
#[test_only]) are never reachable in production bytecode.
Sui’s object model is genuinely a step forward for smart contract security: owned objects are reentrancy-resistant by construction, and explicit input declaration eliminates an entire class of dynamic-dispatch attacks. But the model trades familiar EVM pitfalls for a new set of subtleties — capability leaks, dynamic field orphaning, shared-object spam, and version guard omissions are uniquely Sui problems that require Sui-specific analysis. Internalizing the ownership semantics deeply, auditing every ability annotation, and applying the checklist above are the practical foundations of secure Sui contract development.