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.
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 .
*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 |
Impact: The bug causes two problems:
MinterCap.left is never actually reset (stays orphaned at 0)MinterCap.limit gets decremented instead of MinterCap.leftKey Takeaway
Move makes references explicit where Solidity hides them. When you see &mut types, remember:
= reassigns the pointer (without *)* accesses the underlying dataIf 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.
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
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!
Similar pattern in liquidation context:
A position created with SupplyPool<X, SX0> could be liquidated using SupplyPool<X, SX1>.
Exploit Path and Impact:
SupplyPool<X,SX0> (their debt shares are type SX0)SupplyPool<X,SX1>SX1, liquidator doesn’t need to provide any repayment.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
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.
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 functionWhen an internal helper is accidentally marked public instead of public(package), attackers can deploy their own module and call it directly.
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:
vault::account_mut(target_vault)&mut Account<C> (full mutable access)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).
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).
Mistake 1: Helper functions marked public
Mistake 2: Mutable reference getters
Mistake 3: State-modifying functions without access control
Questions to Ask During Audit:
Key Takeaways
Every public function is an attack surface. Use public(package) for internal APIs and always require capabilities for sensitive operations.
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.
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:
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.
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 :
public vs public(package) mistake can expose internal state to attackersThese 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.
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