In Sui, Move source is the program developers write and auditors inspect, but published bytecode is what validators and the Move VM execute. That makes the compiler part of every package's security boundary: if compilation preserves types and resource rules while changing a branch's meaning, the rest of the stack can accept and execute a valid—but unintended—program.
match expressions sit directly on that boundary. In Sui Move protocols, they can select a recipient, authorize a withdrawal, record a user's claim, or choose a settlement path. A wrong arm is therefore more than a wrong return value: when the missing branch controls value flow, assets can remain stranded in a vault, reach a fallback destination, or become accessible after an intended check disappears.
| Research profile | Finding |
|---|---|
| Target | Sui Move compiler — Move 2024 match lowering |
| Vulnerability class | Semantic miscompilation / incorrect match-arm selection |
| Affected component | PatternMatrix::wild_tree_opt in shared/matching.rs |
| Demonstrated divergence | Source semantics: 50 · affected bytecode: 0 |
| Potential economic impact | Dropped credit, misrouted payment, or skipped authorization when the omitted arm controls value |
| Resolution | Fixed in PR #25615 / commit 278cdc8867, merged February 26, 2026 |
| Public exploitation | No confirmed exploitation identified in the material reviewed |
Status: fixed upstream. Mysten Labs corrected the defect in PR #25615. Toolchains containing the vulnerable pre-fix implementation—and bytecode already produced by them—are not changed retroactively.
1. Overview — Where Source and Bytecode Diverged
That is the failure reconstructed here. The reduced case models a payment router receiving Send(Alice(50)) while its pause guard is false. Source-level match semantics require evaluation to continue past the guarded broad arm, select the Alice-specific arm, and produce 50.
An affected compiler instead omitted the Alice arm while constructing the match decision tree. The final bytecode retained the pause guard and catch-all, passed verification, and executed deterministically—but its observed result was 0. The failure was not a VM crash or malformed module; it was valid bytecode with behavior that no longer matched the valid source.
The reduced PoC uses local u64 accounting and an abort status. It proves a deterministic 50 → 0 semantic divergence, not confirmed token theft or mainnet exploitation. The fund-loss analysis below explains the additional conditions required for that compiler primitive to become an economic loss.
2. Background — The Compiler Is Part of the Security Boundary
The Chain Executes Bytecode, Not Source
Move source → typing and match analysis → HLIR match lowering → Move bytecode → bytecode verifier → published package → Move VM executionEach layer proves something different. Typing establishes that the source patterns and arm bodies are legal. The verifier later checks the emitted bytecode's types, references, resources, and structural invariants. Neither step proves that lowering preserved source-level branch precedence. Once a valid branch disappears during HLIR construction, downstream layers have no source arm from which to reconstruct it.
How a Move `match` Becomes Control Flow
The current compiler makes the transformation explicit in hlir/match_compilation.rs: source arms become a PatternMatrix, the matrix becomes a recursive MatchTree, and that tree is finally lowered into ordinary HLIR expressions.
let (pattern_matrix, arms) = PatternMatrix::from(context, loc, subject.ty.clone(), arms.value); let match_tree = build_match_tree(context, VecDeque::from([match_subject]), pattern_matrix); let match_exp = match_tree_to_exp(&mut resolution_context, &init_subject, match_tree);PatternMatrix::from retains arm order and records each original right-hand side. build_match_tree then decides whether to emit a literal switch, struct unpack, variant switch, or leaf. A wrong early Leaf decision is therefore destructive: the omitted arm never reaches match_tree_to_exp, bytecode generation, or execution.
Guards and Fallthrough
match (subject) { Broad(_) if (guard) => guarded_action(), Concrete(payload) => handler(payload), _ => fallback(),}A guard does not make an arm exclusive. The pattern is tested first; if it matches but the guard is false, evaluation continues in source order. In this shape, a false guard must give Concrete(payload) a chance to match before the fallback is considered.
Pattern Matrices and Specialization
The compiler represents match arms as rows in a pattern matrix, then recursively specializes that matrix on constructors. In the PoC, the outer Request::Send constructor is consumed first. After that step, the broad pattern Request::Send(_) is reduced to _, while the next rows still depend on the inner Recipient::Alice and Recipient::Bob constructors.
- The first relevant row becomes wildcard-like and carries a guard.
- A later row still depends on a constructor, such as the enum variant in the PoC or a literal covered by upstream tests.
- A still-later unguarded wildcard provides the fallback.
- Execution falls through the retained guard chain and an omitted concrete row would otherwise match.
The nested-enum case is how the divergence was first exposed, not the true boundary of the bug. Regression tests added with the fix cover a flat enum and a bare literal as well. The actual trigger is any recursive matrix stage that reaches guarded all-wild row → constructor-dependent row → unguarded all-wild fallback.
The Wildcard-Leaf Optimization
Before switching on the next constructor, build_match_tree asks wild_tree_opt whether the remaining matrix can be represented as a constructor-independent chain of guarded wildcard arms:
if let Some(leaf) = matrix.wild_tree_opt(context, &fringe) { return MatchTree::Leaf(leaf);} // Otherwise inspect the next subject and specialize by constructor.let Some(subject) = fringe.pop_front() else { return MatchTree::Failure;};Returning Some(leaf) is a strong proof claim: the compiler is saying that inspecting the subject's constructor is no longer necessary. That shortcut is sound only while every row it collects is wildcard-like. The vulnerable implementation violated this boundary.
3. The Bug — A Constructor Arm Was Silently Skipped
The vulnerable loop attempted to convert every later row with all_wild_arm. A constructor-dependent row returns None, because it cannot be represented without inspecting the subject. But the loop treated that result as a row to skip rather than a reason to abandon the optimization:
let mut result = vec![arm];for pat in self.patterns[1..].iter_mut() { if let Some(arm) = pat.all_wild_arm(context, fringe) { let has_guard = arm.guard.is_some(); result.push(arm); if !has_guard { return Some(result); } }}NoneThe if let had no else. When Recipient::Alice(amount) returned None, iteration continued. The same happened to Bob. The final unguarded _ did convert successfully, so wild_tree_opt returned a leaf containing the guard and fallback—but neither concrete recipient arm.
| Row after `Send` specialization | Guard | Vulnerable optimizer |
|---|---|---|
_ | paused | Append guarded wildcard |
Recipient::Alice(amount) | None | Return value is None; silently skip row |
Recipient::Bob(amount) | None | Return value is None; silently skip row |
_ | None | Append fallback and return the leaf |
Because build_match_tree immediately accepted that leaf, normal variant specialization never ran. Later, match_tree_to_exp passed the shortened leaf to make_leaf, which uses the final unguarded arm as the else branch and wraps the earlier guarded arms around it. Alice and Bob were never in that leaf, so they could not appear in the generated control flow:
if (paused) { abort 0xDEAD} else { // Alice and Bob no longer exist in this branch tree. {}}The value did not become zero because of arithmetic, overflow, or storage corruption. It became zero because the only branch that could assign 50 never reached the emitted program.
4. Why the Compiler Pipeline Did Not Catch It
| Layer | What it checked | Why the bug survived |
|---|---|---|
| Parser and type checker | Patterns, guards, bindings, and result types | The source is valid |
| Match lowering | Translation into executable control flow | This phase removed the concrete arms |
| Bytecode verifier | Typing, resources, references, and bytecode structure | The shortened program remains valid bytecode |
| Move VM | Deterministic execution of verified bytecode | It faithfully executes the wrong program it received |
The verifier cannot infer that a source-level Alice branch should exist. It validates the bytecode in front of it, not equivalence between that bytecode and the source. Detecting this class of failure requires a source-aware oracle such as differential execution or translation validation.
How the Divergence Was Found
According to the original report, generative differential fuzzing executed well-typed Move programs both as compiled bytecode and through a reference interpreter, then compared their results. Roughly 15,000 programs agreed before a nested-enum match produced the first mismatch. The compiler did not crash; it simply produced the wrong answer, which is why the semantic oracle mattered.
5. Proof of Concept — Reproducing 50 → 0
The reduced transactional PoC models a payment router. The outer enum identifies the request, the inner enum identifies the recipient, and a broad guarded arm blocks sends only while the system is paused.
//# init --edition 2024.beta //# runmodule 0x42::vault { public enum Recipient has drop { Alice(u64), Bob(u64) } public enum Request has drop { Send(Recipient), Noop } fun main() { let mut credited_alice: u64 = 0; let mut credited_bob: u64 = 0; let paused = false; let req = Request::Send(Recipient::Alice(50)); match (req) { Request::Send(_) if (paused) => abort 0xDEAD, Request::Send(Recipient::Alice(amount)) => { credited_alice = credited_alice + amount; }, Request::Send(Recipient::Bob(amount)) => { credited_bob = credited_bob + amount; }, _ => {}, }; // Use the abort sub-status as an observable result. abort (credited_alice) }}At source level, Request::Send(_) matches but its paused guard is false, so matching continues. Recipient::Alice(50) then matches, binds amount = 50, and credits the accumulator. The fallback is unreachable for this input.
| Execution | Observed sub-status | Selected path |
|---|---|---|
| Source semantics / fixed compiler | 50 | Alice arm |
| Affected compiler | 0 | Unguarded fallback |
The PoC uses local u64 values and deliberately aborts to expose the selected result as a sub-status. It does not execute a real Coin transfer, and an abort would roll back real state changes.
6. Security Impact and Exploitability
How Valid Bytecode Can Still Lose Money
Move's resource model protects the existence of assets; it does not prove that business logic assigns the correct economic owner. Consider a protocol that first joins a user's Coin into a vault, then uses a recipient-specific match arm to record the user's claim. If the compiler removes that arm and a no-op fallback succeeds, the coin remains resource-safe inside the vault while the user's credited balance remains zero. The bytecode is valid; the economic result is not.
user asset is escrowed or debited → affected pre-fix compiler omits a concrete settlement arm → affected bytecode is published → a matching input reaches the false-guard path → a post-escrow no-op or resource-safe fallback executes successfully → transaction commits without the intended credit, refund, or check → value remains stranded, is misrouted, or becomes withdrawable incorrectly| Protocol flow | Omitted arm | Possible economic result |
|---|---|---|
| Deposit or escrow | Credit the recipient's internal balance | Assets enter the vault but the user's claim remains zero |
| Payment or refund routing | Select the intended recipient and amount | A fallback legally consumes or routes value to a default or wrong destination |
| Withdrawal or settlement | Enforce an authorization or invariant check | A permissive fallback allows an operation the source intended to reject |
Potential Impact
- Dropped or misrouted payment — A post-escrow no-op leaves the user's claim at zero, or a resource-safe fallback routes value to a default destination.
- Skipped validation — A concrete arm performs an authorization, limit, or invariant check that the fallback never executes.
- Accounting divergence — The omitted arm updates shares, debt, rewards, or liabilities while the fallback leaves related state unchanged.
Required Conditions
- The package was compiled with an affected revision containing the vulnerable pre-fix implementation.
- The normalized matrix reaches guarded wildcard → constructor-dependent arm → unguarded fallback ordering.
- Execution falls through the retained guard chain and the omitted concrete arm would otherwise match.
- An attacker can influence the matched subject or the state that determines the relevant guard outcome.
- The fallback succeeds or routes execution into a security-relevant state transition instead of reverting harmlessly.
If the fallback aborts, the transaction normally rolls back and the practical effect is failure or denial of service—not fund loss. If the skipped arm controls no value, accounting, or authorization decision, the semantic bug may have no security impact. The loss scenario requires the wrong branch to change what successfully commits.
We found no public evidence of confirmed exploitation in the material reviewed. The article's 50 → 0 result is a modeled accounting divergence, not proof that 50 real tokens were lost or that PR #25615 responded to a known theft.
7. The Fix — Stop Instead of Skip
The patch did not add a runtime check. It restored the compiler invariant that the wildcard shortcut had violated. The first row that cannot become an all-wildcard arm now makes the optimization return None:
let mut result = vec![arm];for pat in self.patterns[1..].iter_mut() { // If we find a non-wildcard arm, we have only seen guarded wildcards and should // specialize against the fringe instead. let Some(arm) = pat.all_wild_arm(context, fringe) else { return None; }; let has_guard = arm.guard.is_some(); result.push(arm); if !has_guard { return Some(result); }}NoneHere return None is not a compilation failure. It declines the shortcut and returns control to build_match_tree, which performs normal constructor or literal specialization. The Alice and Bob rows remain in the matrix and reach their correct leaves in source order.
How the Current Compiler Encodes the Boundary
The current Sui compiler has since refactored wild_tree_opt, so readers will not find the PR-era loop verbatim at HEAD. Today it first proves that an unguarded fallback exists inside the contiguous wildcard prefix, then converts only that proven prefix into a leaf:
let stop = self .patterns .iter() .take_while(|pat| pat.is_wild_arm()) .position(|pat| pat.guard.is_none())?; Some( self.patterns[..=stop] .iter_mut() .map(|pat| pat.all_wild_arm(context, fringe).unwrap()) .collect(),)A concrete enum or literal row now ends take_while. Because no unguarded wildcard was found before that boundary, position(...) returns None and the shortcut is declined. The read-only proof also happens before all_wild_arm mutates binders, avoiding a partially converted matrix when the optimization cannot apply.
The restored invariant is precise: a wildcard leaf may contain guarded all-wildcard rows ending in an unguarded all-wildcard row. If any row in between depends on a constructor, the shortcut must stop. The official PR #25615 regressions exercise both guard outcomes across flat enum variants and literal values, confirming that the fix protects the lowering rule rather than only the nested-enum discovery shape.
Before the fix, source 50 could become 0. With normal specialization restored, the false pause guard reaches the Alice arm and 50 remains 50.
Conclusion — Semantic Equivalence Is the Security Boundary
This bug is dangerous precisely because nothing looked obviously broken. The source remained readable, the compiler emitted valid bytecode, and the verifier accepted it. Yet the program executed on-chain could make a different security decision from the program developers reviewed. When the missing branch controls a payment, refund, balance update, or authorization check, that silent semantic gap can become stranded funds, misrouted value, or an unintended state transition.
PR #25615 fixed this specific lowering error, but the broader lesson extends beyond one optimization. Compiler correctness is part of smart-contract security. Teams should write unit tests that exercise every security-relevant match arm under both true and false guard outcomes, then assert the resulting balances, ownership, permissions, and state—not merely whether execution succeeds. Compiler maintainers should reinforce those tests with differential execution that compares source-level semantics against the generated bytecode across the same inputs.
The required invariant is simple and non-negotiable: compilation may change representation, never meaning. If reviewed source says 50, deployed bytecode must not execute 0. For code entrusted with user assets, proving that equivalence before publication is not optional quality assurance; it is the final security check between intent and irreversible on-chain execution.



