Missing Object Uniqueness Enforcement
Sui's object model lacks built-in one-per-address constraints, allowing duplicate singleton objects to bypass restrictions.
Description
In Sui, every call to object::new(ctx) creates a new object with a unique UID, and transfer::transfer sends it to an address in a one-way operation with no built-in deduplication. Unlike the EVM, where writing to a mapping(address => Struct) naturally enforces one entry per address, Sui has no mapping-equivalent that ties a single value to each address at the storage layer. Once an object is transferred, the contract cannot enumerate which objects an address already owns -- there is no on-chain index of objects by type and owner accessible from Move code.
This architectural gap means that if a registration function creates and transfers a new object without explicitly checking a separate registry, users can accumulate duplicate objects. A user holding two Membership objects can have is_active = true on one and is_active = false on the other, allowing them to bypass single-use restrictions by switching to the unconsumed copy. The same class of bug applies to any system that assumes one-object-per-user semantics -- profiles, accounts, memberships, or any singleton-like abstraction built on transferred objects.
Exploit Scenario
Alice deploys a membership module where an admin registers members by creating a Membership object and calling transfer::transfer to send it to the member's address. Bob convinces the admin to register his address twice (or exploits a public registration function). Bob now owns two Membership objects. When the protocol deactivates Bob's first membership by setting is_active = false, Bob simply passes his second Membership object to bypass the restriction, breaking the single-membership invariant the protocol depends on.
Example
A module lets an admin register members. The function creates a fresh Membership and transfers it to the given address, but nothing prevents the admin from calling it twice for the same user:
public struct Membership has key, store {
id: UID,
is_active: bool,
}
public fun register_member(
_: &AdminCap,
member_addr: address,
ctx: &mut TxContext,
) {
let membership = Membership {
id: object::new(ctx),
is_active: false,
};
// BUG: no check whether member_addr already has a Membership object
transfer::transfer(membership, member_addr);
}
Mitigations
- Track registered entities in a
Table<address, ID>orObjectTablekeyed by address and assert the key does not exist before creating a new object. - Consider using dynamic fields on a shared registry object instead of transferring individual objects, making uniqueness structurally enforced by the key.
- Write tests that attempt duplicate registration and confirm the transaction aborts.
- Audit every function that calls
object::newfollowed bytransfer::transferfor missing uniqueness checks.