Sui supports sponsored transactions where one account pays gas fees for another account’s transaction. Confusing the gas sponsor with the transaction sender can lead to impersonation attacks, unauthorized actions, and broken access control.
Risk Level
High — Can lead to complete access control bypass and impersonation.
sender() returns the transaction sender, not sponsor
Sponsor only pays gas — they don’t authorize object operations
Sender’s signature authorizes accessing their owned objects
Vulnerable Example
modulevulnerable::gasless{usesui::object::{Self,UID};usesui::tx_context::{Self,TxContext};usesui::transfer;publicstructRelayerConfighaskey{id: UID,authorized_relayer: address,// Gas sponsor
trusted_users: vector<address>,}publicstructUserVaulthaskey{id: UID,owner: address,balance: u64,}/// VULNERABLE: Checks sponsor instead of sender
/// Attacker can get anyone to sponsor their malicious tx
publicentryfunwithdraw_via_relayer(config: &RelayerConfig,vault: &mutUserVault,amount: u64,ctx: &mutTxContext){// WRONG: This might check the wrong entity
// In sponsored TX, sender() is still the actual sender
// But developer might be thinking of the sponsor
// Suppose they meant to check if relayer is calling:
// This is STILL wrong because it doesn't verify
// the sender is authorized to access this vault!
assert!(tx_context::sender(ctx)==config.authorized_relayer,E_NOT_RELAYER);// No check that the vault owner authorized this!
vault.balance=vault.balance-amount;}/// VULNERABLE: Assumes sponsor = authorized party
publicentryfunadmin_action_gasless(config: &RelayerConfig,ctx: &mutTxContext){// If sponsor is the admin, attacker creates TX where:
// - Attacker is sender
// - Admin is sponsor (pays gas)
// Attacker's action gets authorized because admin sponsors it!
// This is logically backwards
}}modulevulnerable::meta_tx{usesui::ecdsa_k1;usesui::tx_context::TxContext;/// VULNERABLE: Replay attack possible
publicentryfunexecute_meta_tx(user_signature: vector<u8>,action_data: vector<u8>,ctx: &mutTxContext){// Missing: nonce check
// Missing: deadline check
// Missing: chain ID check
letsigner=ecdsa_k1::secp256k1_ecrecover(&user_signature,&action_data,0);// Signature can be replayed indefinitely!
// ... execute action
}}
modulesecure::gasless{usesui::object::{Self,UID,ID};usesui::tx_context::{Self,TxContext};usesui::transfer;usesui::table::{Self,Table};usesui::clock::{Self,Clock};usesui::ecdsa_k1;usesui::hash;constE_NOT_OWNER: u64=0;constE_INVALID_SIGNATURE: u64=1;constE_NONCE_USED: u64=2;constE_EXPIRED: u64=3;constE_WRONG_CHAIN: u64=4;/// Chain ID for replay protection
constCHAIN_ID: u64=1;publicstructUserVaulthaskey{id: UID,owner: address,balance: u64,}publicstructNonceRegistryhaskey{id: UID,used_nonces: Table<vector<u8>,bool>,}/// SECURE: Sender must own the vault (object ownership)
/// Gas sponsor is irrelevant — ownership is what matters
publicentryfunwithdraw(vault: &mutUserVault,amount: u64,ctx: &mutTxContext){// sender() returns the transaction sender, who must own vault
// This is enforced by Sui's object model — no explicit check needed
// if vault is address-owned
vault.balance=vault.balance-amount;// Transfer to sender...
}/// SECURE: For shared objects, verify sender is owner
publicentryfunwithdraw_from_shared(vault: &mutUserVault,amount: u64,ctx: &mutTxContext){// For shared objects, explicitly verify sender
assert!(tx_context::sender(ctx)==vault.owner,E_NOT_OWNER);vault.balance=vault.balance-amount;}/// SECURE: Meta-transaction with proper protections
publicentryfunexecute_meta_tx(nonce_registry: &mutNonceRegistry,clock: &Clock,user_pubkey: vector<u8>,user_signature: vector<u8>,action_type: u8,action_data: vector<u8>,nonce: vector<u8>,deadline: u64,chain_id: u64,ctx: &mutTxContext){// Check chain ID
assert!(chain_id==CHAIN_ID,E_WRONG_CHAIN);// Check deadline
letnow=clock::timestamp_ms(clock);assert!(now<deadline,E_EXPIRED);// Check nonce not used
assert!(!table::contains(&nonce_registry.used_nonces,nonce),E_NONCE_USED);// Mark nonce as used
table::add(&mutnonce_registry.used_nonces,nonce,true);// Build message that was signed
letmutmessage=vector::empty<u8>();vector::append(&mutmessage,action_data);vector::append(&mutmessage,nonce);vector::append(&mutmessage,bcs::to_bytes(&deadline));vector::append(&mutmessage,bcs::to_bytes(&chain_id));// Hash the message
letmessage_hash=hash::keccak256(&message);// Verify signature
letrecovered=ecdsa_k1::secp256k1_ecrecover(&user_signature,&message_hash,0);assert!(recovered==user_pubkey,E_INVALID_SIGNATURE);// Now execute action on behalf of verified user
// The signer of user_signature authorized this, not tx sender
}}
Meta-Transaction Best Practices
1. Include Replay Protection
publicstructMetaTxDatahasdrop{action: vector<u8>,nonce: u64,// Unique per user
deadline: u64,// Expiration timestamp
chain_id: u64,// Network identifier
contract_address: address,// Target contract
}
2. Verify User Intent, Not Sponsor
/// User signature proves intent, sponsor just pays gas
publicentryfunuser_authorized_action(user_signature: vector<u8>,action_data: vector<u8>,ctx: &mutTxContext){// Verify USER signed the action
letauthorized_user=verify_signature(user_signature,action_data);// Execute action AS the authorized user
// Transaction sender (possibly sponsor) is irrelevant
}
3. Domain Separation
/// Different actions have different signature domains
constDOMAIN_WITHDRAW: vector<u8>=b"WITHDRAW_V1:";constDOMAIN_TRANSFER: vector<u8>=b"TRANSFER_V1:";funbuild_withdraw_message(amount: u64,recipient: address,nonce: u64): vector<u8>{letmutmsg=DOMAIN_WITHDRAW;vector::append(&mutmsg,bcs::to_bytes(&amount));vector::append(&mutmsg,bcs::to_bytes(&recipient));vector::append(&mutmsg,bcs::to_bytes(&nonce));msg}
4. Use Object Ownership When Possible
/// Sui's object model provides natural authorization
/// If vault is address-owned, only owner can include it in TX
publicentryfunsecure_withdraw(vault: &mutUserVault,// Must be owned by sender
amount: u64,){// No authorization check needed!
// Sui verifies sender owns this object
vault.balance=vault.balance-amount;}
Recommended Mitigations
1. Never Use Sponsor for Authorization
// WRONG: Don't do this
assert!(is_sponsor(ctx),E_NOT_AUTHORIZED);// RIGHT: Check sender or capability
assert!(tx_context::sender(ctx)==expected_user,E_NOT_AUTHORIZED);// OR
assert!(has_capability(cap),E_NOT_AUTHORIZED);
2. Prefer Object Ownership to Explicit Checks
// Object ownership is the strongest authorization in Sui
publicentryfunaction(owned_object: &mutMyObject){// Only owner can include this in their transaction
}
3. Implement Full Meta-TX Protection
structMetaTxParams{nonce: u64,deadline: u64,chain_id: u64,target_contract: address,}// All must be verified before execution
Testing Checklist
Test that sponsor cannot perform actions requiring sender authorization