13. Unsafe Object ID Usage
Overview
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.
OWASP / CWE Mapping
| OWASP Top 10 | MITRE CWE |
|---|---|
| A01 (Broken Access Control) | CWE-639 (Authorization Bypass), CWE-915 (Improperly Controlled Modification) |
The Problem
Object ID Characteristics
| Object Type | ID Stability | Notes |
|---|---|---|
| Address-owned | Stable | ID persists across transfers |
| Shared | Stable | ID fixed after sharing |
| Dynamic field child | Unstable | ID can change on wrap/unwrap |
| Wrapped object | Lost | Inner object’s ID changes when unwrapped |
Common Mistakes
- 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
module vulnerable::membership {
use sui::object::{Self, UID, ID};
use sui::tx_context::TxContext;
use sui::transfer;
use sui::dynamic_object_field as dof;
public struct Organization has key {
id: UID,
/// VULNERABLE: Storing child object IDs as member identifiers
member_ids: vector<ID>,
admin_member_id: ID,
}
public struct MemberBadge has key, store {
id: UID,
org_id: ID,
role: u8,
}
/// VULNERABLE: Uses ID for permanent reference
public entry fun add_member(
org: &mut Organization,
ctx: &mut TxContext
) {
let badge = MemberBadge {
id: object::new(ctx),
org_id: object::id(org),
role: 0,
};
let badge_id = object::id(&badge);
vector::push_back(&mut org.member_ids, badge_id);
// Badge stored as dynamic field
dof::add(&mut org.id, badge_id, badge);
}
/// VULNERABLE: ID-based lookup can fail or return wrong object
public entry fun promote_member(
org: &mut Organization,
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);
let badge: &mut MemberBadge = dof::borrow_mut(&mut org.id, member_id);
badge.role = 1;
}
/// VULNERABLE: Admin check using potentially invalid ID
public entry fun admin_action(
org: &mut Organization,
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
public entry fun remove_member(
org: &mut Organization,
member_id: ID,
) {
let badge: MemberBadge = dof::remove(&mut org.id, member_id);
// Remove from member list
let (found, idx) = vector::index_of(&org.member_ids, &member_id);
if (found) {
vector::remove(&mut org.member_ids, idx);
};
// Delete badge
let MemberBadge { 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
module secure::membership {
use sui::object::{Self, UID, ID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
use sui::table::{Self, Table};
/// Use a stable identifier separate from object ID
public struct MemberId has copy, drop, store {
value: u64,
}
public struct Organization has key {
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,
}
public struct MemberRecord has store {
address: address,
role: u8,
joined_at: u64,
}
/// SECURE: Member badge references stable ID, owned by member
public struct MemberBadge has key {
id: UID,
org_id: ID,
member_id: MemberId, // Stable identifier
}
public entry fun add_member(
org: &mut Organization,
member_address: address,
ctx: &mut TxContext
) {
// Generate stable member ID
let member_id = MemberId { value: org.next_member_id };
org.next_member_id = org.next_member_id + 1;
// Store member record
table::add(&mut org.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
public entry fun promote_member(
org: &mut Organization,
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);
let record = table::borrow_mut(&mut org.members, badge.member_id);
record.role = 1;
}
/// SECURE: Admin check using badge possession
public entry fun admin_action(
org: &mut Organization,
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
public entry fun remove_member(
org: &mut Organization,
badge: MemberBadge,
ctx: &TxContext
) {
let MemberBadge { 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(&mut org.members, member_id);
// Delete badge
object::delete(id);
}
}Safe ID Usage Patterns
Pattern 1: Stable Application-Level IDs
/// Use incrementing counter for stable IDs
public struct StableId has copy, drop, store {
value: u64,
}
public struct IdGenerator has key {
id: UID,
next_id: u64,
}
public fun generate_id(gen: &mut IdGenerator): StableId {
let id = 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
public entry fun authorized_action(
cap: &AuthCap, // Possession proves authorization
target: &mut Target,
) {
// 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
public fun use_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
let obj = table::borrow(®istry.objects, obj_id);
assert!(obj.valid, E_OBJECT_INVALID);
}Pattern 4: Immutable Reference Objects
/// Create immutable reference objects for stable identity
public struct IdentityAnchor has key {
id: UID,
// Never modified after creation
owner: address,
created_at: u64,
}
public fun create_anchor(ctx: &mut TxContext): IdentityAnchor {
let anchor = 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
let stable_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
public entry fun admin_action(admin_cap: &AdminCap, ...) { }3. Verify Referenced Objects Still Exist
public fun safe_lookup(registry: &Registry, id: ID): &Object {
assert!(registry.contains(id), E_NOT_FOUND);
registry.borrow(id)
}4. Document ID Stability Requirements
/// NOTE: This ID is stable because:
/// - Object is address-owned (not wrapped)
/// - Object is never transferred to dynamic field
/// - Object is immutable after creationTesting 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
- Test with wrapped and unwrapped object scenarios