22. Unsafe Option Authority

Overview

Unsafe Option authority occurs when developers use Option<T> types to toggle permissions or authority states, creating vulnerabilities where attackers can manipulate authorization by extracting, replacing, or exploiting the optional nature of authority objects. This pattern is particularly dangerous when capabilities or access tokens are wrapped in Option types.

Risk Level

High — Can lead to privilege escalation or unauthorized access.

OWASP / CWE Mapping

OWASP Top 10 MITRE CWE
A04 (Insecure Design) CWE-696 (Incorrect Behavior Order), CWE-693 (Protection Mechanism Failure)

The Problem

Common Option Authority Issues

Issue Risk Description
Mutable Option capability Critical Authority can be extracted and replaced
None as “no permission” High Missing != denied, logic can be bypassed
Option in shared objects High Race conditions on authority state
fill/extract patterns Medium Authority can be temporarily removed

Vulnerable Example

module vulnerable::vault {
    use sui::object::{Self, UID};
    use sui::tx_context::{Self, TxContext};
    use sui::transfer;
    use sui::coin::{Self, Coin};
    use sui::balance::{Self, Balance};
    use std::option::{Self, Option};

    const E_NOT_AUTHORIZED: u64 = 1;
    const E_NO_ADMIN: u64 = 2;

    public struct AdminCap has key, store {
        id: UID,
    }

    public struct Vault<phantom T> has key {
        id: UID,
        balance: Balance<T>,
        /// VULNERABLE: Admin stored as Option in shared object
        admin_cap: Option<AdminCap>,
        withdraw_enabled: bool,
    }

    /// VULNERABLE: Admin can be extracted
    public fun extract_admin(vault: &mut Vault<SUI>): AdminCap {
        assert!(option::is_some(&vault.admin_cap), E_NO_ADMIN);
        option::extract(&mut vault.admin_cap)
    }

    /// VULNERABLE: Anyone can fill when empty
    public fun set_admin(vault: &mut Vault<SUI>, cap: AdminCap) {
        // If admin was extracted, anyone can become admin!
        assert!(option::is_none(&vault.admin_cap), E_NOT_AUTHORIZED);
        option::fill(&mut vault.admin_cap, cap);
    }

    /// VULNERABLE: Check passes when Option is None
    public entry fun emergency_withdraw<T>(
        vault: &mut Vault<T>,
        ctx: &mut TxContext
    ) {
        // Attacker extracts admin, making this None
        // Then this check becomes meaningless
        if (option::is_some(&vault.admin_cap)) {
            // Only check if admin exists - but it was extracted!
            let admin = option::borrow(&vault.admin_cap);
            // No actual verification of caller
        };

        // Withdraw proceeds even without proper auth
        let amount = balance::value(&vault.balance);
        let coins = coin::take(&mut vault.balance, amount, ctx);
        transfer::public_transfer(coins, tx_context::sender(ctx));
    }
}

module vulnerable::toggle_auth {
    use sui::object::{Self, UID};
    use std::option::{Self, Option};

    public struct Permission has store, drop {}

    public struct Resource has key {
        id: UID,
        /// VULNERABLE: Permission as toggle
        permission: Option<Permission>,
        data: vector<u8>,
    }

    /// VULNERABLE: Toggle-based auth can be manipulated
    public fun modify_if_permitted(
        resource: &mut Resource,
        new_data: vector<u8>
    ) {
        // Attacker can swap in their own Permission
        if (option::is_some(&resource.permission)) {
            resource.data = new_data;
        }
        // Also: if Permission has `drop`, it can be destroyed
        // leaving permanent "no permission" state
    }

    /// VULNERABLE: Permission can be stolen via swap
    public fun swap_permission(
        resource: &mut Resource,
        new_perm: Permission
    ): Option<Permission> {
        // Returns the old permission to caller!
        let old = option::swap(&mut resource.permission, new_perm);
        option::some(old)
    }
}

Attack Scenario

module attack::option_exploit {
    use vulnerable::vault::{Self, Vault, AdminCap};
    use sui::tx_context::TxContext;

    /// Step 1: Extract admin during legitimate operation
    public fun steal_admin(vault: &mut Vault<SUI>): AdminCap {
        // If we can call extract, we become the admin holder
        vault::extract_admin(vault)
    }

    /// Step 2: Vault now has no admin, emergency_withdraw check fails open
    public entry fun drain_vault(
        vault: &mut Vault<SUI>,
        ctx: &mut TxContext
    ) {
        // Admin check sees None, doesn't properly deny access
        vault::emergency_withdraw(vault, ctx);
    }

    /// Alternative: Front-run admin reinsertion
    public entry fun become_admin(
        vault: &mut Vault<SUI>,
        ctx: &mut TxContext
    ) {
        // Create our own admin cap
        let fake_admin = AdminCap {
            id: object::new(ctx)
        };
        // Race to fill the empty slot
        vault::set_admin(vault, fake_admin);
    }
}

Secure Example

module secure::vault {
    use sui::object::{Self, UID, ID};
    use sui::tx_context::{Self, TxContext};
    use sui::transfer;
    use sui::coin::{Self, Coin};
    use sui::balance::{Self, Balance};

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

    /// SECURE: Capability is a separate object, not embedded
    public struct AdminCap has key {
        id: UID,
        vault_id: ID,  // Bound to specific vault
    }

    public struct Vault<phantom T> has key {
        id: UID,
        balance: Balance<T>,
        admin: address,  // Store admin address, not capability
        withdraw_enabled: bool,
    }

    fun init(ctx: &mut TxContext) {
        let vault = Vault {
            id: object::new(ctx),
            balance: balance::zero(),
            admin: tx_context::sender(ctx),
            withdraw_enabled: true,
        };

        let vault_id = object::id(&vault);

        let admin_cap = AdminCap {
            id: object::new(ctx),
            vault_id,
        };

        transfer::share_object(vault);
        transfer::transfer(admin_cap, tx_context::sender(ctx));
    }

    /// SECURE: Requires capability proof, not Option check
    public entry fun emergency_withdraw<T>(
        cap: &AdminCap,
        vault: &mut Vault<T>,
        ctx: &mut TxContext
    ) {
        // Verify cap matches vault
        assert!(cap.vault_id == object::id(vault), E_WRONG_VAULT);

        let amount = balance::value(&vault.balance);
        let coins = coin::take(&mut vault.balance, amount, ctx);
        transfer::public_transfer(coins, tx_context::sender(ctx));
    }

    /// SECURE: Transfer admin to new address
    public entry fun transfer_admin(
        cap: AdminCap,
        vault: &mut Vault<SUI>,
        new_admin: address,
        ctx: &mut TxContext
    ) {
        assert!(cap.vault_id == object::id(vault), E_WRONG_VAULT);
        vault.admin = new_admin;
        transfer::transfer(cap, new_admin);
    }
}

module secure::optional_feature {
    use sui::object::{Self, UID, ID};
    use sui::tx_context::{Self, TxContext};
    use std::option::{Self, Option};

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

    /// SECURE: Feature flag is a simple bool, not authority
    public struct FeatureConfig has key {
        id: UID,
        owner: address,
        premium_enabled: bool,
        max_operations: Option<u64>,  // Optional limit, not authority
    }

    /// SECURE: Authority check is separate from optional config
    public entry fun use_premium_feature(
        config: &FeatureConfig,
        ctx: &TxContext
    ) {
        // First: verify ownership (authority)
        assert!(tx_context::sender(ctx) == config.owner, E_NOT_OWNER);

        // Then: check feature flag (configuration)
        assert!(config.premium_enabled, E_FEATURE_DISABLED);

        // Optional config affects behavior, not authorization
        let limit = if (option::is_some(&config.max_operations)) {
            *option::borrow(&config.max_operations)
        } else {
            1000  // Default limit
        };

        // Proceed with operation...
    }
}

Safe Option Patterns

Pattern 1: Option for Optional Data, Not Authority

module safe::optional_data {
    use std::option::{Self, Option};

    public struct UserProfile has key {
        id: UID,
        owner: address,           // Authority: non-optional
        name: vector<u8>,         // Required field
        bio: Option<vector<u8>>,  // Optional data - OK
        avatar_url: Option<vector<u8>>,  // Optional data - OK
    }

    /// Safe: Option used for optional data, not permissions
    public fun get_bio(profile: &UserProfile): Option<vector<u8>> {
        profile.bio
    }

    /// Safe: Authority check uses address, not Option
    public fun update_bio(
        profile: &mut UserProfile,
        new_bio: Option<vector<u8>>,
        ctx: &TxContext
    ) {
        assert!(tx_context::sender(ctx) == profile.owner, E_NOT_OWNER);
        profile.bio = new_bio;
    }
}

Pattern 2: Capability References, Never Embedded Options

module safe::capability_pattern {
    use sui::object::{Self, UID, ID};

    public struct AdminCap has key {
        id: UID,
        resource_id: ID,
    }

    public struct ManagedResource has key {
        id: UID,
        // NO Option<AdminCap> here!
        // Admin holds cap separately
        data: vector<u8>,
    }

    /// Capability passed as reference, not extracted from Option
    public fun admin_modify(
        cap: &AdminCap,
        resource: &mut ManagedResource,
        new_data: vector<u8>
    ) {
        assert!(cap.resource_id == object::id(resource), E_WRONG_RESOURCE);
        resource.data = new_data;
    }
}

Pattern 3: Immutable Authority with Optional Delegation

module safe::delegation {
    use sui::object::{Self, UID, ID};
    use std::option::{Self, Option};

    public struct PrimaryAdmin has key {
        id: UID,
        resource_id: ID,
    }

    public struct DelegatedAdmin has key {
        id: UID,
        resource_id: ID,
        delegated_by: ID,
        expires_at: u64,
    }

    public struct Resource has key {
        id: UID,
        primary_admin: address,  // Immutable primary authority
        // Delegation is separate objects, not Options
        data: vector<u8>,
    }

    /// Primary admin always works
    public fun primary_modify(
        cap: &PrimaryAdmin,
        resource: &mut Resource,
        new_data: vector<u8>
    ) {
        assert!(cap.resource_id == object::id(resource), E_WRONG_RESOURCE);
        resource.data = new_data;
    }

    /// Delegated admin requires valid, non-expired delegation
    public fun delegated_modify(
        cap: &DelegatedAdmin,
        resource: &mut Resource,
        new_data: vector<u8>,
        clock: &Clock
    ) {
        assert!(cap.resource_id == object::id(resource), E_WRONG_RESOURCE);
        assert!(clock::timestamp_ms(clock) < cap.expires_at, E_DELEGATION_EXPIRED);
        resource.data = new_data;
    }
}

Pattern 4: Option for Grace Periods, Not Access

module safe::grace_period {
    use std::option::{Self, Option};
    use sui::clock::{Self, Clock};

    public struct Subscription has key {
        id: UID,
        owner: address,
        active: bool,
        expires_at: u64,
        grace_period_end: Option<u64>,  // Optional extension, not authority
    }

    public fun can_access(
        sub: &Subscription,
        clock: &Clock,
        ctx: &TxContext
    ): bool {
        // Authority: must be owner
        if (tx_context::sender(ctx) != sub.owner) {
            return false
        };

        if (!sub.active) {
            return false
        };

        let now = clock::timestamp_ms(clock);

        // Active subscription
        if (now < sub.expires_at) {
            return true
        };

        // Check grace period (optional feature, not authority)
        if (option::is_some(&sub.grace_period_end)) {
            let grace_end = *option::borrow(&sub.grace_period_end);
            return now < grace_end
        };

        false
    }
}

1. Never Store Capabilities in Option

// BAD: Capability in Option can be extracted
public struct Bad has key {
    admin: Option<AdminCap>,
}

// GOOD: Capability is separate object
public struct Good has key {
    admin_address: address,
}

2. Use Address or ID for Authority Reference

// Store who has authority, not the authority itself
public struct Resource has key {
    id: UID,
    owner: address,
    admin_cap_id: ID,  // Reference, not embedded
}

3. Require Capability Proof, Not Option Check

// BAD: Check if Option contains value
if (option::is_some(&resource.admin)) { ... }

// GOOD: Require capability as parameter
public fun admin_action(cap: &AdminCap, resource: &mut Resource) {
    assert!(cap.resource_id == object::id(resource), E_WRONG_CAP);
}

4. Use Option Only for Optional Data

// GOOD: Optional configuration data
public struct Config has key {
    max_limit: Option<u64>,     // Optional setting
    description: Option<String>, // Optional metadata
}

Testing Checklist

  • Verify no capabilities are stored in Option types
  • Test that extracting optional values doesn’t bypass auth
  • Confirm authority checks don’t rely on Option::is_some
  • Test race conditions on shared objects with Option fields
  • Verify None state doesn’t grant unexpected access
  • Check that swap/extract patterns can’t steal authority