Objects with key + store abilities can be frozen by any holder using sui::transfer::public_freeze_object. Once frozen, an object becomes immutable and globally readable. This can be exploited to permanently disable critical protocol functionality or expose sensitive data.
Risk Level
High — Can permanently break protocol functionality with no recovery path.
When you expose an object by value (returning it from a function), the caller gains full control including the ability to freeze it. If that object is your protocol’s treasury, configuration, or any mutable state, freezing it permanently disables all mutations.
Frozen Object Characteristics
Immutable forever — No function can ever mutate the object again
Globally accessible — Anyone can read the frozen object’s data
No recovery — There is no “unfreeze” operation in Sui
Vulnerable Example
modulevulnerable::treasury{usesui::object::{Self,UID};usesui::tx_context::TxContext;usesui::transfer;usesui::coin::{Self,Coin};usesui::sui::SUI;publicstructTreasuryhaskey,store{id: UID,funds: Coin<SUI>,fee_bps: u64,}publicstructStatehaskey{id: UID,treasury: Treasury,// Treasury stored inline
}/// VULNERABLE: Returns treasury by value!
/// Caller can freeze it, permanently breaking the protocol
publicfuntake_treasury(state: &mutState): Treasury{lettreasury=state.treasury;// Move out
// ... some logic
treasury// Returns by value — caller now owns it!
}/// VULNERABLE: Exposes treasury for "temporary" use
publicfunborrow_treasury_unsafe(state: &mutState): Treasury{state.treasury// Caller gets ownership!
}/// Deposit funds — will fail if treasury is frozen
publicentryfundeposit(state: &mutState,payment: Coin<SUI>){coin::join(&mutstate.treasury.funds,payment);}}
Attack Scenario
// Attacker's transaction
moduleattack::freeze_treasury{usevulnerable::treasury;usesui::transfer;publicentryfunattack(state: &muttreasury::State){// Step 1: Extract treasury by value
lettreasury=treasury::take_treasury(state);// Step 2: Freeze it permanently
transfer::public_freeze_object(treasury);// Protocol is now permanently broken!
// No deposits, withdrawals, or fee changes possible
}}
Secure Example
modulesecure::treasury{usesui::object::{Self,UID};usesui::tx_context::TxContext;usesui::coin::{Self,Coin};usesui::sui::SUI;/// SECURE: No `store` ability — cannot be frozen by external callers
publicstructTreasuryhaskey{id: UID,funds: Coin<SUI>,fee_bps: u64,}publicstructAdminCaphaskey{id: UID,}publicstructStatehaskey{id: UID,treasury_id: object::ID,// Store reference, not object
}/// SECURE: Only exposes immutable reference
publicfunget_balance(treasury: &Treasury): u64{coin::value(&treasury.funds)}/// SECURE: Only exposes mutable reference, not ownership
publicfundeposit(treasury: &mutTreasury,payment: Coin<SUI>){coin::join(&muttreasury.funds,payment);}/// SECURE: Withdrawal requires capability and returns Coin, not Treasury
publicfunwithdraw(_cap: &AdminCap,treasury: &mutTreasury,amount: u64,ctx: &mutTxContext): Coin<SUI>{coin::split(&muttreasury.funds,amount,ctx)}/// If freezing is intentional, make it explicit and controlled
publicentryfunintentional_freeze(_cap: AdminCap,// Consume the cap
treasury: Treasury){// Only admin can freeze, and it's a deliberate action
sui::transfer::freeze_object(treasury);}}
Recommended Mitigations
1. Remove store from Critical Objects
/// Without `store`, public_freeze_object cannot be called
publicstructTreasuryhaskey{id: UID,// ...
}
usesui::dynamic_fieldasdf;publicstructStatehaskey{id: UID,}/// Treasury is stored as dynamic field — not directly accessible
funinit(ctx: &mutTxContext){letstate=State{id: object::new(ctx)};df::add(&mutstate.id,b"treasury",Treasury{id: object::new(ctx),funds: coin::zero(ctx),fee_bps: 100,});transfer::share_object(state);}/// Access only via controlled functions
publicfundeposit(state: &mutState,payment: Coin<SUI>){lettreasury: &mutTreasury=df::borrow_mut(&mutstate.id,b"treasury");coin::join(&muttreasury.funds,payment);}
4. Separate Data from Control
/// Freeze-safe pattern: separate mutable data from immutable config
publicstructTreasuryConfighaskey{id: UID,fee_bps: u64,admin: address,}/// This can be shared and is safe to freeze (config becomes permanent)
publicstructTreasuryVaulthaskey{id: UID,config_id: object::ID,funds: Coin<SUI>,}
Testing Checklist
Verify no functions return critical objects by value
Confirm all state objects lack store ability unless necessary
Test that public_freeze_object cannot be called on protocol-critical objects
Audit all places where objects are moved out of storage