Capability-Model Pitfalls in Move: Three Patterns We See in Audit
Move's capability and object model is safer than Solidity by default, but it has its own footguns. Three patterns that repeatedly show up during our audits of Sui and Aptos protocols.
Move’s type system and object/capability model catch a lot of bugs that Solidity lets through — reentrancy by construction, no double-spend, no gas re-entry tricks. New developers coming to Move from Solidity often assume that means the platform is secure-by-default. It is more secure by default, but it is not bug-free. The bugs are just different, and they tend to be subtler.
Here are three patterns we see repeatedly in audit engagements.
1. Capabilities as bearer tokens without transfer restrictions
A capability in Move is typically a resource struct granting the holder some privileged action — minting, upgrading, closing a market, pausing transfers. The mistake we see most often is treating a capability purely as proof of identity at creation time, without considering what happens after creation.
Consider a pattern like:
struct AdminCap has key, store {
id: UID,
}
public fun pause(cap: &AdminCap, market: &mut Market) {
market.paused = true;
}
This looks secure — only the holder of AdminCap can call pause. But the store ability means the capability is transferable. If the admin’s wallet is compromised, if the capability is accidentally sent to a wrong address, or if a frontend bug causes it to be wrapped in a transferable object — the privilege moves with the token.
We have seen real deployments where:
AdminCapwas stored in a shared object by accident, making anyone an admin.- Capability was sent to a DEX router address during a UI copy-paste error, permanently freezing admin functions.
- Capability was made
storefor “future flexibility,” then inherited into a game object that players could trade.
Defense: Drop store from capability structs unless transfer is genuinely part of the design. If it is, add explicit transfer_admin functions with two-step handoff (pending-then-accepted) and events.
2. Object confusion in shared vs. owned design
Move forces you to decide whether each object is owned (controlled by a single address) or shared (consumable by anyone). This is a powerful invariant. The bug we see most often is protocols mixing the two within a single function, without recognizing that shared objects create a concurrency surface that owned objects do not.
A classic example: a lending market where liquidations operate on a shared Market object and take an owned Position as input.
public fun liquidate(
market: &mut Market, // shared
position: Position, // owned by victim
liquidator: &mut Coin<USDC>,
) { ... }
Because Market is shared, multiple liquidators can enter this function in the same Sui checkpoint with different Position inputs. If the bonus calculation uses market.total_collateral as a denominator, and the first-committed transaction updates it, the second transaction sees a different state — potentially one where the bonus has compressed to zero or expanded beyond the intended cap.
This is not reentrancy in the Solidity sense. It is order-of-commit sensitivity in a parallel execution environment, and it is a new class of bug for auditors coming from EVM.
Defense: For any shared-object function whose outcome depends on aggregated state, either (a) make the aggregate fully determined by immutable fields, or (b) introduce a sequence number and require callers to assert it — failing cleanly if the state moved under them.
3. Event emission without on-chain assertions
Move protocols often emit events to signal governance actions, admin changes, or parameter updates. The footgun is relying on event emission as a security boundary — assuming that off-chain consumers (indexers, monitoring, alerting) will catch anomalies.
They sometimes will. But events are advisory, not enforcing. If an AdminChanged event fires only inside the change_admin function and not on other paths that set the admin field, an attacker who finds an alternate path to admin change escapes all monitoring that was wired to the event.
We have audited protocols where:
- A governance proposal execution path updated admin without firing the event.
- An upgrade path restored admin to a hardcoded default without firing the event.
- A deprecated “emergency” function existed for legacy reasons and bypassed events entirely.
Defense: Make events assertions about post-state, not narrations of intent. The pattern is: finish the state change, then emit the event from the unique function that executes the change — and prove during audit that no alternate path exists.
Takeaway
Move’s type system is a force multiplier for safety, but it does not make audit unnecessary — it makes audit subtler. The bugs we find in Move are rarely “you forgot a check.” They are usually “you implicitly assumed an invariant that the object/capability model does not enforce on your behalf.”
When you request a Move audit from us, our first half-day is usually spent mapping exactly which invariants are enforced by the type system, which are enforced by runtime checks, and which are assumed by developer convention alone. The gap between the second and third is where the bugs live.