2. Object Freezing Misuse
Overview
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.
OWASP / CWE Mapping
| OWASP Top 10 | MITRE CWE |
|---|---|
| A01 (Broken Access Control) | CWE-284 (Improper Access Control), CWE-732 (Incorrect Permission Assignment) |
The Problem
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
module vulnerable::treasury {
use sui::object::{Self, UID};
use sui::tx_context::TxContext;
use sui::transfer;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
public struct Treasury has key, store {
id: UID,
funds: Coin<SUI>,
fee_bps: u64,
}
public struct State has key {
id: UID,
treasury: Treasury, // Treasury stored inline
}
/// VULNERABLE: Returns treasury by value!
/// Caller can freeze it, permanently breaking the protocol
public fun take_treasury(state: &mut State): Treasury {
let treasury = state.treasury; // Move out
// ... some logic
treasury // Returns by value — caller now owns it!
}
/// VULNERABLE: Exposes treasury for "temporary" use
public fun borrow_treasury_unsafe(state: &mut State): Treasury {
state.treasury // Caller gets ownership!
}
/// Deposit funds — will fail if treasury is frozen
public entry fun deposit(
state: &mut State,
payment: Coin<SUI>
) {
coin::join(&mut state.treasury.funds, payment);
}
}Attack Scenario
// Attacker's transaction
module attack::freeze_treasury {
use vulnerable::treasury;
use sui::transfer;
public entry fun attack(state: &mut treasury::State) {
// Step 1: Extract treasury by value
let treasury = 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
module secure::treasury {
use sui::object::{Self, UID};
use sui::tx_context::TxContext;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
/// SECURE: No `store` ability — cannot be frozen by external callers
public struct Treasury has key {
id: UID,
funds: Coin<SUI>,
fee_bps: u64,
}
public struct AdminCap has key {
id: UID,
}
public struct State has key {
id: UID,
treasury_id: object::ID, // Store reference, not object
}
/// SECURE: Only exposes immutable reference
public fun get_balance(treasury: &Treasury): u64 {
coin::value(&treasury.funds)
}
/// SECURE: Only exposes mutable reference, not ownership
public fun deposit(
treasury: &mut Treasury,
payment: Coin<SUI>
) {
coin::join(&mut treasury.funds, payment);
}
/// SECURE: Withdrawal requires capability and returns Coin, not Treasury
public fun withdraw(
_cap: &AdminCap,
treasury: &mut Treasury,
amount: u64,
ctx: &mut TxContext
): Coin<SUI> {
coin::split(&mut treasury.funds, amount, ctx)
}
/// If freezing is intentional, make it explicit and controlled
public entry fun intentional_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
public struct Treasury has key {
id: UID,
// ...
}2. Never Return Critical Objects by Value
/// BAD: Returns ownership
public fun get_treasury(state: &mut State): Treasury { ... }
/// GOOD: Returns immutable reference
public fun get_treasury(state: &State): &Treasury { ... }
/// GOOD: Returns mutable reference
public fun get_treasury_mut(state: &mut State): &mut Treasury { ... }3. Use Dynamic Fields for Encapsulation
use sui::dynamic_field as df;
public struct State has key {
id: UID,
}
/// Treasury is stored as dynamic field — not directly accessible
fun init(ctx: &mut TxContext) {
let state = State { id: object::new(ctx) };
df::add(&mut state.id, b"treasury", Treasury {
id: object::new(ctx),
funds: coin::zero(ctx),
fee_bps: 100,
});
transfer::share_object(state);
}
/// Access only via controlled functions
public fun deposit(state: &mut State, payment: Coin<SUI>) {
let treasury: &mut Treasury = df::borrow_mut(&mut state.id, b"treasury");
coin::join(&mut treasury.funds, payment);
}4. Separate Data from Control
/// Freeze-safe pattern: separate mutable data from immutable config
public struct TreasuryConfig has key {
id: UID,
fee_bps: u64,
admin: address,
}
/// This can be shared and is safe to freeze (config becomes permanent)
public struct TreasuryVault has key {
id: UID,
config_id: object::ID,
funds: Coin<SUI>,
}Testing Checklist
- Verify no functions return critical objects by value
- Confirm all state objects lack
storeability unless necessary - Test that
public_freeze_objectcannot be called on protocol-critical objects - Audit all places where objects are moved out of storage