1. Object Transfer Misuse
Overview
Any address-owned object with key (especially combined with store) can be freely transferred using sui::transfer::transfer or public_transfer. This breaks assumptions about invariants, capability possession, and ownership that your contract may depend on.
Risk Level
High — Can lead to complete bypass of access control mechanisms.
OWASP / CWE Mapping
| OWASP Top 10 | MITRE CWE |
|---|---|
| A01 (Broken Access Control) | CWE-284 (Improper Access Control), CWE-275 (Permission Issues) |
The Problem
In Sui, objects with key + store abilities can be transferred by their owner to any address. If your contract issues capability objects or admin tokens and assumes they will remain with the original recipient, an attacker can transfer these objects to themselves or others, bypassing your intended access control.
Common Mistakes
- Mixing authority models — Using
sender()checks in some functions and capability-based checks in others - Assuming capability possession — Expecting that whoever received a capability still holds it
- Transferable admin tokens — Creating admin capabilities with
storethat can be freely moved
Vulnerable Example
module vulnerable::admin {
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
/// VULNERABLE: This capability has `store`, allowing it to be transferred
public struct AdminCap has key, store {
id: UID,
}
public struct Treasury has key {
id: UID,
balance: u64,
}
/// Issue an admin cap to the deployer
fun init(ctx: &mut TxContext) {
transfer::transfer(
AdminCap { id: object::new(ctx) },
tx_context::sender(ctx)
);
}
/// VULNERABLE: Mixing sender check with capability-based system
/// An attacker can transfer the AdminCap to themselves and bypass this
public entry fun set_fee(new_fee: u64, ctx: &mut TxContext) {
// This check is useless if AdminCap can be transferred!
assert!(tx_context::sender(ctx) == @0xADMIN, 0);
// ... set fee logic
}
/// Anyone with the cap can drain the treasury
public entry fun withdraw(
_cap: &AdminCap,
treasury: &mut Treasury,
amount: u64,
ctx: &mut TxContext
) {
// Attacker obtains AdminCap via transfer and drains funds
treasury.balance = treasury.balance - amount;
}
}Attack Scenario
- Admin deploys contract and receives
AdminCap - Attacker social-engineers admin or exploits another vulnerability to get
AdminCaptransferred - Attacker now has full admin access, can drain treasury
- Original
sender()checks are bypassed because the cap was transferred
Secure Example
module secure::admin {
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
/// SECURE: No `store` ability prevents unauthorized transfers
/// Only this module can transfer this capability
public struct AdminCap has key {
id: UID,
authorized_address: address,
}
public struct Treasury has key {
id: UID,
balance: u64,
}
fun init(ctx: &mut TxContext) {
let sender = tx_context::sender(ctx);
transfer::transfer(
AdminCap {
id: object::new(ctx),
authorized_address: sender,
},
sender
);
}
/// SECURE: Verify the capability is held by the authorized address
public entry fun withdraw(
cap: &AdminCap,
treasury: &mut Treasury,
amount: u64,
ctx: &mut TxContext
) {
// Double-check: capability holder must match authorized address
assert!(tx_context::sender(ctx) == cap.authorized_address, 0);
treasury.balance = treasury.balance - amount;
}
/// Explicit transfer function with additional checks
public entry fun transfer_admin(
cap: AdminCap,
new_admin: address,
ctx: &mut TxContext
) {
// Only current authorized address can transfer
assert!(tx_context::sender(ctx) == cap.authorized_address, 0);
// Create new cap with updated authorization
let AdminCap { id, authorized_address: _ } = cap;
object::delete(id);
transfer::transfer(
AdminCap {
id: object::new(ctx),
authorized_address: new_admin,
},
new_admin
);
}
}Recommended Mitigations
1. Remove store from Capability Objects
/// Without `store`, only your module can transfer this object
public struct AdminCap has key {
id: UID,
}2. Use Consistent Authority Models
Choose one authority model and stick to it:
- Capability-based: All authorization via capability objects (recommended)
- Address-based: All authorization via
sender()checks
Don’t mix them — it creates confusion and security gaps.
3. Embed Authorization in Capabilities
public struct AdminCap has key {
id: UID,
authorized_address: address, // Track who should hold this
permissions: u64, // Bitmask of allowed operations
}4. Use Non-Transferable Patterns
/// Soul-bound capability — cannot be transferred at all
public struct SoulBoundCap has key {
id: UID,
owner: address,
}
/// Verify ownership in every function
public fun use_cap(cap: &SoulBoundCap, ctx: &TxContext) {
assert!(tx_context::sender(ctx) == cap.owner, E_NOT_OWNER);
}Testing Checklist
- Verify all capability objects lack
storeability unless intentionally transferable - Confirm no mixing of
sender()checks with capability-based authorization - Test that transferred capabilities cannot bypass access controls
- Audit all
public_transferandtransfercalls for sensitive objects