Object IDs (object::ID) in Sui are unique identifiers, but using them as stable identity anchors for child objects or in access control can lead to vulnerabilities. Child object IDs can change when objects are unwrapped, rewrapped, or transferred between parents.
Risk Level
Medium — Can lead to authorization bypasses and state inconsistencies.
Using child object IDs as permanent identifiers — IDs change when structure changes
Storing IDs for authorization — Referenced object may no longer exist
Cross-referencing by ID without verification — IDs may point to wrong objects
Assuming ID uniqueness across time — Same ID could be reused after deletion
Vulnerable Example
modulevulnerable::membership{usesui::object::{Self,UID,ID};usesui::tx_context::TxContext;usesui::transfer;usesui::dynamic_object_fieldasdof;publicstructOrganizationhaskey{id: UID,/// VULNERABLE: Storing child object IDs as member identifiers
member_ids: vector<ID>,admin_member_id: ID,}publicstructMemberBadgehaskey,store{id: UID,org_id: ID,role: u8,}/// VULNERABLE: Uses ID for permanent reference
publicentryfunadd_member(org: &mutOrganization,ctx: &mutTxContext){letbadge=MemberBadge{id: object::new(ctx),org_id: object::id(org),role: 0,};letbadge_id=object::id(&badge);vector::push_back(&mutorg.member_ids,badge_id);// Badge stored as dynamic field
dof::add(&mutorg.id,badge_id,badge);}/// VULNERABLE: ID-based lookup can fail or return wrong object
publicentryfunpromote_member(org: &mutOrganization,member_id: ID,){// What if badge was removed and re-added with same ID?
// What if member_id doesn't exist?
assert!(vector::contains(&org.member_ids,&member_id),E_NOT_MEMBER);letbadge: &mutMemberBadge=dof::borrow_mut(&mutorg.id,member_id);badge.role=1;}/// VULNERABLE: Admin check using potentially invalid ID
publicentryfunadmin_action(org: &mutOrganization,actor_badge_id: ID,){// If admin badge was rewrapped, this ID is stale
assert!(actor_badge_id==org.admin_member_id,E_NOT_ADMIN);// ... perform admin action
}/// VULNERABLE: ID reuse after deletion
publicentryfunremove_member(org: &mutOrganization,member_id: ID,){letbadge: MemberBadge=dof::remove(&mutorg.id,member_id);// Remove from member list
let(found,idx)=vector::index_of(&org.member_ids,&member_id);if(found){vector::remove(&mutorg.member_ids,idx);};// Delete badge
letMemberBadge{id,org_id: _,role: _}=badge;object::delete(id);// Problem: member_id is now "free" and could theoretically be reused
// (not in practice for UID, but the logic is still flawed)
}}
Secure Example
modulesecure::membership{usesui::object::{Self,UID,ID};usesui::tx_context::{Self,TxContext};usesui::transfer;usesui::table::{Self,Table};/// Use a stable identifier separate from object ID
publicstructMemberIdhascopy,drop,store{value: u64,}publicstructOrganizationhaskey{id: UID,/// SECURE: Use stable member ID, not object ID
next_member_id: u64,/// Map stable ID to member data
members: Table<MemberId,MemberRecord>,admin_id: MemberId,}publicstructMemberRecordhasstore{address: address,role: u8,joined_at: u64,}/// SECURE: Member badge references stable ID, owned by member
publicstructMemberBadgehaskey{id: UID,org_id: ID,member_id: MemberId,// Stable identifier
}publicentryfunadd_member(org: &mutOrganization,member_address: address,ctx: &mutTxContext){// Generate stable member ID
letmember_id=MemberId{value: org.next_member_id};org.next_member_id=org.next_member_id+1;// Store member record
table::add(&mutorg.members,member_id,MemberRecord{address: member_address,role: 0,joined_at: tx_context::epoch(ctx),});// Create badge with stable ID
transfer::transfer(MemberBadge{id: object::new(ctx),org_id: object::id(org),member_id,},member_address);}/// SECURE: Verify both badge ownership and org membership
publicentryfunpromote_member(org: &mutOrganization,badge: &MemberBadge,ctx: &TxContext){// Verify badge is for this org
assert!(badge.org_id==object::id(org),E_WRONG_ORG);// Verify member exists in org
assert!(table::contains(&org.members,badge.member_id),E_NOT_MEMBER);letrecord=table::borrow_mut(&mutorg.members,badge.member_id);record.role=1;}/// SECURE: Admin check using badge possession
publicentryfunadmin_action(org: &mutOrganization,admin_badge: &MemberBadge,ctx: &TxContext){// Verify badge is for this org
assert!(admin_badge.org_id==object::id(org),E_WRONG_ORG);// Verify caller holds the admin badge
assert!(admin_badge.member_id==org.admin_id,E_NOT_ADMIN);// Additional: verify sender owns the badge
// (implicit through object ownership)
// ... perform admin action
}/// SECURE: Clean removal with stable ID
publicentryfunremove_member(org: &mutOrganization,badge: MemberBadge,ctx: &TxContext){letMemberBadge{id,org_id,member_id}=badge;// Verify badge is for this org
assert!(org_id==object::id(org),E_WRONG_ORG);// Remove from membership table
assert!(table::contains(&org.members,member_id),E_NOT_MEMBER);let_record=table::remove(&mutorg.members,member_id);// Delete badge
object::delete(id);}}
Safe ID Usage Patterns
Pattern 1: Stable Application-Level IDs
/// Use incrementing counter for stable IDs
publicstructStableIdhascopy,drop,store{value: u64,}publicstructIdGeneratorhaskey{id: UID,next_id: u64,}publicfungenerate_id(gen: &mutIdGenerator): StableId{letid=StableId{value: gen.next_id};gen.next_id=gen.next_id+1;id}
Pattern 2: Object Ownership for Authorization
/// Don't store IDs for auth — use object possession
publicentryfunauthorized_action(cap: &AuthCap,// Possession proves authorization
target: &mutTarget,){// No ID comparison needed
// Caller must own cap to include it in transaction
}
Pattern 3: Verify ID References
/// When IDs must be used, verify they point to valid objects
publicfunuse_reference(registry: &Registry,obj_id: ID,){// Verify object still exists in registry
assert!(table::contains(®istry.objects,obj_id),E_OBJECT_NOT_FOUND);// Get the actual object and verify properties
letobj=table::borrow(®istry.objects,obj_id);assert!(obj.valid,E_OBJECT_INVALID);}
Pattern 4: Immutable Reference Objects
/// Create immutable reference objects for stable identity
publicstructIdentityAnchorhaskey{id: UID,// Never modified after creation
owner: address,created_at: u64,}publicfuncreate_anchor(ctx: &mutTxContext): IdentityAnchor{letanchor=IdentityAnchor{id: object::new(ctx),owner: tx_context::sender(ctx),created_at: tx_context::epoch(ctx),};// Immediately freeze — ID now permanently stable
transfer::freeze_object(anchor);anchor}
Recommended Mitigations
1. Use Application-Level Identifiers
// Instead of object::id(obj)
// Use a stable counter-based ID
letstable_id=StableId{value: counter.next()};
2. Prefer Object Possession Over ID Checks
// BAD: ID comparison
assert!(user_id==stored_admin_id,E_NOT_ADMIN);// GOOD: Object possession
publicentryfunadmin_action(admin_cap: &AdminCap,...){}
/// NOTE: This ID is stable because:
/// - Object is address-owned (not wrapped)
/// - Object is never transferred to dynamic field
/// - Object is immutable after creation
Testing Checklist
Test that removing and re-adding objects doesn’t reuse stale IDs
Verify authorization works after objects are transferred
Test behavior when referenced objects are deleted
Confirm child object ID changes are handled correctly