Programmable Transaction Blocks (PTBs) in Sui allow multiple operations in a single transaction. Attackers can reorder or interleave calls in unexpected ways, bypassing invariants that assume specific execution order.
Risk Level
High — Can bypass access control and break protocol invariants.
OWASP / CWE Mapping
OWASP Top 10
MITRE CWE
A04 (Insecure Design)
CWE-841 (Improper Enforcement of Behavioral Workflow), CWE-662 (Improper Synchronization)
The Problem
PTB Characteristics
Multiple Move calls in one atomic transaction
Caller controls the order of calls
Intermediate results can be passed between calls
All calls see the same object state (within the PTB)
modulevulnerable::multistep{usesui::object::{Self,UID};usesui::tx_context::TxContext;publicstructAuthSessionhaskey{id: UID,user: address,is_authenticated: bool,}publicstructVaulthaskey{id: UID,balance: u64,}/// Step 1: User calls this to authenticate
publicentryfunauthenticate(session: &mutAuthSession,password_hash: vector<u8>,){// Verify password
assert!(verify_password(password_hash),E_WRONG_PASSWORD);session.is_authenticated=true;}/// Step 2: User calls this to withdraw (should be after auth)
/// VULNERABLE: Assumes authenticate was called first
publicentryfunwithdraw(session: &AuthSession,vault: &mutVault,amount: u64,ctx: &mutTxContext){// This check can be bypassed in a PTB!
assert!(session.is_authenticated,E_NOT_AUTHENTICATED);vault.balance=vault.balance-amount;// ... transfer to user
}/// VULNERABLE: Cleanup resets auth state
publicentryfunlogout(session: &mutAuthSession){session.is_authenticated=false;}}modulevulnerable::flashloan{publicstructPoolhaskey{id: UID,balance: u64,borrowed: u64,}/// VULNERABLE: No hot potato to enforce repayment
publicentryfunborrow(pool: &mutPool,amount: u64,){assert!(pool.balance>=amount,E_INSUFFICIENT);pool.borrowed=pool.borrowed+amount;pool.balance=pool.balance-amount;// ... give coins to borrower
}/// Repayment can be skipped in PTB
publicentryfunrepay(pool: &mutPool,amount: u64,){pool.borrowed=pool.borrowed-amount;pool.balance=pool.balance+amount;// ... receive coins
}}
Attack: PTB Reordering
// Attacker's PTB:
Transaction{// Skip authenticate entirely!
// Or: authenticate with wrong password, then proceed anyway
commands: [// Borrow from flash loan
Call(flashloan::borrow,[pool,1000000]),// Use borrowed funds for exploit
Call(some_protocol::exploit,[borrowed_coins]),// Never call flashloan::repay
// Transaction completes successfully!
]}
Secure Example
modulesecure::multistep{usesui::object::{Self,UID};usesui::tx_context::{Self,TxContext};/// Hot potato pattern — must be consumed in same transaction
publicstructAuthToken{user: address,expires_at: u64,vault_id: ID,}publicstructVaulthaskey{id: UID,balance: u64,}/// SECURE: Returns hot potato that must be consumed
publicfunauthenticate(password_hash: vector<u8>,vault: &Vault,clock: &Clock,ctx: &TxContext): AuthToken{assert!(verify_password(password_hash,ctx),E_WRONG_PASSWORD);AuthToken{user: tx_context::sender(ctx),expires_at: clock::timestamp_ms(clock)+60000,// 1 minute
vault_id: object::id(vault),}}/// SECURE: Consumes hot potato, ensuring auth happened
publicfunwithdraw(token: AuthToken,vault: &mutVault,amount: u64,clock: &Clock,ctx: &mutTxContext){letAuthToken{user,expires_at,vault_id}=token;// Verify token is for this vault
assert!(vault_id==object::id(vault),E_WRONG_VAULT);// Verify token hasn't expired
assert!(clock::timestamp_ms(clock)<expires_at,E_EXPIRED);// Verify caller is the authenticated user
assert!(tx_context::sender(ctx)==user,E_WRONG_USER);vault.balance=vault.balance-amount;// Token is consumed — cannot be reused
}}modulesecure::flashloan{usesui::object::{Self,UID,ID};usesui::coin::{Self,Coin};usesui::sui::SUI;publicstructPoolhaskey{id: UID,coins: Coin<SUI>,}/// Hot potato — MUST be repaid before transaction ends
publicstructFlashLoanReceipt{pool_id: ID,amount: u64,fee: u64,}/// SECURE: Returns receipt that must be consumed
publicfunborrow(pool: &mutPool,amount: u64,ctx: &mutTxContext): (Coin<SUI>,FlashLoanReceipt){assert!(coin::value(&pool.coins)>=amount,E_INSUFFICIENT);letborrowed=coin::split(&mutpool.coins,amount,ctx);letfee=amount/1000;// 0.1% fee
letreceipt=FlashLoanReceipt{pool_id: object::id(pool),amount,fee,};(borrowed,receipt)}/// SECURE: Must be called to destroy receipt
publicfunrepay(pool: &mutPool,receipt: FlashLoanReceipt,repayment: Coin<SUI>,){letFlashLoanReceipt{pool_id,amount,fee}=receipt;// Verify correct pool
assert!(pool_id==object::id(pool),E_WRONG_POOL);// Verify full repayment with fee
assert!(coin::value(&repayment)>=amount+fee,E_INSUFFICIENT_REPAYMENT);coin::join(&mutpool.coins,repayment);// Receipt consumed — loan is repaid
}}
PTB-Safe Patterns
Pattern 1: Hot Potato (Must Consume)
/// No abilities — cannot be stored, dropped, or copied
/// MUST be consumed in the same transaction
publicstructMustConsume{value: u64,}publicfunstart_operation(): MustConsume{MustConsume{value: 100}}publicfunfinish_operation(potato: MustConsume){letMustConsume{value: _}=potato;// Potato consumed — operation complete
}
Pattern 2: Atomic Operations
/// Combine check and action in single function
publicentryfunauthenticated_withdraw(password_hash: vector<u8>,vault: &mutVault,amount: u64,ctx: &mutTxContext){// Auth and action in same call — cannot be separated
assert!(verify_password(password_hash,ctx),E_WRONG_PASSWORD);vault.balance=vault.balance-amount;}
/// Check invariants at function boundaries
publicentryfunoperation(state: &mutState,...){// Pre-conditions
assert_invariants(state);// Perform operation
// ...
// Post-conditions
assert_invariants(state);}funassert_invariants(state: &State){assert!(state.total==state.a+state.b,E_INVARIANT_BROKEN);assert!(state.balance>=state.minimum,E_UNDERCOLLATERALIZED);}
Recommended Mitigations
1. Use Hot Potatoes for Multi-Step Operations
// Force the caller to complete the operation
publicfunstart(): Receipt{}publicfunfinish(receipt: Receipt){}// Receipt has no abilities — must be consumed
2. Validate Invariants in Every Function
publicentryfunany_function(state: &mutState,...){// Don't assume previous function was called
// Validate everything this function needs
}