17. PTB Ordering Issues
Overview
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)
Vulnerability Pattern
Expected: auth_check() → perform_action()
Attacker: perform_action() → some_cleanup() // Skip auth entirelyVulnerable Example
module vulnerable::multistep {
use sui::object::{Self, UID};
use sui::tx_context::TxContext;
public struct AuthSession has key {
id: UID,
user: address,
is_authenticated: bool,
}
public struct Vault has key {
id: UID,
balance: u64,
}
/// Step 1: User calls this to authenticate
public entry fun authenticate(
session: &mut AuthSession,
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
public entry fun withdraw(
session: &AuthSession,
vault: &mut Vault,
amount: u64,
ctx: &mut TxContext
) {
// 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
public entry fun logout(session: &mut AuthSession) {
session.is_authenticated = false;
}
}
module vulnerable::flashloan {
public struct Pool has key {
id: UID,
balance: u64,
borrowed: u64,
}
/// VULNERABLE: No hot potato to enforce repayment
public entry fun borrow(
pool: &mut Pool,
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
public entry fun repay(
pool: &mut Pool,
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
module secure::multistep {
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
/// Hot potato pattern — must be consumed in same transaction
public struct AuthToken {
user: address,
expires_at: u64,
vault_id: ID,
}
public struct Vault has key {
id: UID,
balance: u64,
}
/// SECURE: Returns hot potato that must be consumed
public fun authenticate(
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
public fun withdraw(
token: AuthToken,
vault: &mut Vault,
amount: u64,
clock: &Clock,
ctx: &mut TxContext
) {
let AuthToken { 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
}
}
module secure::flashloan {
use sui::object::{Self, UID, ID};
use sui::coin::{Self, Coin};
use sui::sui::SUI;
public struct Pool has key {
id: UID,
coins: Coin<SUI>,
}
/// Hot potato — MUST be repaid before transaction ends
public struct FlashLoanReceipt {
pool_id: ID,
amount: u64,
fee: u64,
}
/// SECURE: Returns receipt that must be consumed
public fun borrow(
pool: &mut Pool,
amount: u64,
ctx: &mut TxContext
): (Coin<SUI>, FlashLoanReceipt) {
assert!(coin::value(&pool.coins) >= amount, E_INSUFFICIENT);
let borrowed = coin::split(&mut pool.coins, amount, ctx);
let fee = amount / 1000; // 0.1% fee
let receipt = FlashLoanReceipt {
pool_id: object::id(pool),
amount,
fee,
};
(borrowed, receipt)
}
/// SECURE: Must be called to destroy receipt
public fun repay(
pool: &mut Pool,
receipt: FlashLoanReceipt,
repayment: Coin<SUI>,
) {
let FlashLoanReceipt { 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(&mut pool.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
public struct MustConsume {
value: u64,
}
public fun start_operation(): MustConsume {
MustConsume { value: 100 }
}
public fun finish_operation(potato: MustConsume) {
let MustConsume { value: _ } = potato;
// Potato consumed — operation complete
}Pattern 2: Atomic Operations
/// Combine check and action in single function
public entry fun authenticated_withdraw(
password_hash: vector<u8>,
vault: &mut Vault,
amount: u64,
ctx: &mut TxContext
) {
// Auth and action in same call — cannot be separated
assert!(verify_password(password_hash, ctx), E_WRONG_PASSWORD);
vault.balance = vault.balance - amount;
}Pattern 3: Sequence Numbers
public struct StateMachine has key {
id: UID,
current_step: u64,
expected_next: u64,
}
public entry fun step_one(state: &mut StateMachine) {
assert!(state.current_step == 0, E_WRONG_STEP);
state.current_step = 1;
state.expected_next = 2;
}
public entry fun step_two(state: &mut StateMachine) {
assert!(state.current_step == 1, E_WRONG_STEP);
state.current_step = 2;
// ...
}Pattern 4: Invariant Assertions
/// Check invariants at function boundaries
public entry fun operation(state: &mut State, ...) {
// Pre-conditions
assert_invariants(state);
// Perform operation
// ...
// Post-conditions
assert_invariants(state);
}
fun assert_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
public fun start(): Receipt { }
public fun finish(receipt: Receipt) { }
// Receipt has no abilities — must be consumed2. Validate Invariants in Every Function
public entry fun any_function(state: &mut State, ...) {
// Don't assume previous function was called
// Validate everything this function needs
}3. Make Operations Atomic When Possible
// Instead of: check() → action()
// Use: check_and_action()4. Use Object Ownership for Authorization
// Object ownership is enforced by Sui itself
public entry fun action(cap: &AdminCap, ...) {
// Caller must own cap — cannot be bypassed
}Testing Checklist
- Test each function in isolation (not just expected sequence)
- Test with functions called in reversed order
- Test skipping intermediate steps
- Verify hot potatoes cannot be stored or dropped
- Test that invariants hold regardless of call order