28. Read API Leakage

Overview

Read API leakage occurs when view functions or public getters expose sensitive information that should remain private. In Sui Move, while direct state access requires ownership, public functions can inadvertently reveal private keys, internal state, user data, or security-critical information. Attackers can use this leaked information to plan attacks, front-run transactions, or compromise user privacy.

Risk Level

Medium to High — Can enable attacks, compromise privacy, or reveal competitive information.

OWASP / CWE Mapping

OWASP Top 10 MITRE CWE
A01 (Broken Access Control) CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor)

The Problem

Common Read API Leakage Issues

Issue Risk Description
Exposing private keys/seeds Critical Admin keys or secrets readable
Revealing pending orders High Front-running opportunity
Leaking user balances Medium Privacy violation
Exposing internal thresholds Medium Attack planning information
Revealing algorithm parameters Medium Gaming/manipulation enablement

Vulnerable Example

module vulnerable::exchange {
    use sui::object::{Self, UID};
    use sui::table::{Self, Table};

    public struct Exchange has key {
        id: UID,
        /// VULNERABLE: Internal fee calculation parameters
        fee_numerator: u64,
        fee_denominator: u64,
        /// VULNERABLE: Liquidation thresholds
        liquidation_threshold_bps: u64,
        /// VULNERABLE: Oracle update key
        oracle_private_seed: vector<u8>,
        /// VULNERABLE: Pending orders visible
        pending_orders: Table<ID, Order>,
        /// VULNERABLE: User position data
        user_positions: Table<address, Position>,
    }

    public struct Order has store {
        owner: address,
        amount: u64,
        price: u64,
        order_type: u8,
        /// VULNERABLE: Stop-loss prices visible
        stop_loss: u64,
        take_profit: u64,
    }

    public struct Position has store {
        collateral: u64,
        debt: u64,
        /// VULNERABLE: Liquidation price calculable
        entry_price: u64,
    }

    /// VULNERABLE: Exposes internal fee parameters
    public fun get_fee_params(exchange: &Exchange): (u64, u64) {
        (exchange.fee_numerator, exchange.fee_denominator)
    }

    /// VULNERABLE: Reveals liquidation thresholds
    public fun get_liquidation_threshold(exchange: &Exchange): u64 {
        exchange.liquidation_threshold_bps
    }

    /// VULNERABLE: Leaks oracle seed!
    public fun get_oracle_config(exchange: &Exchange): vector<u8> {
        exchange.oracle_private_seed
    }

    /// VULNERABLE: Anyone can see pending orders
    public fun get_order(exchange: &Exchange, order_id: ID): &Order {
        table::borrow(&exchange.pending_orders, order_id)
    }

    /// VULNERABLE: Anyone can enumerate all orders
    public fun get_all_order_ids(exchange: &Exchange): vector<ID> {
        // Returns all pending order IDs - front-running goldmine
        table::keys(&exchange.pending_orders)
    }

    /// VULNERABLE: Anyone can see any user's position
    public fun get_user_position(exchange: &Exchange, user: address): &Position {
        table::borrow(&exchange.user_positions, user)
    }

    /// VULNERABLE: Reveals when user will be liquidated
    public fun get_liquidation_price(exchange: &Exchange, user: address): u64 {
        let position = table::borrow(&exchange.user_positions, user);
        // Calculation reveals exact liquidation trigger
        (position.debt * 10000) / (position.collateral * exchange.liquidation_threshold_bps)
    }
}

module vulnerable::auction {
    public struct Auction has key {
        id: UID,
        /// VULNERABLE: Reserve price visible
        reserve_price: u64,
        /// VULNERABLE: All bids visible before reveal
        bids: vector<Bid>,
    }

    public struct Bid has store {
        bidder: address,
        amount: u64,
        /// VULNERABLE: Sealed bid amount visible
        sealed_amount: u64,
    }

    /// VULNERABLE: Reserve price should be hidden
    public fun get_reserve_price(auction: &Auction): u64 {
        auction.reserve_price
    }

    /// VULNERABLE: Sealed bids exposed before reveal phase
    public fun get_all_bids(auction: &Auction): &vector<Bid> {
        &auction.bids
    }
}

module vulnerable::governance {
    public struct Proposal has key {
        id: UID,
        votes_for: u64,
        votes_against: u64,
        /// VULNERABLE: Individual votes visible
        voter_choices: Table<address, bool>,
        /// VULNERABLE: Quorum threshold visible
        quorum_threshold: u64,
    }

    /// VULNERABLE: See how anyone voted
    public fun get_vote(proposal: &Proposal, voter: address): bool {
        *table::borrow(&proposal.voter_choices, voter)
    }

    /// VULNERABLE: Calculate exactly how many votes needed
    public fun votes_until_quorum(proposal: &Proposal): u64 {
        let total = proposal.votes_for + proposal.votes_against;
        if (total >= proposal.quorum_threshold) {
            0
        } else {
            proposal.quorum_threshold - total
        }
    }
}

Attack Scenarios

module attack::frontrun {
    /// Attacker reads pending orders and front-runs
    public entry fun frontrun_large_order(
        exchange: &Exchange,
        order_id: ID,
        ctx: &mut TxContext
    ) {
        // Read victim's order details
        let order = vulnerable::exchange::get_order(exchange, order_id);

        // If large buy order, buy before them
        if (order.amount > 1000000 && order.order_type == BUY) {
            // Execute trade before victim's order fills
            // Sell to victim at higher price
        };
    }

    /// Attacker targets positions near liquidation
    public entry fun hunt_liquidations(
        exchange: &Exchange,
        target: address,
        ctx: &mut TxContext
    ) {
        // Read target's exact liquidation price
        let liq_price = vulnerable::exchange::get_liquidation_price(exchange, target);

        // Push price to trigger liquidation
        // Profit from liquidation bonus
    }
}

Secure Example

module secure::exchange {
    use sui::object::{Self, UID, ID};
    use sui::tx_context::{Self, TxContext};
    use sui::table::{Self, Table};
    use sui::hash;

    const E_NOT_OWNER: u64 = 1;
    const E_NOT_ADMIN: u64 = 2;

    public struct Exchange has key {
        id: UID,
        admin: address,
        /// Internal parameters - no public getters
        fee_numerator: u64,
        fee_denominator: u64,
        liquidation_threshold_bps: u64,
        /// Sensitive data not stored on-chain
        oracle_config_hash: vector<u8>,  // Only hash, not actual seed
        /// Orders indexed by owner
        user_orders: Table<address, vector<ID>>,
        order_data: Table<ID, Order>,
        user_positions: Table<address, Position>,
    }

    public struct Order has store {
        owner: address,
        /// Only store commitment, not actual values
        order_commitment: vector<u8>,  // hash(amount, price, salt)
        created_at: u64,
    }

    public struct Position has store {
        owner: address,
        collateral: u64,
        debt: u64,
        entry_price: u64,
    }

    /// SECURE: Only expose fee rate, not internal calculation params
    public fun get_effective_fee_rate(exchange: &Exchange): u64 {
        // Return calculated rate, not components
        (exchange.fee_numerator * 10000) / exchange.fee_denominator
    }

    /// SECURE: No getter for liquidation threshold
    // Liquidation logic is internal only

    /// SECURE: Oracle config hash only, no secrets
    public fun get_oracle_config_hash(exchange: &Exchange): vector<u8> {
        exchange.oracle_config_hash
    }

    /// SECURE: Only owner can view their own orders
    public fun get_my_orders(
        exchange: &Exchange,
        ctx: &TxContext
    ): vector<ID> {
        let owner = tx_context::sender(ctx);
        if (table::contains(&exchange.user_orders, owner)) {
            *table::borrow(&exchange.user_orders, owner)
        } else {
            vector::empty()
        }
    }

    /// SECURE: Only owner can view their order details
    public fun get_my_order(
        exchange: &Exchange,
        order_id: ID,
        ctx: &TxContext
    ): &Order {
        let order = table::borrow(&exchange.order_data, order_id);
        assert!(order.owner == tx_context::sender(ctx), E_NOT_OWNER);
        order
    }

    /// SECURE: Only owner can view their position
    public fun get_my_position(
        exchange: &Exchange,
        ctx: &TxContext
    ): &Position {
        let owner = tx_context::sender(ctx);
        let position = table::borrow(&exchange.user_positions, owner);
        assert!(position.owner == owner, E_NOT_OWNER);
        position
    }

    /// SECURE: Health check without revealing exact values
    public fun is_position_healthy(
        exchange: &Exchange,
        user: address
    ): bool {
        if (!table::contains(&exchange.user_positions, user)) {
            return true  // No position = healthy
        };

        let position = table::borrow(&exchange.user_positions, user);
        // Return boolean only, not the actual ratio
        let health_ratio = (position.collateral * 10000) / position.debt;
        health_ratio > exchange.liquidation_threshold_bps
    }

    /// SECURE: Admin-only access to sensitive data
    public fun admin_get_liquidation_threshold(
        exchange: &Exchange,
        ctx: &TxContext
    ): u64 {
        assert!(tx_context::sender(ctx) == exchange.admin, E_NOT_ADMIN);
        exchange.liquidation_threshold_bps
    }
}

module secure::auction {
    use sui::object::{Self, UID};
    use sui::hash;

    public struct Auction has key {
        id: UID,
        /// Reserve price stored as commitment
        reserve_price_commitment: vector<u8>,
        /// Sealed bids - only commitments visible
        bid_commitments: vector<BidCommitment>,
        /// Revealed bids (after reveal phase)
        revealed_bids: vector<RevealedBid>,
        phase: u8,  // 0=bidding, 1=reveal, 2=complete
    }

    public struct BidCommitment has store {
        bidder: address,
        commitment: vector<u8>,  // hash(amount, nonce)
        timestamp: u64,
    }

    public struct RevealedBid has store {
        bidder: address,
        amount: u64,
    }

    /// SECURE: Only commitment visible during bidding
    public fun get_bid_count(auction: &Auction): u64 {
        vector::length(&auction.bid_commitments)
    }

    /// SECURE: Actual bids only visible after reveal phase
    public fun get_revealed_bids(auction: &Auction): &vector<RevealedBid> {
        assert!(auction.phase >= 1, E_STILL_BIDDING);
        &auction.revealed_bids
    }

    /// SECURE: Reserve only revealed at end
    public fun get_reserve_price(
        auction: &Auction,
        reserve_salt: vector<u8>
    ): u64 {
        assert!(auction.phase == 2, E_NOT_COMPLETE);
        // Verify caller knows the salt (is the auctioneer)
        // Then reveal reserve
        0  // placeholder
    }
}

module secure::governance {
    use sui::object::{Self, UID};
    use sui::table::{Self, Table};

    public struct Proposal has key {
        id: UID,
        /// Only totals visible, not individual votes
        votes_for: u64,
        votes_against: u64,
        total_voters: u64,
        /// Individual votes are private
        voter_commitments: Table<address, vector<u8>>,
        /// Quorum expressed as percentage, not absolute
        quorum_percentage: u64,
        eligible_voters: u64,
    }

    /// SECURE: Only aggregate data visible
    public fun get_vote_totals(proposal: &Proposal): (u64, u64) {
        (proposal.votes_for, proposal.votes_against)
    }

    /// SECURE: No individual vote visibility
    public fun has_voted(proposal: &Proposal, voter: address): bool {
        table::contains(&proposal.voter_commitments, voter)
    }

    /// SECURE: Quorum status, not exact numbers needed
    public fun has_quorum(proposal: &Proposal): bool {
        let total = proposal.votes_for + proposal.votes_against;
        let required = (proposal.eligible_voters * proposal.quorum_percentage) / 100;
        total >= required
    }

    /// SECURE: Percentage, not absolute votes needed
    public fun get_participation_rate(proposal: &Proposal): u64 {
        let total = proposal.votes_for + proposal.votes_against;
        (total * 100) / proposal.eligible_voters
    }
}

Information Exposure Patterns

Pattern 1: Return Aggregates, Not Details

// BAD: Exposes individual data
public fun get_all_balances(pool: &Pool): &Table<address, u64> {
    &pool.balances
}

// GOOD: Return aggregate only
public fun get_total_liquidity(pool: &Pool): u64 {
    pool.total_liquidity
}

public fun get_participant_count(pool: &Pool): u64 {
    table::length(&pool.balances)
}

Pattern 2: Owner-Only Access

/// Only the data owner can read their data
public fun get_my_data(
    registry: &Registry,
    ctx: &TxContext
): &UserData {
    let user = tx_context::sender(ctx);
    let data = table::borrow(&registry.user_data, user);
    assert!(data.owner == user, E_NOT_OWNER);
    data
}

Pattern 3: Commitment Schemes

/// Store commitment, not actual value
public struct HiddenValue has store {
    commitment: vector<u8>,  // hash(value, salt)
}

public fun create_commitment(value: u64, salt: vector<u8>): vector<u8> {
    let mut data = bcs::to_bytes(&value);
    vector::append(&mut data, salt);
    hash::keccak256(&data)
}

public fun verify_and_reveal(
    hidden: &HiddenValue,
    claimed_value: u64,
    salt: vector<u8>
): u64 {
    let expected = create_commitment(claimed_value, salt);
    assert!(expected == hidden.commitment, E_INVALID_REVEAL);
    claimed_value
}

Pattern 4: Role-Based Access

public fun admin_view_sensitive_data(
    state: &State,
    admin_cap: &AdminCap
): &SensitiveData {
    assert!(admin_cap.state_id == object::id(state), E_WRONG_CAP);
    &state.sensitive_data
}

public fun user_view_own_data(
    state: &State,
    ctx: &TxContext
): &UserData {
    let user = tx_context::sender(ctx);
    table::borrow(&state.user_data, user)
}

// No public getter for cross-user data

Pattern 5: Time-Delayed Revelation

public struct TimeLocked has key {
    id: UID,
    hidden_data: vector<u8>,
    reveal_time: u64,
}

public fun get_data(
    locked: &TimeLocked,
    clock: &Clock
): vector<u8> {
    assert!(clock::timestamp_ms(clock) >= locked.reveal_time, E_TOO_EARLY);
    locked.hidden_data
}

1. Minimize Public Getters

// Only expose what's absolutely necessary
// Ask: "Does the public need this?"
public fun get_total(): u64 { }  // OK: aggregate
// Don't expose: get_user_balance(user: address)

2. Require Ownership for User Data

public fun get_my_balance(ctx: &TxContext): u64 {
    let user = tx_context::sender(ctx);
    // Return only caller's data
}

3. Use Commitments for Sensitive Values

// Store hash during sensitive phase
reserve_price_commitment: vector<u8>,
// Reveal after phase ends
public fun reveal(commitment: vector<u8>, value: u64, salt: vector<u8>)

4. Return Booleans Instead of Values

// BAD: Reveals exact threshold
public fun get_liquidation_threshold(): u64

// GOOD: Reveals only status
public fun is_above_threshold(user: address): bool

5. Audit All Public Functions

// Review every `public fun` for information leakage
// Document what information is exposed and why

Testing Checklist

  • Audit all public functions for sensitive data exposure
  • Verify user data requires ownership to read
  • Check that internal thresholds are not exposed
  • Confirm pending transactions are not enumerable
  • Test that sealed/committed values stay hidden
  • Verify admin-only functions check capability
  • Review aggregates don’t leak individual data
  • Check time-locked data respects reveal time