Any address-owned object with key (especially combined with store) can be freely transferred using sui::transfer::transfer or public_transfer. This breaks assumptions about invariants, capability possession, and ownership that your contract may depend on.
Risk Level
High — Can lead to complete bypass of access control mechanisms.
In Sui, objects with key + store abilities can be transferred by their owner to any address. If your contract issues capability objects or admin tokens and assumes they will remain with the original recipient, an attacker can transfer these objects to themselves or others, bypassing your intended access control.
Common Mistakes
Mixing authority models — Using sender() checks in some functions and capability-based checks in others
Assuming capability possession — Expecting that whoever received a capability still holds it
Transferable admin tokens — Creating admin capabilities with store that can be freely moved
Vulnerable Example
modulevulnerable::admin{usesui::object::{Self,UID};usesui::tx_context::{Self,TxContext};usesui::transfer;/// VULNERABLE: This capability has `store`, allowing it to be transferred
publicstructAdminCaphaskey,store{id: UID,}publicstructTreasuryhaskey{id: UID,balance: u64,}/// Issue an admin cap to the deployer
funinit(ctx: &mutTxContext){transfer::transfer(AdminCap{id: object::new(ctx)},tx_context::sender(ctx));}/// VULNERABLE: Mixing sender check with capability-based system
/// An attacker can transfer the AdminCap to themselves and bypass this
publicentryfunset_fee(new_fee: u64,ctx: &mutTxContext){// This check is useless if AdminCap can be transferred!
assert!(tx_context::sender(ctx)==@0xADMIN,0);// ... set fee logic
}/// Anyone with the cap can drain the treasury
publicentryfunwithdraw(_cap: &AdminCap,treasury: &mutTreasury,amount: u64,ctx: &mutTxContext){// Attacker obtains AdminCap via transfer and drains funds
treasury.balance=treasury.balance-amount;}}
Attack Scenario
Admin deploys contract and receives AdminCap
Attacker social-engineers admin or exploits another vulnerability to get AdminCap transferred
Attacker now has full admin access, can drain treasury
Original sender() checks are bypassed because the cap was transferred
Secure Example
modulesecure::admin{usesui::object::{Self,UID};usesui::tx_context::{Self,TxContext};usesui::transfer;/// SECURE: No `store` ability prevents unauthorized transfers
/// Only this module can transfer this capability
publicstructAdminCaphaskey{id: UID,authorized_address: address,}publicstructTreasuryhaskey{id: UID,balance: u64,}funinit(ctx: &mutTxContext){letsender=tx_context::sender(ctx);transfer::transfer(AdminCap{id: object::new(ctx),authorized_address: sender,},sender);}/// SECURE: Verify the capability is held by the authorized address
publicentryfunwithdraw(cap: &AdminCap,treasury: &mutTreasury,amount: u64,ctx: &mutTxContext){// Double-check: capability holder must match authorized address
assert!(tx_context::sender(ctx)==cap.authorized_address,0);treasury.balance=treasury.balance-amount;}/// Explicit transfer function with additional checks
publicentryfuntransfer_admin(cap: AdminCap,new_admin: address,ctx: &mutTxContext){// Only current authorized address can transfer
assert!(tx_context::sender(ctx)==cap.authorized_address,0);// Create new cap with updated authorization
letAdminCap{id,authorized_address: _}=cap;object::delete(id);transfer::transfer(AdminCap{id: object::new(ctx),authorized_address: new_admin,},new_admin);}}
Recommended Mitigations
1. Remove store from Capability Objects
/// Without `store`, only your module can transfer this object
publicstructAdminCaphaskey{id: UID,}
2. Use Consistent Authority Models
Choose one authority model and stick to it:
Capability-based: All authorization via capability objects (recommended)
Address-based: All authorization via sender() checks
Don’t mix them — it creates confusion and security gaps.
3. Embed Authorization in Capabilities
publicstructAdminCaphaskey{id: UID,authorized_address: address,// Track who should hold this
permissions: u64,// Bitmask of allowed operations
}
4. Use Non-Transferable Patterns
/// Soul-bound capability — cannot be transferred at all
publicstructSoulBoundCaphaskey{id: UID,owner: address,}/// Verify ownership in every function
publicfunuse_cap(cap: &SoulBoundCap,ctx: &TxContext){assert!(tx_context::sender(ctx)==cap.owner,E_NOT_OWNER);}
Testing Checklist
Verify all capability objects lack store ability unless intentionally transferable
Confirm no mixing of sender() checks with capability-based authorization
Test that transferred capabilities cannot bypass access controls
Audit all public_transfer and transfer calls for sensitive objects