Back to Blog

research

Source Says 50. Bytecode Says 0: The Sui Move Compiler Bug That Could Silently Drop Payments

Smart contracts execute bytecode, not the source developers review. We investigate a fixed Sui Move match-lowering bug that could erase a value-flow branch, turning a modeled 50-unit credit into 0 while still producing valid, verifiable bytecode.

ExVul Security Research Team

Security Researchers

12 min
  • Sui Move
  • Compiler Security
  • Miscompilation
  • Pattern Matching
  • Fixed
Source Says 50. Bytecode Says 0: The Sui Move Compiler Bug That Could Silently Drop Payments

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 profileFinding
TargetSui Move compiler — Move 2024 match lowering
Vulnerability classSemantic miscompilation / incorrect match-arm selection
Affected componentPatternMatrix::wild_tree_opt in shared/matching.rs
Demonstrated divergenceSource semantics: 50 · affected bytecode: 0
Potential economic impactDropped credit, misrouted payment, or skipped authorization when the omitted arm controls value
ResolutionFixed in PR #25615 / commit 278cdc8867, merged February 26, 2026
Public exploitationNo 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

Sui Move execution boundary
Move source
typing and match analysis
HLIR match lowering
Move bytecode
bytecode verifier
published package
Move VM execution

Each 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.

hlir/match_compilation.rs — compilation pipeline
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

abstract trigger shape
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:

hlir/match_compilation.rs
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:

matching.rs — vulnerable logic
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);
}
}
}
None

The 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` specializationGuardVulnerable optimizer
_pausedAppend guarded wildcard
Recipient::Alice(amount)NoneReturn value is None; silently skip row
Recipient::Bob(amount)NoneReturn value is None; silently skip row
_NoneAppend 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:

affected lowered behavior
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

LayerWhat it checkedWhy the bug survived
Parser and type checkerPatterns, guards, bindings, and result typesThe source is valid
Match loweringTranslation into executable control flowThis phase removed the concrete arms
Bytecode verifierTyping, resources, references, and bytecode structureThe shortened program remains valid bytecode
Move VMDeterministic execution of verified bytecodeIt 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.

fund_loss_poc.move
//# init --edition 2024.beta
//# run
module 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.

ExecutionObserved sub-statusSelected path
Source semantics / fixed compiler50Alice arm
Affected compiler0Unguarded 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.

conditional value-loss path
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 flowOmitted armPossible economic result
Deposit or escrowCredit the recipient's internal balanceAssets enter the vault but the user's claim remains zero
Payment or refund routingSelect the intended recipient and amountA fallback legally consumes or routes value to a default or wrong destination
Withdrawal or settlementEnforce an authorization or invariant checkA permissive fallback allows an operation the source intended to reject

Potential Impact

  • Dropped or misrouted paymentA post-escrow no-op leaves the user's claim at zero, or a resource-safe fallback routes value to a default destination.
  • Skipped validationA concrete arm performs an authorization, limit, or invariant check that the fallback never executes.
  • Accounting divergenceThe 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:

matching.rs — PR #25615 patch (historical)
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);
}
}
None

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

shared/matching.rs — current implementation
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.