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.
modulevulnerable::vault{usesui::object::{Self,UID};usesui::tx_context::{Self,TxContext};usesui::transfer;usesui::coin::{Self,Coin};usesui::balance::{Self,Balance};usestd::option::{Self,Option};constE_NOT_AUTHORIZED: u64=1;constE_NO_ADMIN: u64=2;publicstructAdminCaphaskey,store{id: UID,}publicstructVault<phantomT>haskey{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
publicfunextract_admin(vault: &mutVault<SUI>): AdminCap{assert!(option::is_some(&vault.admin_cap),E_NO_ADMIN);option::extract(&mutvault.admin_cap)}/// VULNERABLE: Anyone can fill when empty
publicfunset_admin(vault: &mutVault<SUI>,cap: AdminCap){// If admin was extracted, anyone can become admin!
assert!(option::is_none(&vault.admin_cap),E_NOT_AUTHORIZED);option::fill(&mutvault.admin_cap,cap);}/// VULNERABLE: Check passes when Option is None
publicentryfunemergency_withdraw<T>(vault: &mutVault<T>,ctx: &mutTxContext){// 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!
letadmin=option::borrow(&vault.admin_cap);// No actual verification of caller
};// Withdraw proceeds even without proper auth
letamount=balance::value(&vault.balance);letcoins=coin::take(&mutvault.balance,amount,ctx);transfer::public_transfer(coins,tx_context::sender(ctx));}}modulevulnerable::toggle_auth{usesui::object::{Self,UID};usestd::option::{Self,Option};publicstructPermissionhasstore,drop{}publicstructResourcehaskey{id: UID,/// VULNERABLE: Permission as toggle
permission: Option<Permission>,data: vector<u8>,}/// VULNERABLE: Toggle-based auth can be manipulated
publicfunmodify_if_permitted(resource: &mutResource,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
publicfunswap_permission(resource: &mutResource,new_perm: Permission): Option<Permission>{// Returns the old permission to caller!
letold=option::swap(&mutresource.permission,new_perm);option::some(old)}}
Attack Scenario
moduleattack::option_exploit{usevulnerable::vault::{Self,Vault,AdminCap};usesui::tx_context::TxContext;/// Step 1: Extract admin during legitimate operation
publicfunsteal_admin(vault: &mutVault<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
publicentryfundrain_vault(vault: &mutVault<SUI>,ctx: &mutTxContext){// Admin check sees None, doesn't properly deny access
vault::emergency_withdraw(vault,ctx);}/// Alternative: Front-run admin reinsertion
publicentryfunbecome_admin(vault: &mutVault<SUI>,ctx: &mutTxContext){// Create our own admin cap
letfake_admin=AdminCap{id: object::new(ctx)};// Race to fill the empty slot
vault::set_admin(vault,fake_admin);}}
Secure Example
modulesecure::vault{usesui::object::{Self,UID,ID};usesui::tx_context::{Self,TxContext};usesui::transfer;usesui::coin::{Self,Coin};usesui::balance::{Self,Balance};constE_NOT_ADMIN: u64=1;constE_WRONG_VAULT: u64=2;/// SECURE: Capability is a separate object, not embedded
publicstructAdminCaphaskey{id: UID,vault_id: ID,// Bound to specific vault
}publicstructVault<phantomT>haskey{id: UID,balance: Balance<T>,admin: address,// Store admin address, not capability
withdraw_enabled: bool,}funinit(ctx: &mutTxContext){letvault=Vault{id: object::new(ctx),balance: balance::zero(),admin: tx_context::sender(ctx),withdraw_enabled: true,};letvault_id=object::id(&vault);letadmin_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
publicentryfunemergency_withdraw<T>(cap: &AdminCap,vault: &mutVault<T>,ctx: &mutTxContext){// Verify cap matches vault
assert!(cap.vault_id==object::id(vault),E_WRONG_VAULT);letamount=balance::value(&vault.balance);letcoins=coin::take(&mutvault.balance,amount,ctx);transfer::public_transfer(coins,tx_context::sender(ctx));}/// SECURE: Transfer admin to new address
publicentryfuntransfer_admin(cap: AdminCap,vault: &mutVault<SUI>,new_admin: address,ctx: &mutTxContext){assert!(cap.vault_id==object::id(vault),E_WRONG_VAULT);vault.admin=new_admin;transfer::transfer(cap,new_admin);}}modulesecure::optional_feature{usesui::object::{Self,UID,ID};usesui::tx_context::{Self,TxContext};usestd::option::{Self,Option};constE_FEATURE_DISABLED: u64=1;constE_NOT_OWNER: u64=2;/// SECURE: Feature flag is a simple bool, not authority
publicstructFeatureConfighaskey{id: UID,owner: address,premium_enabled: bool,max_operations: Option<u64>,// Optional limit, not authority
}/// SECURE: Authority check is separate from optional config
publicentryfunuse_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
letlimit=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
modulesafe::optional_data{usestd::option::{Self,Option};publicstructUserProfilehaskey{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
publicfunget_bio(profile: &UserProfile): Option<vector<u8>>{profile.bio}/// Safe: Authority check uses address, not Option
publicfunupdate_bio(profile: &mutUserProfile,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
modulesafe::capability_pattern{usesui::object::{Self,UID,ID};publicstructAdminCaphaskey{id: UID,resource_id: ID,}publicstructManagedResourcehaskey{id: UID,// NO Option<AdminCap> here!
// Admin holds cap separately
data: vector<u8>,}/// Capability passed as reference, not extracted from Option
publicfunadmin_modify(cap: &AdminCap,resource: &mutManagedResource,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
modulesafe::delegation{usesui::object::{Self,UID,ID};usestd::option::{Self,Option};publicstructPrimaryAdminhaskey{id: UID,resource_id: ID,}publicstructDelegatedAdminhaskey{id: UID,resource_id: ID,delegated_by: ID,expires_at: u64,}publicstructResourcehaskey{id: UID,primary_admin: address,// Immutable primary authority
// Delegation is separate objects, not Options
data: vector<u8>,}/// Primary admin always works
publicfunprimary_modify(cap: &PrimaryAdmin,resource: &mutResource,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
publicfundelegated_modify(cap: &DelegatedAdmin,resource: &mutResource,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
modulesafe::grace_period{usestd::option::{Self,Option};usesui::clock::{Self,Clock};publicstructSubscriptionhaskey{id: UID,owner: address,active: bool,expires_at: u64,grace_period_end: Option<u64>,// Optional extension, not authority
}publicfuncan_access(sub: &Subscription,clock: &Clock,ctx: &TxContext): bool{// Authority: must be owner
if(tx_context::sender(ctx)!=sub.owner){returnfalse};if(!sub.active){returnfalse};letnow=clock::timestamp_ms(clock);// Active subscription
if(now<sub.expires_at){returntrue};// Check grace period (optional feature, not authority)
if(option::is_some(&sub.grace_period_end)){letgrace_end=*option::borrow(&sub.grace_period_end);returnnow<grace_end};false}}
Recommended Mitigations
1. Never Store Capabilities in Option
// BAD: Capability in Option can be extracted
publicstructBadhaskey{admin: Option<AdminCap>,}// GOOD: Capability is separate object
publicstructGoodhaskey{admin_address: address,}
2. Use Address or ID for Authority Reference
// Store who has authority, not the authority itself
publicstructResourcehaskey{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
publicfunadmin_action(cap: &AdminCap,resource: &mutResource){assert!(cap.resource_id==object::id(resource),E_WRONG_CAP);}