News | OpenZeppelin

Critical Bug Patterns in Sui Move: Lessons from Real Audits

Written by Kose Dogus | April 29, 2026

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:

The Mental Model Shift From Solidity

In Solidity, variable assignment always copies values:

To modify storage, we use the storage keyword explicitly:

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.

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):

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

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:

Now when the subsequent code runs:

The code reads from and writes to MinterCap.limit instead of MinterCap.left .

The Fix

  • *limit → Reads the value (1000) from the memory location
  • *left = ... → Writes to the where left points (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

  1. It compiles successfully : No compiler warning
  2. It runs without errors : No runtime abort
  3. It appears to work : The function completes
  4. The effect is silent : The wrong field gets modified

Impact: The bug causes two problems:

  • MinterCap.left is never actually reset (stays orphaned at 0)
  • MinterCap.limit gets decremented instead of MinterCap.left

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:

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:

A position created with SupplyPool<X, SX0> could be liquidated using SupplyPool<X, SX1>.

Exploit Path and Impact:

  1. User borrows from SupplyPool<X,SX0> (their debt shares are type SX0)
  2. Liquidator calls liquidate with SupplyPool<X,SX1>
  3. Since there is no collateral to pay for SX1, liquidator doesn’t need to provide any repayment.
  4. Liquidator still receives collateral rewards
  5. 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

Option 2: Derive index from type (no user input)

Option 3: Store pool reference in position

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 function
  • public(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:

This internal helper function was marked public instead of public(package).

Here is the attack path:

  1. Attacker deploys their own module
  2. Attacker calls vault::account_mut(target_vault)
  3. Attacker receives &mut Account<C> (full mutable access)
  4. Attacker modifies trade positions, drains collateral

The Fix:

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

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

Mistake 2: Mutable reference getters

Mistake 3: State-modifying functions without access control

Questions to Ask During Audit:

  1. Should this function be callable by external modules?
  2. Does it return a mutable reference to sensitive data?
  3. Does it modify state without requiring authorization?
  4. 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:

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.

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:

Fix is to add the following line:

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 :

  1. Move References: Assignment to a reference variable reassigns the pointer, not the value, leading to silent corruption of wrong fields
  2. Type Parameters: Generics ensure type safety but not semantic correctness, validate against stored config
  3. Access Control: A single public vs public(package) mistake can expose internal state to attackers
  4. 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.

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