- April 29, 2026
Kose Dogus
Kose Dogus
Introduction
Move’s type system prevents entire classes of bugs that plague Solidity. Objects can’t be copied or silently dropped, ownership is enforced by both the type system and the runtime, and there's no dynamic dispatch to hijack mid-call. If you’re new to Sui, we recommend From Solidity to Sui for an introduction to the object model and Move’s type system.
Move eliminates many classic vulnerabilities, but no language removes the need for careful review. The patterns below compile successfully, pass basic tests, and only surface under adversarial conditions.
This post covers four critical vulnerability patterns extracted from real security audits.
| Pattern | Main Example Finding | Impact |
|---|---|---|
| Reference vs value assignment | Lombard Finance | Minting permanently broken |
| Type parameter mismatch | Navi Protocol | Steal funds via wrong pool |
| Access control gaps | Aftermath MarketMaker | Withdraw one coin type while receiving another |
| Receipt/ID validation | Cetus limit order | Theft via misrouted payment |
Let’s dissect each one.
Chapter 1: Move References
It compiles, it runs, it corrupts the wrong field
This vulnerability comes from Lombard Finance’s audit. Let’s see the code first and challenge ourselves to find it:
public fun mint_and_transfer(
treasury: &mut Treasury,
amount: u64,
recipient: address,
ctx: &mut TxContext
) {
let MinterCap { limit, epoch, mut left } = get_cap_mut(treasury, ctx.sender());
// Reset the limit if this is a new epoch
if (ctx.epoch() > *epoch) {
left = limit;
*epoch = ctx.epoch();
};
assert!(amount <= *left, EMintLimitExceeded);
*left = *left - amount;
// ... mint tokens ...
}
The Mental Model Shift From Solidity
In Solidity, variable assignment always copies values:
uint256 left = limit; // Copies the value
left = left - amount // Modifies local copy only
To modify storage, we use the storage keyword explicitly:
MinterCap storage cap = minterCaps[msg.sender];
cap.left = cap.limit; // Directly modifies storage
Move works differently. There’s no hidden storage and memory distinction. Instead, Move uses explicit references (pointers) that you must manually de-reference to read or write values.
let left: &mut u64 = ...; // left is a POINTER, not a value
left = limit; // Reassigns where the pointer points (!)
*left = *limit; // Reads value from limit, writes to where left points
The * operator is how you access the actual value behind a reference.
Understanding the Code
When we de-structure a &mut Struct, all fields become references (pointers):
let MinterCap { limit, epoch, mut left } = get_cap_mut(treasury, ctx.sender());
// ^^^^^ ^^^^^ ^^^^^^^^
// &mut &mut &mut
// (pointer) (pointer) (pointer)
These are not values, they are pointers to fields inside the stored MinterCap object.
| Variable | Type | Points To |
|---|---|---|
limit |
&mut u64 |
MinterCap.limit in storage |
epoch |
&mut u64 |
MinterCap.epoch in storage |
left |
&mut u64 |
MinterCap.left in storage |
The Bug
left = limit; // ❌ WRONG: Reassigns the POINTER, not the value!
If you are coming from Solidity background, you might read this as "copy the value of limit into left." But in Move, this reassigns where left points, both left and limit now point to the same location:
Before `left = limit`:
limit ──────► MinterCap.limit (value: 1000)
left ──────► MinterCap.left (value: 0)
After `left = limit`:
limit ──────► MinterCap.limit (value: 1000)
left ──────► MinterCap.limit (value: 1000) ← Both point HERE now!
MinterCap.left is orphaned at 0!
Now when the subsequent code runs:
assert!(amount <= *left, EMintLimitExceeded); // Reads from MinterCap.limit
*left = *left - amount; // Decrements MinterCap.limit!
The code reads from and writes to MinterCap.limit instead of MinterCap.left .
The Fix
*left = *limit // ✅ CORRECT: Dereference BOTH sides
*limit→ Reads the value (1000) from the memory location*left = ...→ Writes to the whereleftpoints (MinterCap.left)
Solidity vs Move Comparison
| Intent | Solidity | Move |
|---|---|---|
| Copy value from A to B | b=a |
*b = *a |
| Read a stored value | cap.left |
*left |
| Write to storage | cap.left = x |
*left = x |
| Reassign pointer | No pointers | left = other_ref |
Why This Bug is Dangerous
- It compiles successfully : No compiler warning
- It runs without errors : No runtime abort
- It appears to work : The function completes
- The effect is silent : The wrong field gets modified
Impact: The bug causes two problems:
MinterCap.leftis never actually reset (stays orphaned at 0)MinterCap.limitgets decremented instead ofMinterCap.left
Epoch 1: User mints 1000 tokens
→ MinterCap.left: 1000 → 0 (correctly decremented)
→ MinterCap.limit: 1000 (unchanged)
Epoch 2: Reset triggers, user mints 1000 tokens
→ left = limit (pointer reassignment!)
→ MinterCap.left: 0 (orphaned, never touched)
→ MinterCap.limit: 1000 → 0 (decremented!)
It is not possible to mint any tokens after this point
Key Takeaway
Move makes references explicit where Solidity hides them. When you see &mut types, remember:
=reassigns the pointer (without*)*accesses the underlying data
If the purpose is to copy a value, de-reference both sides. Forgetting it, means you’re just moving pointers around while the actual storage remains unchanged, or worse, you end up modifying the wrong field entirely.
Chapter 2: Type Parameter Validation
Move’s generics are powerful, but they don’t validate business logic
Move’s type system guarantees that if you have a Coin<SUI>, it really is SUI. We can’t accidentally pass USDC where SUI is expected.
But this generic usage doesn’t guarantee that the generic type parameter matches some stored configuration
Case Study: Navi Protocol
Next finding is from the Navi protocol, a lending protocol where users could steal funds via withdraw function:
public entry fun withdraw(
clock: &Clock,
oracle: &PriceOracle,
storage: &mut Storage,
pool: &mut Pool,
asset: u8, // ← Asset index (e.g., 0 = SUI, 1 = USDC, 2 = BTC)
amount: u64,
to: address,
ctx: &mut TxContext
) {
// ... validation and withdrawal logic ...
}
As we can see the function accepts both a Pool<CoinType> and an asset index, but never validates that they match!
The attacker can provide a BTC pool, and an asset index of USDC, this will lead to system thinking the user is withdrawing one coin of USDC , but giving the user BTC at the end of the transaction considering we are extracting from the BTC pool!
Case Study: Kuna Labs
Similar pattern in liquidation context:
public(package) macro fun liquidate_col_y<$X, $Y, $SX, $LP>(
$position: &mut Position<$X, $Y, $LP>,
$supply_pool: &mut SupplyPool<$X, $SX>, // ← Which SupplyPool?
// ...
): Balance<$Y> {
// ... liquidation logic ...
}
A position created with SupplyPool<X, SX0> could be liquidated using SupplyPool<X, SX1>.
Exploit Path and Impact:
- User borrows from
SupplyPool<X,SX0>(their debt shares are typeSX0) - Liquidator calls liquidate with
SupplyPool<X,SX1> - Since there is no collateral to pay for
SX1, liquidator doesn’t need to provide any repayment. - Liquidator still receives collateral rewards
- Result: Free collateral, no debt reduction
What is common in these findings?
Move’s generics ensure type safety but not semantic correctness.
Type system guarantees that:
Pool<BTC> only holds BTC
Pool<USDC> only holds USDC
But it doesn’t guarantee:
asset_index corresponds to the correct Pool type
or
SupplyPool matches the Position's original pool
The Fix
Option 1: Store and validate type name
public fun safe_function(
pool: &mut Pool,
asset_config_index: u8,
// ...
) {
let config = get_config(asset_config_index);
// ✅ Validate that the user-supplied index corresponds to this pool's type
assert!(
type_name::get() == config.coin_type,
ETypeMismatch
);
// ... proceed safely ...
}
Option 2: Derive index from type (no user input)
public fun safer_function(
pool: &mut Pool,
storage: &Storage,
) {
// ✅ Derive the asset identity from the generic type — never from user input
let asset_type = type_name::get();
let asset_index = storage.index_of(asset_type);
let config = get_config(asset_index);
// ... proceed safely ...
}
Option 3: Store pool reference in position
public struct Position has key, store {
id: UID,
supply_pool_id: ID, // ✅ Store which pool was used
// ...
}
public fun liquidate(
position: &mut Position,
supply_pool: &mut SupplyPool,
// ...
) {
// ✅ Validate pool matches position
assert!(
object::id(supply_pool) == position.supply_pool_id,
EPoolMismatch
);
// ...
}
Key Takeaways
Move’s type system ensures you can’t pass the wrong type, but it doesn’t ensure you pass the corresponding configuration. Always validate that generic type parameters match stored configuration.
Chapter 3: Access Control : public vs public(package)
Classic access control, but in a different shape
The critical distinction for this vulnerability type is simple:
public: Any module (including attacker-deployed modules) can call this functionpublic(package): Only modules within the same package can call this function
When an internal helper is accidentally marked public instead of public(package), attackers can deploy their own module and call it directly.
Case Study: Aftermath MarketMaker
From the Aftermath audit which a single visibility keyword caused a critical vulnerability:
// 🚨 VULNERABLE: Anyone can get mutable access to the trading account!
public fun account_mut(
vault: &mut Vault
): &mut Account {
dof::borrow_mut(&mut vault.id, keys::account_key())
}
This internal helper function was marked public instead of public(package).
Here is the attack path:
- Attacker deploys their own module
- Attacker calls
vault::account_mut(target_vault) - Attacker receives
&mut Account<C>(full mutable access) - Attacker modifies trade positions, drains collateral
The Fix:
// ✅ FIXED: Only same-package modules can call
public(package) fun account_mut(
vault: &mut Vault
): &mut Account {
dof::borrow_mut(&mut vault.id, keys::account_key())
}
Note: the public audit finding shows vault::account (returning &Account<C>); the mutable variant is shown here for clarity, since that is the path that enables the attack described below.
Case study: SuiFrens
// 🚨 VULNERABLE: Anyone can reset cooldown!
public fun suifren_update_last_epoch_mixed(
fren: &mut SuiFren,
epoch: u64
) {
fren.last_epoch_mixed = epoch;
}
Users could bypass the cooldown period for mixing SuiFrens by calling this function directly with epoch = 0
Again, the fix is to change it to public(package).
The Mental Model:
Think of it this way:
| Visibility | Who Can Call | Use Case |
|---|---|---|
fun (private) |
Only this module | Internal helpers |
public(package) |
Same package only | Cross-module internal APIs |
public |
Anyone, including attackers | External-facing APIs |
The security question is: "Should an attacker-deployed module be able to call this?"
If the answer is no → use public(package).
Common Mistakes:
Mistake 1: Helper functions marked public
// 🚨 BAD: Internal helper exposed publicly
public fun calculate_internal_state(vault: &mut Vault): u64 {
// This should only be called by other functions in this package
}
// ✅ GOOD: Properly restricted
public(package) fun calculate_internal_state(vault: &mut Vault): u64 {
// Now only same-package modules can call
}
Mistake 2: Mutable reference getters
// 🚨 BAD: Anyone can get mutable access
public fun get_balance_mut(pool: &mut Pool): &mut Balance {
&mut pool.balance
}
// ✅ GOOD: Either restrict visibility...
public(package) fun get_balance_mut(pool: &mut Pool): &mut Balance {
&mut pool.balance
}
// ✅ ...or require capability
public fun get_balance_mut(
pool: &mut Pool,
_admin: &AdminCap // Proof of authorization
): &mut Balance {
&mut pool.balance
}
Mistake 3: State-modifying functions without access control
// 🚨 BAD: Anyone can set config
public fun set_fee_rate(pool: &mut Pool, new_rate: u64) {
pool.fee_rate = new_rate;
}
// ✅ GOOD: Require admin capability
public fun set_fee_rate(
pool: &mut Pool,
_admin: &AdminCap,
new_rate: u64
) {
pool.fee_rate = new_rate;
}
Questions to Ask During Audit:
- Should this function be callable by external modules?
- Does it return a mutable reference to sensitive data?
- Does it modify state without requiring authorization?
- Could an attacker benefit from calling this directly?
Key Takeaways
Every public function is an attack surface. Use public(package) for internal APIs and always require capabilities for sensitive operations.
Chapter 4: Receipt and ID Validation
Hot potatoes ensure the function is called, but not with the right parameters
In Move, structs with no abilities (drop, copy, store, key) are known as “hot potatoes”, they must be consumed before the transaction ends because they can’t be stored or dropped:
public struct Receipt {
loan_amount: u64,
fee: u64,
// No `drop` ability!
}
This pattern guarantees repayment functions are called. But it doesn’t guarantee they’re called with matching objects.
Case Study: Cetus Limit Order
From the Cetus audit, where a flash loan receipt didn’t validate its source:
The Setup:
In Cetus Limit Order, users create limit orders by depositing PayCoin. When conditions are met, the order swaps PayCoin for TargetCoin. The protocol also offers flash loans, anyone can borrow PayCoin from an order temporarily, as long as they repay with TargetCoin.
public fun repay_flash_loan(
limit_order: &mut LimitOrder,
config: &GlobalConfig,
pool: &mut Pool,
target_coin: Coin,
receipt: FlashLoanReceipt,
) {
// 🚨 BUG: No check that receipt.order_id matches this limit_order!
let FlashLoanReceipt {
order_id: _order_id, // ← Ignored! Never validated!
target_repay_amount: _,
} = receipt;
// Deposits target_coin into limit_order
let target_balance = coin::into_balance(target_coin);
// ... deposits into whatever limit_order was passed ...
}
The receipt contains the order_id of the order that was borrowed from, but the function never checks that the limit_order parameter matches this ID.
The Attack:
1. Victim has an order (OrderV) with 1000 USDC, waiting to receive SUI
2. Attacker creates their own order (OrderA) with same types (USDC/SUI)
- Attacker deposits minimal USDC into OrderA
3. Attacker calls flash_loan(OrderV)
→ Borrows 1000 USDC from VICTIM's order
→ Receives receipt with order_id = OrderV.id
4. Attacker calls repay_flash_loan(OrderA, ..., receipt)
→ Passes ATTACKER's OrderA as the limit_order parameter
→ Receipt is for OrderV, but no validation is happening
→ TargetCoin (SUI) gets deposited into OrderA (attacker's order)
5. Result:
→ Victim's OrderV: Lost 1000 USDC, received nothing
→ Attacker's OrderA: Received the SUI that should have gone to victim
→ Attacker withdraws SUI from their own order
Fix is to add the following line:
// ✅ FIXED: Validate receipt matches the order being repaid
assert!(order_id == object::id(limit_order), EMismatchedOrder);
What to Look for:
- Does the receipt store the source object’s ID?
- Does the consumption function assert IDs match?
- Are all relevant fields validated, not just some?
- Can the validation be skipped via alternate code paths?
Key Takeaways
Hot potatoes ensure the function is called, but you must still validate the receipt matches the object. Always store the source object's ID in receipts and verify on consumption.
Conclusion
Move's safety guarantees are powerful but not absolute. The patterns we've covered represents some of the critical finding patterns we can see in Sui :
- Move References: Assignment to a reference variable reassigns the pointer, not the value, leading to silent corruption of wrong fields
- Type Parameters: Generics ensure type safety but not semantic correctness, validate against stored config
- Access Control: A single
publicvspublic(package)mistake can expose internal state to attackers - Receipt Validation: Hot potatoes guarantee the function is called, not that it's called correctly
These bugs don't trigger compiler errors. They don't fail with normal test inputs. They exploit the gap between what the code does and what the developer intended.
Ready to secure your code?
FAQs
What makes Move vulnerabilities different from Solidity vulnerabilities?
Sui Move is built around an object-centric model where assets exist as first-class on-chain objects with explicit ownership, and the type system enforces that they cannot be silently copied or discarded. As a consequence, several bug classes familiar to Solidity auditors, such as cross-contract reentrancy and asset double-spend bugs, have no direct analogue in Move. The bugs that remain in Sui audits are different in nature: they involve how references are dereferenced, how generic type parameters are validated, how visibility modifiers are chosen, and so on. These patterns compile cleanly and pass standard tests, so they only surface under adversarial conditions, which is why security review remains essential.
How can type parameter mismatches lead to fund theft in Move protocols?
Move's generic type system guarantees that a Pool<BTC> only holds BTC, but it does not validate that a user-supplied asset index or configuration matches the pool type passed into a function. An attacker can exploit this by passing a high-value pool alongside an asset index for a different token, tricking the protocol into accounting for a withdrawal from one asset while releasing funds from another. The fix is to either derive the asset index directly from the type parameter, or assert that the type matches the stored configuration.
Why are hot potato receipts in Move insufficient to prevent flash loan exploits on their own?
Hot potato structs, those with no drop, copy, key, or store abilities, guarantee that
Hot potato structs, those with no drop, copy, or store abilities, guarantee that a repayment function must be called before a transaction completes. However, they do not guarantee the repayment is applied to the correct object. If the receipt's source ID is not validated against the object passed into the repayment function, an attacker can borrow from a victim's order and repay into their own, redirecting funds while the receipt is technically consumed. Always store the source object's ID in the receipt and assert it matches on consumption.
Note: The code snippets in this material might not exactly match with the reference protocol’s codebase. Material is prepared only with access to audit reports and code snippets are simplified to focus on the vulnerability types