<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[mehvetero]]></title><description><![CDATA[Smart contract security researcher. EVM and Move auditing, static analysis tooling, incident post-mortems.]]></description><link>https://mehvetero.com</link><image><url>https://cdn.hashnode.com/uploads/logos/6a6316b60ed37bae98b22704/fae084d4-0fde-4664-a9af-191fc33e7e17.jpg</url><title>mehvetero</title><link>https://mehvetero.com</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 06 Sep 2026 15:20:28 GMT</lastBuildDate><atom:link href="https://mehvetero.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How $951 Bought a $8.5M Vote: The Term Finance Governance Exploit]]></title><description><![CDATA[Zero bugs. Zero hacks. Just a vote.
On August 23, 2026, at 06:25 UTC, an attacker executed a single parameterless function call on Ethereum and walked away with $8.5 million from Term Finance's vaults]]></description><link>https://mehvetero.com/how-951-bought-a-8-5m-vote-the-term-finance-governance-exploit</link><guid isPermaLink="true">https://mehvetero.com/how-951-bought-a-8-5m-vote-the-term-finance-governance-exploit</guid><category><![CDATA[term-finance]]></category><category><![CDATA[hacking]]></category><category><![CDATA[analyze]]></category><category><![CDATA[Web3]]></category><category><![CDATA[defi]]></category><dc:creator><![CDATA[mehvetero]]></dc:creator><pubDate>Thu, 27 Aug 2026 01:07:18 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a6316b60ed37bae98b22704/f43f165b-13b6-4590-a745-c6a525c335bd.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Zero bugs. Zero hacks. Just a vote.</em></p>
<p>On August 23, 2026, at 06:25 UTC, an attacker executed a single parameterless function call on Ethereum and walked away with $8.5 million from Term Finance's vaults. No exploit in the traditional sense — no reentrancy, no overflow, no oracle manipulation. The smart contracts worked exactly as designed. The attacker simply voted.</p>
<p>This is the anatomy of a governance takeover — the kind of attack that no code audit would have caught, because the code was never the problem.</p>
<hr />
<h2>The Target</h2>
<p>Term Finance is an Ethereum-based fixed-rate lending protocol. Its yield products — the Meta Vaults — sit on Yearn V3 infrastructure with a custom governance layer bolted on top. Users deposit ETH and USDC into these vaults, which then deploy capital across strategies in Aave, Morpho Blue, and other lending markets.</p>
<p>At the time of the attack, Term's vaults held roughly $12.45 million in TVL, with $8.8 million on Ethereum mainnet.</p>
<p>The governance design: vault depositors receive a governance token (tmvETH) proportional to their stake. Token holders vote on proposals that control vault parameters — which strategies to add, what fee structures to use, where capital flows. A 7-day timelock and an LP veto mechanism were supposed to prevent hostile proposals from executing.</p>
<p>All of this worked as intended. That was the problem.</p>
<hr />
<h2>The Kill Chain</h2>
<h3>Day 0 — August 17, 05:01 UTC: The $951 Coup</h3>
<p><strong>Block 25,772,572.</strong> A fresh wallet (<code>0xa908b347...</code>) receives 0.9945 ETH from an intermediary funded by Tornado Cash. Two ETH total — seed money.</p>
<p><strong>Block 25,772,675</strong> (twenty minutes later). The wallet purchases 0.4852 tmvETH — the governance token for Term's ETH Meta Vault — for approximately 0.5 ETH.</p>
<p>Cost: roughly $951.</p>
<p>The total staked supply of gtmvETH at that moment: 0.5352.</p>
<p>The attacker now controls <strong>90.66% of all voting power</strong> in the ETH Meta Vault. Four USDC strategy vaults fall under full control as well — their governance participation was even thinner.</p>
<p><em>The entire defense model assumed the electorate would be large enough that no single buyer could reach a majority. It wasn't. The governance token market was so thin that the protocol's security reduced to a liquidity depth question — and no one was watching the depth.</em></p>
<h3>Day 0 — Proposals Submitted</h3>
<p>With 90.66% voting power, the attacker submits governance proposals to the affected vaults. The proposals contain two critical actions, ordered deliberately:</p>
<p><strong>Action 1: Set the timelock cooldown to zero.</strong></p>
<p>This is the move that made everything else possible. The 7-day timelock was the last line of defense — after a proposal passes, there's supposed to be a mandatory waiting period before execution. The attacker's first action upon execution removes that waiting period for all subsequent proposals. One vote to end all future delay.</p>
<p><strong>Action 2: Add a new "strategy" contract.</strong></p>
<p>In Yearn V3 architecture, strategies are the modules that deploy vault capital into external protocols. The attacker's "strategy" is a contract whose sole purpose is to route withdrawn funds to the attacker's address (<code>0x184f2e57b4...</code>). It looks like a strategy. It functions as a drain.</p>
<h3>Day 1–5 — Silence</h3>
<p>The proposals sit in the timelock queue. The 7-day waiting period ticks.</p>
<p>Term Finance had an LP veto mechanism — liquidity providers could reject hostile proposals during this window. Nobody vetoed. The governance forum showed no discussion of these proposals. The monitoring infrastructure either didn't exist or didn't trigger.</p>
<p>Five days of a loaded gun sitting on the table, and nobody noticed.</p>
<h3>Day 6 — August 23, 06:25 UTC: Execution</h3>
<p><strong>Block 25,816,049.</strong> The attacker calls <code>0x373058b8</code> on the governance executor contract (<code>0x64e47780...</code>). A single function, no parameters. Execute.</p>
<p>What happens inside this transaction — 78 log events in total:</p>
<ol>
<li><p>The timelock cooldown drops to zero (Action 1 fires)</p>
</li>
<li><p>The malicious strategy is added to the vault roster</p>
</li>
<li><p>The Meta Vault begins withdrawing from its real strategies:</p>
<ul>
<li><p>Aave WETH pool: two sub-vaults redeemed</p>
</li>
<li><p>Morpho Blue (<code>0xbbbbbbbb...</code>): two withdrawals via Yearn V3 wrapper</p>
</li>
<li><p>Each redemption flows back through the Meta Vault aggregator (<code>0x26fcb50e...</code>)</p>
</li>
</ul>
</li>
<li><p>Capital is routed to the attacker's strategy (<code>0x184f2e57b4...</code>)</p>
</li>
<li><p>The strategy forwards <strong>2,841.74 WETH</strong> to the attacker wallet</p>
</li>
</ol>
<p><strong>Transaction:</strong> <a href="https://etherscan.io/tx/0xd354a15b15cb73d30908f411aee3f795ec86737a4d080e9a818ac4d6d3014129"><code>0xd354a15b...</code></a></p>
<p>A separate execution drains the USDC vaults — approximately <strong>1.68 million USDC</strong>, immediately swapped to DAI through on-chain DEX routes.</p>
<p>Total extraction: <strong>~$8.5 million</strong> from a $951 investment.</p>
<p>Return on capital: <strong>8,938x</strong>.</p>
<hr />
<h2>The Money Trail</h2>
<p>Post-drain, the attacker moves quickly but not completely.</p>
<p>The 2,841 WETH is unwrapped to ETH and forwarded through a series of splitter contracts, all pointing to variations of <code>0xd518...</code> addresses. Address poisoning tactics are visible in the transaction log — fake "EṬH" transfers (note the modified T character: Ṭ vs T) sent to similar-looking addresses, a social engineering technique targeting anyone monitoring the flow who might copy-paste the wrong destination.</p>
<p>As of this analysis, the consolidation wallet <code>0xD5183d8BfC65a50863C62aF2538198A8288FFc13</code> holds:</p>
<table>
<thead>
<tr>
<th>Asset</th>
<th>Amount</th>
<th>USD Value</th>
</tr>
</thead>
<tbody><tr>
<td>ETH</td>
<td>2,543.15</td>
<td>~$6.15M</td>
</tr>
<tr>
<td>DAI</td>
<td>1,679,642.45</td>
<td>~$1.68M</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td></td>
<td><strong>~$7.83M</strong></td>
</tr>
</tbody></table>
<p>Approximately $670K has been spent on gas, forwarding, and partial exits. The bulk remains untouched — either the attacker is patient, or they're waiting for attention to die down.</p>
<hr />
<h2>Why Every Safeguard Failed</h2>
<h3>The Timelock Paradox</h3>
<p>A 7-day timelock is only useful if someone is watching. Term Finance's governance participation was so low that the proposals passed with zero opposition. But the deeper design flaw is subtler: <strong>the timelock itself was governed by the same vote that it was supposed to protect against.</strong></p>
<p>The attacker's first action — setting cooldown to zero — is the governance equivalent of using the first wish to wish for unlimited wishes. If the entity that controls the timelock is the same entity the timelock is supposed to constrain, the timelock is decorative.</p>
<h3>The Veto That Wasn't</h3>
<p>LP veto rights existed on paper. No LP exercised them. This isn't an implementation failure — it's a participation failure. The mechanism assumed LPs would:</p>
<ol>
<li><p>Monitor the governance forum</p>
</li>
<li><p>Understand the proposals</p>
</li>
<li><p>Recognize the threat</p>
</li>
<li><p>Act within the window</p>
</li>
</ol>
<p>Each assumption was individually reasonable. Together, they built a defense that required four human actions in sequence, any one of which could fail to happen. The attacker only needed one to not happen. It was all four.</p>
<h3>The Token Distribution That Nobody Checked</h3>
<p>The most fundamental failure: at the moment of attack, the governance token had a total staked supply of 0.5352 gtmvETH. The entire security model — the votes, the timelock, the veto — was downstream of a single assumption: that this number would be large enough to make a hostile majority prohibitively expensive.</p>
<p>It cost $951.</p>
<p>No monitoring system checked this number. No circuit breaker fired when a single address acquired 90% of voting power. The protocol treated governance token concentration as someone else's problem.</p>
<hr />
<h2>The Architecture Lesson</h2>
<p>This exploit belongs to a class that traditional smart contract audits cannot catch. The code functioned correctly. The governance mechanism executed as specified. The timelock enforced its rules. Every <code>require</code> passed, every <code>assert</code> held, every state transition was valid.</p>
<p>The vulnerability was economic, not technical:</p>
<ol>
<li><p><strong>Governance security is a function of participation, not mechanism design.</strong> A perfectly designed voting system with 0.5 participants is less secure than a flawed one with 10,000.</p>
</li>
<li><p><strong>Timelocks must not govern themselves.</strong> If a governance vote can modify the timelock parameters, the timelock provides exactly one window of protection — the first one. After that, the attacker owns the clock.</p>
</li>
<li><p><strong>Token concentration is a security metric.</strong> Protocols that expose governance power through thin markets need circuit breakers: minimum quorum, concentration alerts, automatic pause when a single address crosses a voting threshold.</p>
</li>
<li><p><strong>The veto window is a monitoring commitment, not a safety feature.</strong> A veto mechanism that nobody monitors is indistinguishable from no veto mechanism at all.</p>
</li>
<li><p><strong>$951 is cheaper than an audit.</strong> The attacker spent less on this entire operation than most protocols spend on a single smart contract review. The audit would have found nothing — the code was clean. The governance design was the attack surface, and no auditor was looking at token distribution depth.</p>
</li>
</ol>
<hr />
<h2>What Should Have Existed</h2>
<ul>
<li><p><strong>Minimum quorum</strong>: proposals below X% participation cannot execute, regardless of vote ratio</p>
</li>
<li><p><strong>Concentration alert</strong>: if a single address holds &gt;Y% of voting power, governance enters emergency mode (pause + multisig override)</p>
</li>
<li><p><strong>Timelock immutability</strong>: core safety parameters (timelock duration, veto window) cannot be modified through governance — they require a multisig or are hardcoded</p>
</li>
<li><p><strong>Strategy whitelist with independent approval</strong>: new strategy contracts require approval from a separate security council, not the same governance that benefits from the strategy</p>
</li>
<li><p><strong>Active monitoring with auto-pause</strong>: governance proposal submission triggers alerts to all LPs, and proposals modifying safety parameters auto-escalate to a higher approval threshold</p>
</li>
</ul>
<p>None of these are novel. All of them existed in other protocols before this attack. They just weren't implemented here.</p>
<hr />
<h2>Timeline</h2>
<table>
<thead>
<tr>
<th>Date</th>
<th>Event</th>
</tr>
</thead>
<tbody><tr>
<td>Aug 17, 05:01 UTC</td>
<td>Attacker wallet funded via Tornado Cash (2 ETH)</td>
</tr>
<tr>
<td>Aug 17, 05:21 UTC</td>
<td>0.4852 tmvETH purchased (~$951) → 90.66% voting power</td>
</tr>
<tr>
<td>Aug 17 (est.)</td>
<td>Governance proposals submitted</td>
</tr>
<tr>
<td>Aug 17–22</td>
<td>6-day timelock window — zero vetoes, zero discussion</td>
</tr>
<tr>
<td>Aug 23, 06:25 UTC</td>
<td>Proposals executed: timelock zeroed, malicious strategy added, 2,841.74 WETH drained</td>
</tr>
<tr>
<td>Aug 23, 06:25+ UTC</td>
<td>USDC vaults drained (~1.68M USDC → DAI)</td>
</tr>
<tr>
<td>Aug 23</td>
<td>Term Labs shuts down all Meta Vaults, revokes DAO governance roles</td>
</tr>
<tr>
<td>Aug 26 (this analysis)</td>
<td>$7.83M still in consolidation wallet</td>
</tr>
</tbody></table>
<hr />
<p><em>On-chain data sourced via Alchemy Ethereum mainnet RPC. All addresses and transaction hashes are independently verifiable on Etherscan.</em></p>
]]></content:encoded></item><item><title><![CDATA[My first competitive audit. Submitted as HIGH, came back as LOW. That stung for about ten minutes — then I re-read the judge's reasoning and they were right.]]></title><description><![CDATA[SweepUnclaimedBonus_Drains_Attacker
Summary
​ A permissionless sweepUnclaimedBonus() between a moderator's SURVIVED flag and a corrective CORRUPTED re-flag irreversibly drains the bonus to recoveryAdd]]></description><link>https://mehvetero.com/my-first-competitive-audit-submitted-as-high-came-back-as-low-that-stung-for-about-ten-minutes-then-i-re-read-the-judge-s-reasoning-and-they-were-right</link><guid isPermaLink="true">https://mehvetero.com/my-first-competitive-audit-submitted-as-high-came-back-as-low-that-stung-for-about-ten-minutes-then-i-re-read-the-judge-s-reasoning-and-they-were-right</guid><category><![CDATA[CodeHawks]]></category><category><![CDATA[audit]]></category><category><![CDATA[defi]]></category><category><![CDATA[Study ]]></category><category><![CDATA[bugbounty]]></category><dc:creator><![CDATA[mehvetero]]></dc:creator><pubDate>Sun, 16 Aug 2026 03:04:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a6316b60ed37bae98b22704/729b0609-7136-45a0-983d-6cf57de25fb7.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>SweepUnclaimedBonus_Drains_Attacker</p>
<h2>Summary</h2>
<p>​ A permissionless <code>sweepUnclaimedBonus()</code> between a moderator's <code>SURVIVED</code> flag and a corrective <code>CORRUPTED</code> re-flag irreversibly drains the bonus to <code>recoveryAddress</code>, permanently reducing the whitehat attacker's bounty. The protocol pays the wrong recipient with no recovery path. ​</p>
<h2>Description</h2>
<p>​ <code>sweepUnclaimedBonus</code> does not set <code>claimsStarted</code> (L503), so the re-flag window stays open after the sweep. When <code>riskWindowStart == 0</code>, bonus is unreserved and fully sweepable (L483-487). On re-flag, the snapshot captures the now-zeroed <code>totalBonus</code> (L358), producing a reduced <code>bountyEntitlement</code>: ​</p>
<pre><code class="language-solidity">// ConfidencePool.sol L357-362
snapshotTotalBonus = totalBonus;           // captures zeroed value after sweep
bountyEntitlement = willBeGoodFaithCorrupted
? snapshotTotalStaked + snapshotTotalBonus  // bonus portion is zero
: 0;
</code></pre>
<p>​ Because the snapshot occurs after the sweep, no later code path restores the swept bonus into <code>bountyEntitlement</code>. ​ <strong>Reachability of</strong> <code>riskWindowStart == 0</code><strong>:</strong> The registry requires <code>UNDER_ATTACK</code> before <code>CORRUPTED</code> (<code>AttackRegistry.sol</code> L324). But the pool only seals <code>riskWindowStart</code> on a pool-touching transaction (L793). If <code>approveAttack()</code> and <code>markCorrupted()</code> execute with no pool interaction in between (e.g., same block), <code>riskWindowStart</code> stays zero. ​</p>
<h2>Vulnerability Details</h2>
<p>​ <strong>Attack path:</strong> ​</p>
<ol>
<li><p>Registry reaches <code>CORRUPTED</code> with no pool interaction during <code>UNDER_ATTACK</code> — <code>riskWindowStart</code> stays zero.</p>
</li>
<li><p>Moderator flags <code>SURVIVED</code>. No call to <code>stake()</code>, <code>contributeBonus()</code>, or <code>pokeRiskWindow()</code> occurred during the active-risk interval.</p>
</li>
<li><p>Anyone calls <code>sweepUnclaimedBonus()</code> — bonus drains to <code>recoveryAddress</code>, <code>totalBonus</code> zeroed.</p>
</li>
<li><p><code>claimsStarted</code> remains false — moderator re-flags to good-faith <code>CORRUPTED</code>.</p>
</li>
<li><p><code>bountyEntitlement = snapshotTotalStaked + 0</code>. Attacker permanently loses the bonus portion. ​</p>
</li>
</ol>
<h2>Risk</h2>
<p>​ <strong>Impact: High</strong> — The protocol permanently pays the wrong recipient. The bonus (up to 100% of <code>totalBonus</code>) is irreversibly sent to <code>recoveryAddress</code> instead of the named whitehat attacker. The loss is bounded only by the bonus pool size and is independent of the attacker's exploit performance. ​ <strong>Likelihood: Medium</strong> — Requires a rare but valid registry path where the pool does not observe the active-risk window before <code>CORRUPTED</code>, combined with a moderator flag correction. The sweep is permissionless and trivially automatable by a MEV bot. ​</p>
<h2>Impact</h2>
<p>​ The whitehat attacker permanently loses their bonus entitlement. In the PoC, the attacker receives 150 tokens instead of the expected 200 — a 25% loss. The swept bonus sits irreversibly at <code>recoveryAddress</code>. No re-flag, no subsequent call, and no admin action can restore it into the attacker's <code>bountyEntitlement</code>. ​</p>
<h2>Proof of Concept</h2>
<p>​ Drop into <code>test/unit/</code> and run: <code>forge test --match-test testSweepBetweenFlagsReducesAttackerBounty -vvvv</code> ​ The PoC uses <code>MockAttackRegistry</code> (same test double used by all 256 official tests) to stage the <code>CORRUPTED</code> state. In production this is reached via <code>approveAttack()</code> → <code>markCorrupted()</code> with no pool interaction in between. ​</p>
<pre><code class="language-solidity">// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
​
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
​
contract PoC_SweepDrainsBounty is BaseConfidencePoolTest {
function testSweepBetweenFlagsReducesAttackerBounty() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 50 * ONE);
​
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0);
​
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
​
pool.sweepUnclaimedBonus();
assertEq(pool.totalBonus(), 0);
assertFalse(pool.claimsStarted());
assertEq(token.balanceOf(recovery), 50 * ONE);
​
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 150 * ONE); // should be 200
​
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 150 * ONE);
assertEq(token.balanceOf(recovery), 50 * ONE);
}
}
</code></pre>
<p>​</p>
<h2>Tools Used</h2>
<p>Manual review, Foundry</p>
<h2>Recommended Mitigation</h2>
<p>​ Gate <code>sweepUnclaimedBonus</code> on <code>claimsStarted</code>: ​</p>
<pre><code class="language-diff">function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED &amp;&amp; outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
+    if (!claimsStarted) revert OutcomeNotEligibleForSweep();
</code></pre>
<p>Alternatively, freeze <code>totalBonus</code> at the first <code>flagOutcome</code> and never re-snapshot it on re-flag, decoupling the sweep's accounting from the re-flag's snapshot.</p>
]]></content:encoded></item><item><title><![CDATA[BlueMove Was Not an Overflow Bug — How a Cross-Version Reserve Desync Drained 714,000 SUI]]></title><description><![CDATA[On July 11, 2026, at 22:13 UTC, an attacker began draining liquidity pools on BlueMove DEX, a Sui-based AMM. Within 23 minutes, approximately 714,000 SUI — roughly $528,000 at the time — had crossed a]]></description><link>https://mehvetero.com/bluemove-was-not-an-overflow-bug-how-a-cross-version-reserve-desync-drained-714000-sui</link><guid isPermaLink="true">https://mehvetero.com/bluemove-was-not-an-overflow-bug-how-a-cross-version-reserve-desync-drained-714000-sui</guid><category><![CDATA[Sui]]></category><category><![CDATA[defi]]></category><category><![CDATA[Security]]></category><category><![CDATA[Blockchain]]></category><category><![CDATA[Smart Contracts]]></category><dc:creator><![CDATA[mehvetero]]></dc:creator><pubDate>Tue, 11 Aug 2026 19:01:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1786474741308/5bde936f-6574-427d-b8ea-312d91bdae06.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>On July 11, 2026, at 22:13 UTC, an attacker began draining liquidity pools on BlueMove DEX, a Sui-based AMM. Within 23 minutes, approximately 714,000 SUI — roughly $528,000 at the time — had crossed a Wormhole bridge as USDC. The contracts were immutable. The pools were empty. There was no way to patch or freeze.</p>
<p>BlueMove called it "a long-standing arithmetic overflow bug in the legacy AMM contract." Tyler Simpson of Quantum Void Labs called it a backdoor — a function shipped in a May 31 upgrade that set the stage for the drain.</p>
<p>Both accounts identify real pieces of the story. Neither captures the mechanism that made the drain possible.</p>
<p>I decompiled every module of both package versions, tested Move's integer behavior directly, and traced the attack transactions command by command. To the best of my knowledge, this is the first public analysis that identifies the actual exploit path: a <strong>reserve desynchronization</strong> between two callable versions of the same package. Both versions write <code>reserve_x</code> after every operation, but they write <em>different values</em> — V1 writes <code>pool.token_x.value()</code>, V-latest writes <code>escrow.token_x.value()</code>. When a V1 swap runs on a pool whose main liquidity lives in the escrow, <code>reserve_x</code> drops to the small <code>pool.token_x</code> balance, while the escrow retains the large one. The next V-latest <code>mint</code> divides by this deflated <code>reserve_x</code>, inflating LP tokens by the ratio between the two balances.</p>
<hr />
<h2>Why the overflow claim does not hold</h2>
<p>Move's integer arithmetic does not wrap on overflow. Every overflow — addition, multiplication, or out-of-range downcast — aborts the transaction at runtime. This is not a compiler flag; it is how the Move VM executes arithmetic.</p>
<p>I tested this directly with Sui CLI v1.74.1:</p>
<pre><code class="language-move">#[test]
#[expected_failure]
fun test_u128_mul_overflow() {
    let a: u128 = 340282366920938463463374607431768211455; // u128::MAX
    let _result = a * 2; // aborts — does not wrap
}

#[test]
#[expected_failure]
fun test_u128_add_overflow() {
    let a: u128 = 340282366920938463463374607431768211455;
    let _result = a + 1; // aborts
}

#[test]
#[expected_failure]
fun test_u64_downcast() {
    let big: u128 = 18446744073709551616; // u64::MAX + 1
    let _small = (big as u64); // aborts — does not truncate
}
</code></pre>
<p>All three pass with <code>expected_failure</code>. Each operation aborts at runtime. An arithmetic overflow in Move is a denial-of-service, never a fund extraction vector.</p>
<hr />
<h2>The decompiled code</h2>
<p>BlueMove never published source code. I decompiled all modules using <a href="https://github.com/verichains/revela">Revela</a> (v1.0.0) for bytecode v6, and <code>sui move disassemble</code> for bytecode v7 (unsupported by Revela). Critical paths were cross-verified between the two tools.</p>
<h3>Three packages</h3>
<p>On Sui, upgrading a package does not replace the original. Both versions remain callable on the same shared objects. BlueMove's Pool objects are shared, so any version can read and mutate them.</p>
<table>
<thead>
<tr>
<th>Label</th>
<th>Package ID</th>
<th>Bytecode</th>
</tr>
</thead>
<tbody><tr>
<td><strong>V1</strong></td>
<td><code>0xb24b6789...145454d9</code></td>
<td>v6 — Original (2023). Decompiled with Revela.</td>
</tr>
<tr>
<td><strong>V-mid</strong></td>
<td><code>0x08cd3348...3244498f</code></td>
<td>v6 — Intermediate. Introduced <code>EscownCoins</code> (the typo is in the deployed bytecode).</td>
</tr>
<tr>
<td><strong>V-latest</strong></td>
<td><code>0x35f3190a...0f656d7</code></td>
<td>v7 — May 31 upgrade. <code>EscrowCoinsV2</code>, <code>add_liquidity_returns</code>. <strong>This is the package the attacker called.</strong></td>
</tr>
</tbody></table>
<h3>Two token stores, one <code>reserve_x</code> field</h3>
<p>V1 stores tokens in <code>pool.token_x</code> / <code>pool.token_y</code>. V-latest stores tokens in <code>EscrowCoinsV2</code>, a dynamic object field on the Pool. Both versions share the Pool struct, including a single <code>reserve_x: u64</code> field. Both versions overwrite <code>reserve_x</code> after every operation — but with values from different balances.</p>
<hr />
<h2>The reserve write: code proof</h2>
<p>This is the linchpin. Each version's <code>swap</code> function ends with <code>update(x, y, pool)</code>, which sets <code>pool.reserve_x = x</code>. The question is: what is <code>x</code>?</p>
<h3>V1 swap → <code>reserve_x = pool.token_x.value()</code></h3>
<p>From V1's decompiled code (Revela, <code>swap.move</code>):</p>
<pre><code class="language-move">// V1 swap (lines 96, 122):
let (v2, v3) = token_balances&lt;T0, T1&gt;(arg2);
//  v2 = balance::value(&amp;pool.token_x)    ← actual pool balance
//  v3 = balance::value(&amp;pool.token_y)
...
update&lt;T0, T1&gt;(v2, v3, arg2);
//  pool.reserve_x = v2 = pool.token_x.value()

// V1 update (lines 80–83):
fun update&lt;T0, T1&gt;(arg0: u64, arg1: u64, arg2: &amp;mut Pool&lt;T0, T1&gt;) {
    arg2.reserve_x = arg0;
    arg2.reserve_y = arg1;
}
</code></pre>
<p>This invariant holds immediately after every successful V1 swap because <code>update()</code> is always called with the current pool balances.</p>
<p>After every V1 swap: <strong><code>reserve_x == pool.token_x.value()</code></strong>. Always.</p>
<h3>V-latest swap → <code>reserve_x = escrow.token_x.value()</code></h3>
<p>From V-latest's disassembly (escrow-exists branch, <code>sui move disassemble</code>):</p>
<pre><code>// V-latest swap, escrow branch (lines 105–112, 269–272):
L105: CopyLoc[38](loc32: &amp;mut EscrowCoinsV2)
L106: ImmBorrowFieldGeneric[11](EscrowCoinsV2.token_x)
L107: Call balance::value → loc18          ← escrow balance
L108: MoveLoc[38]
L109: ImmBorrowFieldGeneric[12](EscrowCoinsV2.token_y)
L110: Call balance::value → loc24          ← escrow balance
...
L269: MoveLoc[24](loc18)                   ← escrow.token_x.value()
L270: MoveLoc[30](loc24)                   ← escrow.token_y.value()
L271: MoveLoc[2](Pool)
L272: Call update                          ← reserve_x = escrow balance
</code></pre>
<p>After every V-latest swap (escrow branch): <strong><code>reserve_x == escrow.token_x.value()</code></strong>.</p>
<p>This invariant holds only for the escrow branch. Therefore, whichever version executes last determines the meaning of <code>reserve_x</code>.</p>
<h3>The consequence</h3>
<p><code>reserve_x</code> is a single field written by two functions that compute it from different balances. Whichever version runs last determines the value. If V-latest ran last, <code>reserve_x</code> tracks the escrow. If V1 ran last, <code>reserve_x</code> tracks <code>pool.token_x</code>. The two balances are independent — one can be orders of magnitude larger than the other.</p>
<hr />
<h2>The attack</h2>
<h3>Pool lifecycle</h3>
<p>MovePump launchpad pools follow this lifecycle:</p>
<ol>
<li><p><strong>Created through V-latest</strong> (<code>create_and_freeze_pool</code> → <code>unfreeze_and_add_liquidity</code> → <code>add_liquidity_direct</code>). Initial liquidity goes into <code>EscrowCoinsV2</code>. <code>pool.token_x</code> stays at zero. <code>reserve_x</code> = escrow balance. LP tokens are minted to the pool creator.</p>
</li>
<li><p><strong>Traded through V-latest.</strong> Users swap via V-latest router. Tokens move in and out of the escrow. <code>reserve_x</code> tracks escrow balance. <code>pool.token_x</code> remains empty. Over weeks, the escrow accumulates a large SUI balance from trading.</p>
</li>
<li><p><strong>Traded through V1.</strong> External aggregators (e.g., OKX DEX Router, whose <code>Move.toml</code> references V1 package <code>0xb24b67...</code>) route swaps through V1. V1 deposits input tokens into <code>pool.token_x</code> and sets <code>reserve_x = pool.token_x.value()</code> — a much smaller number than the escrow balance. <strong>This is the desync.</strong></p>
</li>
</ol>
<h3>Step by step</h3>
<p><strong>Before attack:</strong></p>
<pre><code>escrow.token_x  = 350,000 SUI     (accumulated from V-latest trading)
pool.token_x    = 7,022 SUI       (accumulated from V1 aggregator swaps)
reserve_x       = 7,022            (set by last V1 swap = pool.token_x)
lp_supply       = 1,000            (from initial mint)
</code></pre>
<p><code>reserve_x</code> and <code>pool.token_x</code> are equal (V1's update synchronizes them). But <code>escrow.token_x</code> is 50× larger — V1 doesn't see it, and the last operation was a V1 swap.</p>
<p>The numerical values below are representative rather than extracted from a single pool. They are chosen to satisfy the observed invariants (<code>reserve_x == pool.token_x</code> after a V1 swap) and to illustrate the ratio between the escrow balance and the pool balance that drives the LP inflation.</p>
<p><strong>Step 1 — V-latest <code>add_liquidity_returns</code>(0.057 SUI, meme tokens):</strong></p>
<p>The B20 branch runs (escrow exists). The deposit joins the escrow:</p>
<pre><code>escrow.token_x += 0.057 → escrow_total = 350,000.057
</code></pre>
<p>V-latest's <code>mint</code> runs with <code>lp_supply &gt; 0</code> (the standard proportional branch — not the first-deposit branch):</p>
<pre><code>LP_minted = (escrow_total − reserve_x) × old_supply ÷ reserve_x
          = (350,000.057 − 7,022) × 1,000 ÷ 7,022
          = 342,978.057 × 1,000 ÷ 7,022
          = 48,843
</code></pre>
<p>The attacker mints 48,843 LP — <strong>48× the existing supply</strong> — because <code>mint</code> divides by <code>reserve_x</code> (7,022), not by the escrow balance (350,000) that the tokens actually sit in. No overflow. No underflow. The standard formula, fed a stale denominator.</p>
<p>The minted LP tokens are <strong>returned to the caller</strong> (this is what <code>add_liquidity_returns</code> does differently from <code>add_liquidity</code>), so they flow directly to the next command.</p>
<p><strong>Step 2 — V1 <code>remove_liquidity</code>(48,843 LP):</strong></p>
<p>V1's <code>burn</code> reads <code>balance::value(&amp;pool.token_x)</code> — the actual SUI in the pool's direct balance, not the escrow:</p>
<pre><code>total_supply  = 1,000 + 48,843 = 49,843
attacker_share = 48,843 ÷ 49,843 = 97.99%

withdrawal = pool.token_x.value() × attacker_lp ÷ total_supply
           = 7,022 × 48,843 ÷ 49,843
           = 6,881 SUI
</code></pre>
<p>V1 splits 6,881 SUI from <code>pool.token_x</code> and transfers it to the attacker.</p>
<p><strong>Result:</strong> The attacker deposited 0.057 SUI (into the escrow) and withdrew 6,881 SUI (from <code>pool.token_x</code>). The stolen funds are the SUI that accumulated in <code>pool.token_x</code> from V1 aggregator swaps — real user trades routed through the old package.</p>
<p>The escrow's 350,000 SUI is not directly stolen in this step. It serves as the inflated numerator in the <code>mint</code> formula, making the attacker's LP share large enough to claim nearly all of <code>pool.token_x</code>.</p>
<p>The calculation above is illustrative. The attack transaction follows the same sequence of calls, while the exact balances differ from pool to pool.</p>
<p>This pattern repeated per pool. At ~1,966 SUI average across 363 pools, the total reaches approximately 714,000 SUI.</p>
<hr />
<h2>The attack transaction</h2>
<p>Transaction <a href="https://suiscan.xyz/mainnet/tx/8pMKBovvrHzyRiaGjUPPFuFX6a92KkqSRitZ36zbD1qz"><code>8pMKBovvrHzyRiaGjUPPFuFX6a92KkqSRitZ36zbD1qz</code></a> — 49 programmable commands, 64 inputs. From the raw JSON, the command sequence repeats per pool:</p>
<pre><code>SplitCoins  (prepare 0.057 SUI)
SplitCoins  (prepare meme token amount)
MoveCall    0x35f3190a... / router / add_liquidity_returns     ← V-latest
MoveCall    0xb24b6789... / router / remove_liquidity          ← V1
</code></pre>
<p>V-latest for deposit. V1 for withdrawal. Same Pool. Same transaction.</p>
<p>Pools drained in this single transaction include aaaMEME, SPANK, BSNAKE, JELLY, DGS, PCON, RUSHI, and others. On-chain balance changes for the aaaMEME pool: Add Liquidity +0.057 SUI, Remove Liquidity −7,022.21 SUI.</p>
<p><strong>Attacker:</strong> <a href="https://suiscan.xyz/mainnet/account/0xb29e79198742e84ddf6a5a952238990f7d80565826ff3d97dc92d7056b097335"><code>0xb29e7919...097335</code></a> (Blockaid: MALICIOUS)</p>
<p><strong>Bridge wallet:</strong> <a href="https://suiscan.xyz/mainnet/account/0xa74ee820821d52994785eb9160b3a114a6338de1b3d9e188c00f09ba4f75065e"><code>0xa74ee820...75065e</code></a></p>
<p><strong>Exit:</strong> SUI → USDC across 11 DEXes → Wormhole <code>deposit_for_burn</code> → ~$528,000 USDC off Sui in 23 minutes.</p>
<h3>Timeline</h3>
<table>
<thead>
<tr>
<th>Date</th>
<th>Event</th>
<th>Transaction</th>
</tr>
</thead>
<tbody><tr>
<td>May 31, 2026</td>
<td>V-latest upgrade</td>
<td><a href="https://suivision.xyz/txblock/9wTz16dgUXy4pce4TZor1ouTBUjc7f3TBxbVagTqQwHX"><code>9wTz16...</code></a></td>
</tr>
<tr>
<td>June 3, 2026</td>
<td>UpgradeCap burned</td>
<td><a href="https://suivision.xyz/txblock/EugVQBpX8jp6GFkXSRfhe5cfCeXhwiipKndGpeNAkZRb"><code>EugVQB...</code></a></td>
</tr>
<tr>
<td>July 11, 2026</td>
<td>Pools drained</td>
<td><a href="https://suiscan.xyz/mainnet/tx/8pMKBovvrHzyRiaGjUPPFuFX6a92KkqSRitZ36zbD1qz"><code>8pMKBo...</code></a> and others</td>
</tr>
</tbody></table>
<hr />
<h2>Verdict</h2>
<table>
<thead>
<tr>
<th>Claim</th>
<th>Assessment</th>
<th>Evidence</th>
</tr>
</thead>
<tbody><tr>
<td>BlueMove: "arithmetic overflow"</td>
<td><strong>Does not hold.</strong> Move aborts on overflow.</td>
<td><code>sui move test</code> — three cases, all abort</td>
</tr>
<tr>
<td>BlueMove: "bug since 2023"</td>
<td><strong>Misleading.</strong> V1 alone is internally consistent. The vulnerability requires V-latest's escrow to create the reserve desync.</td>
<td>V1 decompiled — no escrow, no split state</td>
</tr>
<tr>
<td>BlueMove: "UpgradeCap burned"</td>
<td><strong>Correct.</strong></td>
<td>Suiscan: Immutable</td>
</tr>
<tr>
<td>Simpson: "<code>add_liquidity_returns</code> is key"</td>
<td><strong>Correct.</strong> Returns LP to caller for same-TX burn.</td>
<td>Attack TX command list</td>
</tr>
<tr>
<td>Simpson: "double-mint LP inflation"</td>
<td><strong>Imprecise.</strong> The LP formula is standard. The inflation comes from a desynced <code>reserve_x</code> denominator, not from minting twice.</td>
<td>V1 vs V-latest reserve update paths</td>
</tr>
<tr>
<td>Simpson: "backdoor"</td>
<td><strong>Cannot be resolved from code.</strong></td>
<td>—</td>
</tr>
</tbody></table>
<hr />
<h2>The vulnerability class</h2>
<p>The root cause is a <strong>shared mutable field written by incompatible update functions</strong>. Both V1 and V-latest write <code>pool.reserve_x</code> after every swap, but from different sources:</p>
<ul>
<li>V1: <code>reserve_x ← pool.token_x.value()</code></li>
<li>V-latest: <code>reserve_x ← escrow.token_x.value()</code></li>
</ul>
<p>When the two balances diverge (because tokens accumulate in different stores), whichever version wrote <code>reserve_x</code> last determines the denominator for LP minting. If the small balance wrote last, LP is inflated. If the large balance wrote last, LP is correctly priced.</p>
<p>This generalizes to any package upgrade that satisfies three conditions:</p>
<ol>
<li><strong>Storage relocation</strong>: Version B stores assets in a new location (dynamic object field, separate struct, etc.)</li>
<li><strong>Shared accounting field</strong>: Both versions overwrite the same field (<code>reserve_x</code>) with values from their respective stores</li>
<li><strong>Old version remains callable</strong>: Version A's functions are not disabled after Version B deploys</li>
</ol>
<p>The pattern is analogous to a <strong>schema migration without backward compatibility</strong> — except the old schema is not just readable but writable, and both schemas race on the same live accounting field.</p>
<hr />
<h2>What this analysis did not cover</h2>
<ul>
<li><strong>Intent.</strong> The 40-day gap between the upgrade and the exploit is consistent with both a planted vulnerability and independent discovery. The code shows the mechanism; it does not show motive.</li>
<li><strong>Complete transaction accounting.</strong> The four "pure sweep" transfers documented by Simpson total ~256,000 SUI; additional drain transactions account for the remaining ~458,000 SUI. Simpson counted 363 affected pools; BlueMove's statement said 389.</li>
<li><strong>Whether V1 swaps preceding the attack were organic or attacker-triggered.</strong> The desync requires at least one V1 swap to set <code>reserve_x = pool.token_x.value()</code>. External aggregators referencing V1 would produce this naturally, but the attacker could also have triggered V1 swaps as setup within the same programmable transaction.</li>
<li><strong>Pre-attack pool state.</strong> The numerical example uses representative values (escrow = 350,000, pool.token_x = 7,022, supply = 1,000) that are consistent with the on-chain balance changes (+0.057 / −7,022) and with the V1 update rule (reserve_x == pool.token_x). Exact pre-attack values for individual pools were not extracted.</li>
<li><strong>The <code>migrate_escrow_coins</code> function</strong> in V-latest, which moves balances between the V-mid and V-latest escrow structs.</li>
</ul>
<hr />
<h2>Methodology</h2>
<ul>
<li><strong>Decompilation</strong>: Revela v1.0.0 on V1 and V-mid (bytecode v6). <code>sui move disassemble</code> on V-latest (bytecode v7).</li>
<li><strong>Cross-verification</strong>: mint, burn, swap, add_liquidity_direct verified between decompiler output and raw disassembly. Register assignments for reserve_x writes traced instruction-by-instruction: V1 swap L96/L122, V-latest swap L105–112/L269–272, V-latest swap else-branch L319–326/L483–486.</li>
<li><strong>Overflow testing</strong>: <code>sui move test</code>, Sui CLI v1.74.1. Three cases, all abort.</li>
<li><strong>On-chain tracing</strong>: Suiscan Raw JSON for attack transaction. Package addresses extracted from MoveCall commands. Two distinct packages confirmed: <code>0x35f319...</code> (add_liquidity_returns) and <code>0xb24b67...</code> (remove_liquidity).</li>
<li><strong>Mathematical verification</strong>: LP minting and burn withdrawal computed with representative values satisfying the V1 update invariant (reserve_x == pool.token_x). Result (attacker share 97.99%, withdrawal 6,881 SUI) is consistent with the on-chain balance changes for the aaaMEME pool.</li>
</ul>
<p>The <code>EscownCoins</code> spelling (V-mid) and <code>EscrowCoinsV2</code> (V-latest) are exactly as they appear in the deployed bytecode.</p>
<hr />
<p><em>I build <a href="https://github.com/mehvetero/move-test-gen">move-test-gen</a>, an open-source coverage checker and security linter for Sui Move. This analysis started with the linter flagging BlueMove's <code>overflow_add</code> function — which turned out to be irrelevant. The finding came from decompiling the bytecode and tracing the reserve update paths across package versions.</em></p>
<p><em>Upgrading a storage layout without retiring the previous entry points creates two valid writers for a single accounting field. Once those writers disagree about what the field represents, every formula downstream — LP pricing, withdrawal calculation, K-invariant verification — computes a correct answer to the wrong question.</em></p>
]]></content:encoded></item><item><title><![CDATA[How a Missing `assert!` Drained $3.44M From Typus Finance — and Why the Code Looked Correct]]></title><description><![CDATA[On October 15, 2025, at 13:05 UTC, an attacker began draining Typus Finance's TLP liquidity pool on the Sui network. Thirty-four minutes later, the contracts were paused — but the pool was already emp]]></description><link>https://mehvetero.com/how-a-missing-assert-drained-3-44m-from-typus-finance-and-why-the-code-looked-correct</link><guid isPermaLink="true">https://mehvetero.com/how-a-missing-assert-drained-3-44m-from-typus-finance-and-why-the-code-looked-correct</guid><category><![CDATA[Sui]]></category><category><![CDATA[hacking]]></category><category><![CDATA[Security]]></category><category><![CDATA[Smart Contracts]]></category><category><![CDATA[audit]]></category><category><![CDATA[Today ]]></category><dc:creator><![CDATA[mehvetero]]></dc:creator><pubDate>Sat, 01 Aug 2026 13:41:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a6316b60ed37bae98b22704/613c9606-7df6-48be-8962-66fe265f779b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>On October 15, 2025, at 13:05 UTC, an attacker began draining Typus Finance's TLP liquidity pool on the Sui network. Thirty-four minutes later, the contracts were paused — but the pool was already empty. Over the hours that followed, the stolen SUI, xBTC, and suiETH were swapped to USDC on-chain, then bridged to Ethereum via Circle's CCTP in 14 transactions. Total loss: $3.44 million.</p>
<p>The root cause was a single line of Move code that looked like an authorization check — called the right function, passed the right arguments, sat in the right place — but silently discarded its result. The oracle module had been live for eleven months.</p>
<p>I pulled the vulnerable source from GitHub, compared it against the post-incident rewrite, and traced the attack on-chain. This is what I found.</p>
<hr />
<h2>The vulnerable function</h2>
<p>The oracle module (<code>typus_oracle::oracle</code>) managed price feeds for Typus Finance's TLP pool. Prices were updated through a function called <code>update_v2</code>:</p>
<pre><code class="language-move">public fun update_v2(
    oracle: &amp;mut Oracle,
    update_authority: &amp;UpdateAuthority,
    price: u64,
    twap_price: u64,
    clock: &amp;Clock,
    ctx: &amp;mut TxContext
) {
    // check authority
    vector::contains(&amp;update_authority.authority, &amp;tx_context::sender(ctx));
    version_check(oracle);

    update_(oracle, price, twap_price, clock, ctx);
}
</code></pre>
<p>Source: <a href="https://github.com/Typus-Lab/typus/blob/a918e98c4f7d3a28d0d809d3263d8c21e90d3c01/typus_oracle/sources/oracle.move"><code>typus_oracle/sources/oracle.move</code></a>, commit <code>a918e98</code> (September 24, 2025 — three weeks before the exploit).</p>
<p>Read that function slowly. <code>vector::contains()</code> returns a <code>bool</code>. That <code>bool</code> is computed, evaluated — and then thrown away. The return value is never bound to a variable, never passed to <code>assert!</code>, never checked. Move does not warn on an unused pure-function return value. The compiler is happy. The tests pass. The authorization is theater.</p>
<p>What the code should have said:</p>
<pre><code class="language-move">assert!(
    vector::contains(&amp;update_authority.authority, &amp;tx_context::sender(ctx)),
    E_UNAUTHORIZED
);
</code></pre>
<p>One line. The entire $3.44M exploit hinged on the difference between calling a function and acting on its result.</p>
<h2>Why this survived eleven months</h2>
<p>The oracle module was deployed on November 13, 2024. MoveBit audited Typus Finance's contracts in May 2025 — but the oracle module was <strong>excluded from the audit scope</strong>. The module that controls every price in the system, that determines what every swap is worth, was the one module nobody formally reviewed.</p>
<p>The <code>UpdateAuthority</code> struct was a <strong>shared object</strong> on Sui — meaning any transaction could reference it. The authorization check was supposed to ensure only whitelisted addresses could call <code>update_v2</code>. Without the <code>assert!</code>, the whitelist existed but was never enforced. Every address on Sui had oracle-write access for eleven months.</p>
<p>The reason nobody noticed: every legitimate oracle update came from a whitelisted address. The check "worked" in the sense that whitelisted addresses passed it. It also "worked" for every other address on the network. The two populations — authorized callers and everyone else — were indistinguishable to the code.</p>
<h2>The attack</h2>
<p>At 13:05 UTC on October 15, the attacker submitted the first malicious transaction (<code>6KJvWtmrZDi5MxUPkJfDNZTLf2DFGKhQA2WuVAdSRUgH</code>). The attack followed a clean three-phase sequence:</p>
<p><strong>Phase 1 — Price manipulation.</strong> The attacker called <code>update_v2</code> directly — no exploit contract, no flash loan, no reentrancy. Just a direct function call with fabricated prices. In one instance, a token's oracle price was set to 651,548,270 while another was set to 1. The <code>UpdateAuthority</code> shared object (accessible to anyone) was passed as an argument, the <code>vector::contains</code> check returned <code>false</code>, nobody read the <code>false</code>, and <code>update_()</code> executed with the attacker's prices.</p>
<p><strong>Phase 2 — Arbitrage extraction.</strong> The TLP pool relied entirely on oracle prices for swap calculations. With a 651-million-to-1 price ratio, the attacker could swap tiny amounts of one token for enormous amounts of another. In one swap, 1 SUI was exchanged for approximately 60,000,000 xBTC base units (0.6 xBTC at 8 decimals — roughly 0.6 BTC in value). Ten separate manipulation-then-swap sequences drained the pool systematically.</p>
<p><strong>Phase 3 — Exit.</strong> The stolen suiETH, xBTC, and SUI were converted to USDC through Cetus, Haedal Protocol, and Turbos Finance. Approximately 3,430,717 USDC was bridged to Ethereum via Circle's CCTP in 14 separate transactions, then swapped to 3,430,241.91 DAI on Curve. The attacker's initial funding — 0.041 ETH — originated from Tornado Cash on BSC.</p>
<p>Total loss: 588,357.9 SUI + 1,604,034.7 USDC + 0.6 xBTC + 32.227 suiETH. Approximately $3.44 million.</p>
<p>The team detected the attack at 13:24 (19 minutes in), paused all contracts at 13:39, and identified the root cause by 13:42 — three minutes after pausing. The fix was obvious once you saw the missing <code>assert!</code>. Finding it was the hard part.</p>
<h2>The fix — and what it reveals about the original design</h2>
<p>Five days after the exploit, Typus pushed commit <code>4e118f0</code> — a major refactor of the oracle module. The change wasn't just adding an <code>assert!</code>. They redesigned the authorization model entirely:</p>
<table>
<thead>
<tr>
<th>Before (vulnerable)</th>
<th>After (fixed)</th>
</tr>
</thead>
<tbody><tr>
<td><code>UpdateAuthority</code> — shared object, whitelist of addresses</td>
<td><code>UpdateCap</code> / <code>UpdateCaps</code> — shared object, <code>for: address</code> binding</td>
</tr>
<tr>
<td><code>vector::contains()</code> result discarded</td>
<td><code>assert!(update_cap.for == ctx.sender(), EInvalidUpdateCap)</code></td>
</tr>
<tr>
<td><code>update_v2</code> public function</td>
<td><code>update_with_update_cap</code> public function</td>
</tr>
<tr>
<td>Authorization computed but never enforced</td>
<td>Authorization computed and asserted</td>
</tr>
</tbody></table>
<p>A note on what the fix is <em>not</em>: <code>UpdateCap</code> is still a <strong>shared object</strong> (<code>transfer::share_object()</code> in <code>create_update_cap</code>). Anyone can reference it in a transaction. The access control is still a runtime check — the <code>for: address</code> field binds the cap to a specific sender, and <code>assert!</code> enforces it. This is <em>not</em> an owned-capability pattern (the codebase already has one — <code>ManagerCap</code>, which uses <code>transfer::transfer</code> and can only be presented by its owner). Typus likely chose the shared pattern because multiple oracle updater addresses (automated crankers) need to reference the cap concurrently, which an owned object would block.</p>
<p>The post-fix <code>update_with_update_cap</code>:</p>
<pre><code class="language-move">public fun update_with_update_cap(
    oracle: &amp;mut Oracle,
    update_cap: &amp;UpdateCap,
    price: u64,
    twap_price: u64,
    clock: &amp;Clock,
    ctx: &amp;TxContext,
) {
    version_check(oracle);
    assert!(update_cap.`for` == ctx.sender(), EInvalidUpdateCap);
    update_(oracle, price, twap_price, clock, ctx);
}
</code></pre>
<p>The entire defense is one <code>assert!</code>. Remove it and the cap's <code>for</code> field is decoration — exactly the same failure mode as the original <code>vector::contains()</code> call. The fix works, but it is procedural (a runtime check the next developer must remember to keep), not structural (an ownership constraint the type system enforces). That is worth stating plainly: the system that lost $3.44M to a forgotten check was fixed by adding a check and not forgetting it this time.</p>
<h2>What a linter sees</h2>
<p>I ran the vulnerable <code>oracle.move</code> through a static analysis pass looking for a specific pattern: a public function that takes a mutable reference (<code>&amp;mut</code>) to a shared object but does not require a capability parameter. This is the class of defect that <code>MOV-001</code> in <a href="https://github.com/mehvetero/move-test-gen">move-test-gen</a> targets — public state-mutating functions without a capability gate.</p>
<p>The result is instructive but honest: <code>update_v2</code> takes <code>&amp;UpdateAuthority</code> (an access-control struct), which a name-only heuristic would read as "has authorization." A regex-based linter sees a struct with "Authority" in the name and assumes it is a gate. The actual defect — that the gate's result is discarded — lives one abstraction layer deeper: you need to trace the return value of <code>vector::contains()</code> and confirm it reaches an <code>assert!</code> or an <code>if</code> branch. That is control-flow analysis, not pattern matching.</p>
<p>This is a known ceiling for line-based static analysis. The linter catches the structural shape (public function + mutable shared object + no capability), but the specific failure mode (unused return value of an authorization check) requires either a compiler warning for unused pure-function results or a data-flow analyzer that tracks boolean returns to assertion sites.</p>
<p>Move does not currently emit an "unused return value" warning for pure functions. A Rust-equivalent <code>#[must_use]</code> annotation on <code>vector::contains</code> would have caught this at compile time. That annotation does not exist in Move today.</p>
<h2>The uncomfortable pattern</h2>
<p>This is the third Sui DeFi exploit I have analyzed in detail — after <a href="https://mehvetero.com/three-sui-exploits-one-disease-why-the-math-looked-fine-keeps-costing-hundreds-of-millions">Cetus ($223M)</a> and <a href="https://mehvetero.com/how-one-missing-assert-drained-114m-from-aftermath-finance">Aftermath ($1.14M)</a>. The surface details differ — arithmetic overflow, signed-value boundary, authorization bypass — but the structural shape is the same:</p>
<ol>
<li><p><strong>The code looked correct to every reader.</strong> The function name said "check authority." The comment said "check authority." The called function was the right one. The arguments were correct. The only thing missing was one keyword.</p>
</li>
<li><p><strong>The module that mattered most was the one that wasn't audited.</strong> Aftermath's perpetuals module was a late addition. Typus's oracle module was excluded from scope. The highest-value target is systematically the one with the least review.</p>
</li>
<li><p><strong>The fix is trivial in hindsight.</strong> <code>assert!(...)</code> instead of a bare call. <code>(x as u128)</code> instead of <code>x * y</code>. These are one-line fixes that would have prevented nine-figure losses. The difficulty was never in writing the fix — it was in knowing the fix was needed.</p>
</li>
</ol>
<p>This is not a tooling problem or an audit problem. It is a coverage problem. The code that controls the most value gets the least scrutiny, because it ships last, ships fastest, or ships outside the scope of an engagement that was scoped months earlier.</p>
<hr />
<p><strong>Addresses referenced in this analysis:</strong></p>
<ul>
<li><p>Vulnerable package: <code>0xaf44818c67a878b9eba0c63186b00e80d9fc3d1e2ae02f00fa3993b0e683bff3</code></p>
</li>
<li><p>First attack transaction: <code>6KJvWtmrZDi5MxUPkJfDNZTLf2DFGKhQA2WuVAdSRUgH</code></p>
</li>
<li><p>Attacker (Ethereum): <code>0xeb8a15d28dd54231e7e950f5720bc3d7af77b443</code></p>
</li>
<li><p>Funds destination: <code>0x4502cf2bc9c8743e63cbb9bbde0011989eed03c1</code></p>
</li>
<li><p>Vulnerable source: <a href="https://github.com/Typus-Lab/typus/blob/a918e98c4f7d3a28d0d809d3263d8c21e90d3c01/typus_oracle/sources/oracle.move">commit <code>a918e98</code></a> (September 24, 2025)</p>
</li>
<li><p>Post-fix source: <a href="https://github.com/Typus-Lab/typus/blob/4e118f0f4c5d8b09837b3d0dcfb6501dc79f687f/typus_oracle/sources/oracle.move">commit <code>4e118f0</code></a> (October 20, 2025)</p>
</li>
</ul>
<p><strong>Disclosure:</strong> I build <a href="https://github.com/mehvetero/move-test-gen">move-test-gen</a>, an open-source coverage checker and security linter for Sui Move. The linter's <code>MOV-001</code> rule targets missing access control in public functions — the structural class this exploit belongs to. The specific failure mode (unused authorization return value) is beyond the current linter's reach, as noted above. Credit to <a href="https://defimon.xyz/blog/typus-finance-hack-october-2025">Defimon</a> and <a href="https://medium.com/@TypusFinance/typus-finance-tlp-oracle-exploit-post-mortem-report-response-plan-ce2d0800808b">Typus Finance's post-mortem</a> for the on-chain timeline and financial figures.</p>
]]></content:encoded></item><item><title><![CDATA[Three Sui Exploits, One Disease — Why "The Math Looked Fine" Keeps Costing Hundreds of Millions]]></title><description><![CDATA[Cetus lost $223M to a shift overflow. Aftermath lost $1.14M to a negative fee. Bucket Protocol ships a decimal scaling bug today. All three passed audits. All three have the same root cause.

I've spe]]></description><link>https://mehvetero.com/three-sui-exploits-one-disease-why-the-math-looked-fine-keeps-costing-hundreds-of-millions</link><guid isPermaLink="true">https://mehvetero.com/three-sui-exploits-one-disease-why-the-math-looked-fine-keeps-costing-hundreds-of-millions</guid><category><![CDATA[Sui]]></category><category><![CDATA[hacking]]></category><category><![CDATA[audit]]></category><category><![CDATA[research]]></category><dc:creator><![CDATA[mehvetero]]></dc:creator><pubDate>Mon, 27 Jul 2026 19:24:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a6316b60ed37bae98b22704/bb4e4a8f-3ef9-4f58-870f-cf6598adc3b4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Cetus lost $223M to a shift overflow. Aftermath lost $1.14M to a negative fee. Bucket Protocol ships a decimal scaling bug today. All three passed audits. All three have the same root cause.</em></p>
<hr />
<p>I've spent the last month reading exploit post-mortems, scanning production Sui contracts, and building lint rules to catch what I kept seeing. After the third time the same shape showed up, I stopped thinking of these as separate incidents.</p>
<p>They're the same bug wearing different clothes.</p>
<hr />
<h2>Exploit 1: Cetus — $223M (May 2025)</h2>
<p>The largest Move exploit to date. A single function in a shared math library — <code>checked_shlw</code> in <code>integer-mate</code> — was supposed to prevent a 256-bit left shift from overflowing. It didn't.</p>
<p>The function checked whether the input was small enough to shift safely:</p>
<pre><code class="language-move">// BEFORE fix
assert!(n &lt;= MAX_U256 &gt;&gt; 64, ERR_OVERFLOW);
n &lt;&lt; 64
</code></pre>
<p>For inputs near <code>2^192</code>, this check passes when it shouldn't. The shift wraps, producing a tiny number from a massive input. Cetus used this function in its liquidity delta calculation. The attacker opened a position with a crafted tick range, deposited approximately 1 token, and received a liquidity credit worth $223M. Then withdrew it.</p>
<p><strong>Attacker:</strong> <code>0xe28b50cef1d633ea43d3296a3f6b67ff0312a5f1a99f0af753c85b8b5de8ff06</code></p>
<p><strong>Fix:</strong> <code>assert!(n &lt; (1u256 &lt;&lt; 192), ERR_OVERFLOW)</code> — one comparison.</p>
<p><strong>What the auditors saw:</strong> A math library everyone imports. <code>checked_shlw</code> — the name says "checked." It has an assert. It looks complete. You'd need to sit down with a pen and work out the 256-bit boundary arithmetic to realize the comparison is wrong. Under audit time pressure, that doesn't happen.</p>
<h2>Exploit 2: Aftermath Finance — $1.14M (April 2026)</h2>
<p>Different protocol, different operation, same disease.</p>
<p>Aftermath's perpetual futures exchange lets third-party integrators set their own fee rates. The fee type is signed fixed-point. The validation:</p>
<pre><code class="language-move">assert!(integrator_taker_fee &lt;= max_taker_fee, E_INVALID);
</code></pre>
<p>Upper bound only. The attacker set <code>max_taker_fee = 0</code>, then passed a negative fee. Signed comparison: negative ≤ 0 is true. Check passes.</p>
<p>Downstream:</p>
<pre><code class="language-plaintext">collateral_delta = filled_value - (taker_fee + negative_integrator_fee)
</code></pre>
<p>Subtracting a negative number adds. Phantom USDC appears in the account. The attacker deposited 100 USDC, withdrew 100,000+. Eleven times in 36 minutes.</p>
<p><strong>Fix:</strong> <code>assert!(integrator_taker_fee &gt;= 0, E_INVALID)</code> — one assert.</p>
<p>OtterSec audited Aftermath in November 2025. The bug shipped in August 2025. It survived a full audit by a top-tier Move firm.</p>
<h2>Exploit 3: Bucket Protocol — Unpatched (found July 2026)</h2>
<p>This one hasn't blown up yet. I found it while scanning Sui CDP protocols with a lint tool I built.</p>
<p>Bucket's <code>compute_collateral_value_to_buck</code> function calls <code>mul_factor</code> — which safely promotes to u128 internally — and then immediately multiplies the result by <code>pow(10, decimal_diff)</code> as raw u64:</p>
<pre><code class="language-move">let collateral_raw_value = mul_factor(collateral_amount, price, denominator);
// mul_factor used u128 internally. This line undoes that protection:
collateral_raw_value * pow(10, constants::buck_decimal() - collateral_decimal)
</code></pre>
<p>If <code>collateral_decimal &gt; buck_decimal(9)</code> and the value is large enough, this overflows. The function is called by <code>is_in_recovery_mode</code>, <code>is_liquidatable</code>, <code>is_healthy_bottle</code>, and <code>handle_redeem</code>. An overflow here blocks liquidation, redemption, and health checks — the protocol's core safety functions stop working.</p>
<p>Current deployments use SUI (9 decimals = BUCK decimals), so <code>pow(10, 0) = 1</code> — no overflow today. But <code>create_bucket</code> lets admins add new collateral types with arbitrary decimals, and there's no guard. The code is safe by accident, not by design.</p>
<p><strong>Fix:</strong> u128 intermediate cast — <a href="https://github.com/Bucket-Protocol/v1-core/pull/12">PR submitted</a>.</p>
<p>OtterSec audited Bucket in 2023 and found a related issue (OS-BKT-ADV-00, "Improper Conversion" — critical, resolved). The fix for that issue introduced <code>compute_collateral_value_to_buck</code>. The overflow in the fix is a new bug.</p>
<p><strong>Four audit firms reviewed Bucket (OtterSec, MoveBit, Hashlock, Quantstamp). None caught this.</strong></p>
<hr />
<h2>The Pattern</h2>
<p>Three protocols. Three different operations (shift, subtraction, multiplication). Three different triggers (boundary input, negative value, decimal scaling). But strip away the surface and it's the same thing every time:</p>
<p><strong>A math function that returns the wrong answer for inputs the developer didn't consider, protected by a check that looks complete but isn't.</strong></p>
<table>
<thead>
<tr>
<th></th>
<th>Cetus</th>
<th>Aftermath</th>
<th>Bucket</th>
</tr>
</thead>
<tbody><tr>
<td><strong>The math</strong></td>
<td>shift left 64</td>
<td>subtract fee</td>
<td>multiply by 10^n</td>
</tr>
<tr>
<td><strong>The check</strong></td>
<td><code>n &lt;= MAX &gt;&gt; 64</code></td>
<td><code>fee &lt;= max</code></td>
<td><em>(none)</em></td>
</tr>
<tr>
<td><strong>What's missing</strong></td>
<td>upper-bit mask</td>
<td><code>fee &gt;= 0</code></td>
<td>u128 promotion</td>
</tr>
<tr>
<td><strong>Why it passes review</strong></td>
<td>"checked" in the name</td>
<td>assert present</td>
<td><code>mul_factor</code> already "safe"</td>
</tr>
<tr>
<td><strong>The fix</strong></td>
<td>1 comparison</td>
<td>1 assert</td>
<td>1 cast</td>
</tr>
<tr>
<td><strong>Audit coverage</strong></td>
<td>library dependency</td>
<td>in-scope, missed</td>
<td>in-scope across 4 firms</td>
</tr>
</tbody></table>
<p>The pattern isn't "developers write bad math." The math is usually correct for the inputs the developer imagined. The pattern is: <strong>the boundary between correct and catastrophic is one comparison, and it's invisible to a reviewer scanning for logic bugs.</strong></p>
<hr />
<h2>Why Audits Keep Missing This</h2>
<p>I don't think this is an auditor quality problem. I think it's a structural mismatch between how audits work and how arithmetic bugs hide.</p>
<p>An auditor reads code and asks: "Does this logic do what it claims?" For business logic — access control, state transitions, economic flows — that question works. You can read the intent and check the implementation.</p>
<p>For arithmetic boundary bugs, the question doesn't work. <code>checked_shlw</code> does exactly what its name and comment say — it checks and shifts. The bug is in the arithmetic of the check itself, and you can't see it without computing the actual boundary. An auditor under time pressure sees a named function with an assert and moves on. That's rational behavior — the alternative is manually verifying every comparison in every math function, which no audit budget covers.</p>
<p><strong>This is the gap static analysis fills.</strong> Not because tools are smarter than auditors — they're not. Because tools don't have time pressure. A lint rule that flags "u64 multiplication without u128 promotion" runs in milliseconds on every file. It catches the 5 sites out of 500 where the developer skipped the cast, and an auditor can spend their limited time on the hard problems instead.</p>
<hr />
<h2>What I Took Away</h2>
<p>I've been building a lint tool for Sui Move (<a href="https://github.com/mehvetero/move-test-gen">move-test-gen</a>) and these three incidents shaped what it checks:</p>
<ul>
<li><p>MOV-002 exists because of Cetus — flag unchecked multiplications</p>
</li>
<li><p>MOV-004 exists because of Bucket — flag unchecked downcasts</p>
</li>
<li><p>The Aftermath analysis taught me that signed types need entry-point validation, not just conversion-boundary checks</p>
</li>
</ul>
<p>But honestly, the bigger lesson isn't about any specific rule. It's that <strong>the math layer is where Move's safety guarantees end and where the money is.</strong> Move prevents reentrancy, enforces object ownership, and catches type errors at compile time. It doesn't prevent <code>2^192 &lt;&lt; 64</code> from wrapping. Three protocols, three audits (seven total across them), $224M lost and counting.</p>
<p>The fix every time is one line. The cost of missing it is nine figures.</p>
<hr />
<p><em>On-chain data verified via Sui RPC. Cetus attacker</em> <code>0xe28b...ff06</code> <em>confirmed at</em> <code>2025-05-22 10:30:50 UTC</code><em>. Aftermath attacker</em> <code>0x1a65...d41e</code> <em>confirmed at</em> <code>2026-04-29 08:55:50 UTC</code><em>. Bucket PR references commit</em> <code>0ad3cb5</code> <em>(OtterSec audit scope).</em></p>
<p><em>References:</em> <a href="https://www.cyfrin.io/blog/inside-the-223m-cetus-exploit-root-cause-and-impact-analysis"><em>Cyfrin (Cetus)</em></a> <em>·</em> <a href="https://www.darknavy.org/web3/exploits/aftermathfi-perpetuals-negative-integrator-fee-collateral-inflation/"><em>DarkNavy (Aftermath)</em></a> <em>·</em> <a href="https://github.com/Bucket-Protocol/Audit"><em>OtterSec Bucket audit</em></a></p>
]]></content:encoded></item><item><title><![CDATA[I Built an Open-Source Security Linter for Sui Move — Here's What It Found on Real Protocols]]></title><description><![CDATA[I've been building move-test-gen, a security tool for Sui Move smart contracts. It started as a test generator and grew into a coverage checker with mutation testing and a security linter. This post c]]></description><link>https://mehvetero.com/i-built-an-open-source-security-linter-for-sui-move-here-s-what-it-found-on-real-protocols</link><guid isPermaLink="true">https://mehvetero.com/i-built-an-open-source-security-linter-for-sui-move-here-s-what-it-found-on-real-protocols</guid><category><![CDATA[Sui]]></category><category><![CDATA[move]]></category><category><![CDATA[Security]]></category><category><![CDATA[Testing]]></category><dc:creator><![CDATA[mehvetero]]></dc:creator><pubDate>Sat, 25 Jul 2026 05:47:52 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a6316b60ed37bae98b22704/344ad73b-1838-4c44-aaf6-977f030a7160.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I've been building <a href="https://github.com/mehvetero/move-test-gen">move-test-gen</a>, a security tool for Sui Move smart contracts. It started as a test generator and grew into a coverage checker with mutation testing and a security linter. This post covers what it does, what it found when I pointed it at production DeFi protocols, and how to use it in your own CI.</p>
<h2>The Problem</h2>
<p>Sui's built-in linter has 6 rules — all focused on object handling (self-transfer, share-owned, etc.). Nothing about arithmetic overflow, missing access control, or unsafe type casting. <a href="https://movebit.xyz/">MoveScanner</a> exists but is closed-source and commercial.</p>
<p>If you're writing Move and want a <code>slither</code>-equivalent that checks security patterns on every PR — there isn't one. Or there wasn't.</p>
<h2>What move-test-gen Does</h2>
<p>Three layers, each independent:</p>
<p><strong>Layer 1 — Assert pairing.</strong> Scans every <code>assert!</code> and <code>abort</code> in your source modules, every <code>#[expected_failure]</code> in your tests, and tells you which abort paths have no test coverage. Runs in seconds, needs only Node.js.</p>
<p><strong>Layer 2 — Mutation testing.</strong> Injects deterministic bugs (flip a <code>&lt;</code> to <code>&gt;=</code>, comment out an <code>assert!</code>) and checks whether your test suite catches them. If a mutant survives, the test that should have caught it is too weak. 7 operators, exhaustive mode. Needs <code>sui</code> CLI.</p>
<p><strong>Layer 3 — Security lint.</strong> 4 rules that scan for common vulnerability patterns:</p>
<table>
<thead>
<tr>
<th>Rule</th>
<th>Severity</th>
<th>What it catches</th>
</tr>
</thead>
<tbody><tr>
<td><strong>MOV-001</strong></td>
<td>HIGH</td>
<td><code>public fun</code> with <code>&amp;mut</code> but no capability, key, or witness — missing access control</td>
</tr>
<tr>
<td><strong>MOV-002</strong></td>
<td>HIGH</td>
<td><code>u64 * u64</code> without <code>u128</code> promotion — arithmetic overflow risk</td>
</tr>
<tr>
<td><strong>MOV-003</strong></td>
<td>MEDIUM</td>
<td>Division by a variable without prior zero-check</td>
</tr>
<tr>
<td><strong>MOV-004</strong></td>
<td>MEDIUM</td>
<td><code>(expr as u64)</code> downcast from u128/u256 without overflow assert</td>
</tr>
</tbody></table>
<p>MOV-002 and MOV-004 use a lightweight Move parser (<code>scripts/move-parser.mjs</code>) that tracks variable types through declarations, casts, and naming conventions. So it knows that <code>numerator1</code> assigned from <code>liquidity_u256 &lt;&lt; RESOLUTION</code> is u256 — no false positive on the multiplication.</p>
<h2>What It Found on Real Protocols</h2>
<p>I ran the linter against four production Sui DeFi protocols:</p>
<h3>Kriya DEX — 1 finding</h3>
<pre><code class="language-plaintext">🔴 HIGH  spot_dex.move:187  [MOV-001]
  public function modifies state without capability check:
  `update_pool` takes &amp;mut but no capability parameter
</code></pre>
<p>This is the same access control gap I <a href="https://github.com/efficacy-finance/kriya-dex-interface/issues/2">reported manually</a> in a 6-finding security review. The rule catches it in under a second on 3 source files.</p>
<h3>Scallop Lending (172 files) — 2 findings</h3>
<pre><code class="language-plaintext">🔴 HIGH  liquidation_evaluator.move:143  [MOV-002]
  `LIQUIDATION_CAP_DIVISOR * debt_price_raw` — overflow risk

🟡 MEDIUM  limiter.move:189  [MOV-004]
  `(i as u64)` — downcast without overflow check
</code></pre>
<p>Initial run hit 82 false positives. Generic functions <code>fun name&lt;X, Y&gt;()</code> were invisible to the regex, test modules were scanned, Sui-specific patterns like <code>Witness&lt;T&gt;</code> and <code>Version</code> params weren't recognized. 9 commits to fix all of that. After hardening: these 2 legitimate findings, zero false positives on production code.</p>
<h3>Bucket Protocol — Fix PR Submitted</h3>
<p>MOV-002 flagged three sites in <code>compute_collateral_value_to_buck</code> and <code>compute_buck_value_to_collateral</code> where <code>mul_factor</code> (which safely promotes to u128 internally) is followed by a raw u64 multiplication that undoes the protection. I <a href="https://github.com/Bucket-Protocol/v1-core/pull/12">submitted a fix PR</a> — three lines changed, <code>u128</code> intermediate cast.</p>
<p>The overflow doesn't affect current deployments (SUI is 9 decimals = BUCK decimals, so the scaling factor is 1), but adding a collateral type with higher decimals would break liquidation, redemption, and health checks. The <a href="https://github.com/Bucket-Protocol/Audit">OtterSec V1 audit</a> caught a related conversion issue but not the overflow in the fix itself.</p>
<h2>How to Use It</h2>
<h3>One-liner (no install)</h3>
<pre><code class="language-bash">npx mehvetero/move-test-gen sources tests --lint
</code></pre>
<h3>In CI — GitHub Action</h3>
<pre><code class="language-yaml"># .github/workflows/move-security.yml
name: move-security
on: [pull_request]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: mehvetero/move-test-gen@v1.3.0
        with:
          sources: sources
          tests: tests
          lint: 'true'
</code></pre>
<p>Layer 1 + lint runs in seconds with zero dependencies. For mutation testing, add <code>mutate: 'true'</code> and install <code>sui</code> — there's a <a href="https://github.com/mehvetero/move-test-gen/blob/main/examples/workflows/nightly-mutation.yml">nightly schedule example</a> in the repo.</p>
<p><strong>Marketplace:</strong> <a href="https://github.com/marketplace/actions/move-test-gen-coverage-check">move-test-gen coverage check</a></p>
<h3>As an Agent Skill</h3>
<p>The tool is also an <a href="https://github.com/agentskills/agentskills">agentskills</a>-compatible skill. Install:</p>
<pre><code class="language-bash">npx skills add mehvetero/move-test-gen
</code></pre>
<p>Then ask your coding agent: <em>"Generate edge-case tests for sources/vault.move"</em> — it produces <code>#[test]</code> and <code>#[expected_failure]</code> functions covering boundary values, arithmetic overflows, access control violations, and economic edge cases.</p>
<p><strong>Agent Skill listing:</strong> <a href="https://github.com/agentskills/agentskills/discussions/451">agentskills Discussion #451</a></p>
<h2>How It Compares</h2>
<table>
<thead>
<tr>
<th></th>
<th>move-test-gen</th>
<th>Sui built-in linter</th>
<th>MoveScanner</th>
<th>Aptos move-mutation-tools</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Open source</strong></td>
<td>Yes</td>
<td>Yes</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td><strong>Security rules</strong></td>
<td>4 (MOV-001~004)</td>
<td>0 (object-handling only)</td>
<td>Many (undisclosed)</td>
<td>0</td>
</tr>
<tr>
<td><strong>Mutation testing</strong></td>
<td>Yes (7 operators)</td>
<td>No</td>
<td>No</td>
<td>Yes (Aptos only)</td>
</tr>
<tr>
<td><strong>Sui support</strong></td>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>No (Aptos only)</td>
</tr>
<tr>
<td><strong>CI integration</strong></td>
<td>GitHub Action</td>
<td>CLI flag</td>
<td>Commercial</td>
<td>CLI</td>
</tr>
<tr>
<td><strong>Parser</strong></td>
<td>Lightweight (function-level types)</td>
<td>Full compiler</td>
<td>Full compiler</td>
<td>Full compiler</td>
</tr>
<tr>
<td><strong>Price</strong></td>
<td>Free</td>
<td>Free</td>
<td>Paid</td>
<td>Free</td>
</tr>
</tbody></table>
<p>The gap this fills: there's no open-source tool that does security-focused static analysis on Sui Move. The built-in linter handles object patterns. MoveScanner is commercial. Aptos has mutation tools but they don't work on Sui. This tool covers the middle ground — regex + parser based, not compiler-grade, but catches the patterns that actually show up in exploits.</p>
<h2>Measured, Not Trusted</h2>
<p>The tool has an eval lab (<code>eval/</code>) with 5 campaigns, 13 scenarios, and 47 rounds of testing. Frozen prompt templates, retirement-by-saturation protocol, dated records. The methodology is borrowed from <a href="https://github.com/TheColliery">TheColliery</a> — full lineage in the <a href="https://github.com/mehvetero/move-test-gen/blob/main/eval/RESULTS.md">RESULTS.md</a>.</p>
<p>The lint rules were validated against Kriya DEX, Scallop (172 files), Bucket Protocol, and Turbos CLMM. Every false positive fix gets a selftest pin before it ships — 11 regression cases, all enforced in CI.</p>
<h2>What's Next</h2>
<ul>
<li><p>More rules — clock staleness, upgrade policy, shared object access patterns</p>
</li>
<li><p>Parser improvements — tracking types through function calls, not just declarations</p>
</li>
<li><p>More protocol scans — each scan either validates the rules or finds a new pattern to handle</p>
</li>
<li><p>Contribution: if you have a Move security pattern that should be a rule, <a href="https://github.com/mehvetero/move-test-gen/issues">open an issue</a> or PR a <code>rules/mov-NNN-*.mjs</code> file</p>
</li>
</ul>
<hr />
<p><strong>Repo:</strong> <a href="https://github.com/mehvetero/move-test-gen">github.com/mehvetero/move-test-gen</a> <strong>Marketplace:</strong> <a href="https://github.com/marketplace/actions/move-test-gen-coverage-check">move-test-gen coverage check</a></p>
]]></content:encoded></item><item><title><![CDATA[How One Missing Assert Drained $1.14M From Aftermath Finance — A Signed Integer Exploit on Sui

]]></title><description><![CDATA[A technical breakdown of the Aftermath Finance perpetuals exploit (April 29, 2026). How a negative fee value bypassed validation, inflated collateral, and allowed an attacker to drain $1.14M USDC in 3]]></description><link>https://mehvetero.com/how-one-missing-assert-drained-1-14m-from-aftermath-finance-a-signed-integer-exploit-on-sui</link><guid isPermaLink="true">https://mehvetero.com/how-one-missing-assert-drained-1-14m-from-aftermath-finance-a-signed-integer-exploit-on-sui</guid><category><![CDATA[Security]]></category><category><![CDATA[Sui]]></category><category><![CDATA[exploit]]></category><category><![CDATA[move]]></category><category><![CDATA[Smart Contracts]]></category><category><![CDATA[Blockchain]]></category><dc:creator><![CDATA[mehvetero]]></dc:creator><pubDate>Fri, 24 Jul 2026 13:19:26 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a6316b60ed37bae98b22704/da6d1893-a154-43db-9886-910027df1de4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>A technical breakdown of the Aftermath Finance perpetuals exploit (April 29, 2026). How a negative fee value bypassed validation, inflated collateral, and allowed an attacker to drain $1.14M USDC in 36 minutes — and what a single line of code would have prevented.</em></p>
<hr />
<h2>TL;DR</h2>
<ul>
<li><p><strong>Protocol:</strong> Aftermath Finance Perpetuals (Sui Network)</p>
</li>
<li><p><strong>Date:</strong> April 29, 2026</p>
</li>
<li><p><strong>Loss:</strong> $1,139,927 USDC across 11 transactions in 36 minutes</p>
</li>
<li><p><strong>Root cause:</strong> Signed fixed-point fee value accepted without non-negativity check. <code>fee &lt;= max</code> was validated, <code>fee &gt;= 0</code> was not.</p>
</li>
<li><p><strong>Auditor:</strong> OtterSec (November 2025) — did not catch this issue</p>
</li>
<li><p><strong>Fix:</strong> One assert: <code>assert!(integrator_taker_fee &gt;= 0)</code></p>
</li>
</ul>
<hr />
<h2>Background: What Aftermath Perps Does</h2>
<p>Aftermath Finance operates a fully onchain perpetual futures exchange on Sui. Unlike hybrid architectures that rely on off-chain sequencers, every order, match, and settlement executes in Move smart contracts.</p>
<p>The protocol supports an <strong>integrator system</strong> — third parties can integrate with Aftermath's clearing house and charge their own fees on top. Each integrator registers a fee configuration per user account.</p>
<p>This is the system the attacker exploited.</p>
<hr />
<h2>The Vulnerable Code Path</h2>
<p>The perpetuals contracts are not open-source. The reconstruction below is based on <a href="https://www.darknavy.org/web3/exploits/aftermathfi-perpetuals-negative-integrator-fee-collateral-inflation/">DarkNavy's on-chain bytecode disassembly</a>, which preserved the decompiled Move code in full.</p>
<h3>On-Chain Addresses</h3>
<table>
<thead>
<tr>
<th>Component</th>
<th>Address</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Clearing House Package</strong></td>
<td><code>0x21d001e8b07da2e3facb3e2d636bbaef43ba3c978bd84810368840b7d57c5068</code></td>
</tr>
<tr>
<td><strong>USDC Clearing House Object</strong></td>
<td><code>0x95969906ca735c9d44e8a44b5b7791b4dacaddf70fbdfbda40ccd3f8a9fd4920</code></td>
</tr>
<tr>
<td><strong>Interface Wrapper</strong></td>
<td><code>0x9e208bed81b7072fa75af8f9eaca42ced3ec8154bb04f5b948c2a9455125d136</code></td>
</tr>
<tr>
<td><strong>Signed Fixed-Point Library</strong></td>
<td><code>0x46234ba81f3a5ba6571383233df0f9ea5fe60a3a327537be1f5fec447bced693</code></td>
</tr>
<tr>
<td><strong>Attacker</strong></td>
<td><code>0x1a65086c85114c1a3f8dc74140115c6e18438d48d33a21fd112311561112d41e</code></td>
</tr>
</tbody></table>
<h3>Step 1: Fee Validation (the bug)</h3>
<p>When a trade executes, the clearing house calculates taker fees:</p>
<pre><code class="language-move">// clearing_house.move (reconstructed from disassembly)
fun calculate_taker_fees(
    position, account, quote_filled, base_fee, gas_fee, integrator_info
) {
    let integrator_taker_fee = get_integrator_taker_fee(integrator_info);
    let max_taker_fee = get_integrator_max_taker_fee(account);
    
    // THE BUG: only upper-bound check
    assert!(integrator_taker_fee &lt;= max_taker_fee, invalid_integrator_taker_fee);
    
    // missing: assert!(integrator_taker_fee &gt;= 0, invalid_integrator_taker_fee);
    
    // ... compute fees
}
</code></pre>
<p>The comparison uses a <strong>signed fixed-point</strong> type. The <code>less_than_eq</code> function in the fixed-point library does signed comparison:</p>
<pre><code class="language-move">// signed fixed-point library
public fun less_than_eq(a: u256, b: u256): bool {
    return (a ^ SIGN_BIT) &lt;= (b ^ SIGN_BIT);
}
</code></pre>
<p>The XOR with <code>SIGN_BIT</code> converts to two's complement ordering. Any negative value has <code>SIGN_BIT</code> set, so after XOR it becomes a small number — smaller than zero (which has <code>SIGN_BIT</code> unset after XOR). Result: <strong>negative &lt;= 0 is always true.</strong></p>
<h3>Step 2: Collateral Calculation (the damage)</h3>
<p>After fees are "validated," the clearing house applies them to the taker's collateral:</p>
<pre><code class="language-move">// clearing_house.move (reconstructed)
fun process_fill_taker(position, account, ...) {
    let (taker_fee_total, integrator_fee_total, integrator_addr) = 
        calculate_taker_fees(position, account, quote_filled, base_fee, gas_fee, integrator_info);
    
    // THE CRITICAL LINE
    let collateral_delta = filled_value - (taker_fee_total + integrator_fee_total);
    
    position::add_to_collateral_usd(position, collateral_delta, price_scale);
}
</code></pre>
<p>When <code>integrator_fee_total</code> is negative:</p>
<pre><code class="language-plaintext">collateral_delta = filled_value - (taker_fee + NEGATIVE_FEE)
                 = filled_value - (taker_fee - |fee|)
                 = filled_value - taker_fee + |fee|
</code></pre>
<p>The negative fee <strong>adds</strong> to the collateral instead of subtracting. The taker account receives phantom USDC credit that does not correspond to any real deposit.</p>
<h3>Step 3: Cash Out</h3>
<p>The attacker then calls two functions to convert phantom collateral into real USDC:</p>
<pre><code class="language-move">// 1. Move collateral from position to account balance
clearing_house::deallocate_collateral(clearing_house, account, amount);

// 2. Withdraw account balance as actual USDC
account::withdraw_collateral(account, amount);
</code></pre>
<p>The <code>to_balance</code> conversion function in the fixed-point library actually checks for negative values:</p>
<pre><code class="language-move">public fun to_balance(value: u256, scale: u256): u64 {
    if (value &gt;= SIGN_BIT) abort 0;  // rejects negative!
    return (value / scale) as u64;
}
</code></pre>
<p>But this check is at the <strong>exit boundary</strong>, not at the entry point. By the time the flow reaches <code>to_balance</code>, the collateral delta has already been computed as a large positive number (filled_value + |fee|). The negative value was consumed in the fee subtraction step — it never reaches <code>to_balance</code>.</p>
<p><strong>Defense-in-depth failure:</strong> the inner function rejects negative, but the outer function lets it in. The attacker's path never touches the inner function with a negative value.</p>
<hr />
<h2>The Attack Transaction by Transaction</h2>
<p>The attacker followed the same pattern in each PTB (Programmable Transaction Block):</p>
<pre><code class="language-plaintext">1. Create two fresh accounts (maker + taker) via interface::create_and_return_account
2. Create positions for both accounts
3. Deposit 100 USDC into the maker-side account ONLY
4. Create integrator vault for attacker's address
5. Add integrator config to taker-side account: max_taker_fee = 0
6. Place a limit order on the maker side
7. Call clearing_house::create_integrator_info(attacker_address, NEGATIVE_FEE)
8. Start taker session → market order fills against maker
9. End session → deallocate free collateral → withdraw USDC from taker
10. Clean up transient accounts
</code></pre>
<p><strong>100 USDC deposited on maker side → 100,000+ USDC withdrawn from taker side.</strong> The delta is pure phantom collateral.</p>
<h3>All 11 Profitable Transactions</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>Time (UTC)</th>
<th>Transaction</th>
<th>Net USDC Gain</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>08:55:50</td>
<td><code>531W14qrdyoD8tZrA34CzSU8pe4Dz1bNEAEcq2mC7E7u</code></td>
<td>~103K</td>
</tr>
<tr>
<td>2</td>
<td>08:55:54</td>
<td><code>FQALUkZD1NnY9cDYoah2Zus6Z4TSp6voj1k2XKuA863Y</code></td>
<td>~103K</td>
</tr>
<tr>
<td>3</td>
<td>09:06:40</td>
<td><code>u4zE7XBnPKxETVPBfF7XtKYd694qEHa6QTx1LDHwujT</code></td>
<td>~103K</td>
</tr>
<tr>
<td>4</td>
<td>09:11:40</td>
<td><code>2s3ybJFhwzzLSMTsugGSAN9b1SgjaL1uc8yPaqVbJ1wz</code></td>
<td>~103K</td>
</tr>
<tr>
<td>5</td>
<td>09:13:30</td>
<td><code>9UJLFirEqNuXEPkVZQL1MfNHZTfHy2zDH99MyoSb7k61</code></td>
<td>~103K</td>
</tr>
<tr>
<td>6</td>
<td>09:14:47</td>
<td><code>CKfb8nYzTA9XWSs6A3PpaCT6HqQFp48nBLBPwEV4EGPC</code></td>
<td>~103K</td>
</tr>
<tr>
<td>7</td>
<td>09:15:27</td>
<td><code>7P8TFYuvACtoPG79SpsBhucEiJ7MvmdVj11DomLjgrXu</code></td>
<td>~103K</td>
</tr>
<tr>
<td>8</td>
<td>09:18:13</td>
<td><code>BqWYBVHnx8USdbKH24BNJD1gB7WxZWGeGz4WqMVmEFMn</code></td>
<td>~103K</td>
</tr>
<tr>
<td>9</td>
<td>09:18:52</td>
<td><code>CSaHz1ABSeUbaLv7zdqFkH4ibLxEBxPWqYTBmXyCXVXB</code></td>
<td>~103K</td>
</tr>
<tr>
<td>10</td>
<td>09:19:05</td>
<td>—</td>
<td>~103K</td>
</tr>
<tr>
<td>11</td>
<td>09:31:29</td>
<td>—</td>
<td>~103K</td>
</tr>
</tbody></table>
<p>Total: <strong>$1,139,927 USDC</strong> net gain. Seeded with only <strong>$1,100 USDC</strong> across maker accounts.</p>
<hr />
<h2>Why the Audit Missed It</h2>
<p>OtterSec audited Aftermath Perps in November 2025. The vulnerability was introduced in August 2025 — it was in-scope during the audit.</p>
<p>This is not a knock on OtterSec. They are a top-tier Move auditor. But the bug reveals a systematic blind spot in manual review:</p>
<p><strong>The code <em>reads</em> correct.</strong> <code>assert!(fee &lt;= max_fee)</code> looks like a complete validation. A reviewer scanning the fee path sees a bounds check and moves on. The absence of a lower-bound check is invisible unless the reviewer specifically asks: "can this signed value be negative, and what happens if it is?"</p>
<p>Manual review checks what IS there. A linter checks what ISN'T there.</p>
<p>A static rule — <em>"signed fixed-point value accepted as function parameter without</em> <code>assert!(value &gt;= 0)</code> <em>in the validation path"</em> — would have flagged <code>calculate_taker_fees</code> on the first scan.</p>
<hr />
<h2>The Pattern: Upper-Bound-Only Validation on Signed Types</h2>
<p>This is not unique to Aftermath or Move. The pattern appears wherever:</p>
<ol>
<li><p>A protocol uses signed types for values that must be non-negative (fees, amounts, rates, prices)</p>
</li>
<li><p>Validation checks <code>x &lt;= max</code> but not <code>x &gt;= 0</code></p>
</li>
<li><p>A negative value inverts arithmetic downstream</p>
</li>
</ol>
<p><strong>Other instances of this pattern class:</strong></p>
<ul>
<li><p><strong>Dango DeFi (April 2026, Sui):</strong> Insurance fund donations accepted without checking <code>amount &gt; 0</code>. Negative donations extracted collateral. $410K drained, returned by whitehat.</p>
</li>
<li><p><strong>Traditional finance:</strong> Signed integer underflows in banking transaction processors have caused similar issues — a negative transfer amount credited instead of debited.</p>
</li>
</ul>
<p>The defense is simple: <strong>every entry point that accepts a value which must be non-negative needs an explicit lower-bound assert, regardless of what inner functions check.</strong></p>
<hr />
<h2>What Would Have Caught This</h2>
<h3>At the code level</h3>
<pre><code class="language-move">// One line. That's the entire fix.
assert!(integrator_taker_fee &gt;= 0, invalid_integrator_taker_fee);
</code></pre>
<h3>At the tooling level</h3>
<p>A lint rule scanning for signed-type parameters without non-negativity asserts. The rule would flag any function that:</p>
<ul>
<li><p>Accepts a signed fixed-point value as input</p>
</li>
<li><p>Uses that value in arithmetic (subtraction, addition to balances)</p>
</li>
<li><p>Has no <code>assert!(value &gt;= 0)</code> or equivalent guard before the arithmetic</p>
</li>
</ul>
<p>This is the class of bug that manual review systematically misses and static analysis systematically catches.</p>
<hr />
<h2>Timeline</h2>
<table>
<thead>
<tr>
<th>Date</th>
<th>Event</th>
</tr>
</thead>
<tbody><tr>
<td>August 29, 2025</td>
<td>Vulnerability introduced in Aftermath Perps codebase</td>
</tr>
<tr>
<td>November 2025</td>
<td>OtterSec audit conducted — issue not flagged</td>
</tr>
<tr>
<td>April 29, 2026 08:55 UTC</td>
<td>First exploit transaction</td>
</tr>
<tr>
<td>April 29, 2026 09:31 UTC</td>
<td>Last exploit transaction (11 total, 36 minutes)</td>
</tr>
<tr>
<td>April 29, 2026</td>
<td>Protocol paused, clearing house set to <code>is_emergency = true</code></td>
</tr>
<tr>
<td>Post-exploit</td>
<td>$1.14M USDC drained. Only perps affected; AMM, staking, other products safe</td>
</tr>
</tbody></table>
<hr />
<h2>Key Takeaways</h2>
<ol>
<li><p><strong>Signed types in financial code are a loaded gun.</strong> If a value must be non-negative, validate at the entry point — not at a downstream conversion function.</p>
</li>
<li><p><code>x &lt;= max</code> <strong>is not a complete bounds check on signed values.</strong> The lower bound matters. This is obvious in retrospect and invisible in review.</p>
</li>
<li><p><strong>Move's type safety didn't help here.</strong> Move prevents reentrancy, memory corruption, and unauthorized object access. It does not prevent signed arithmetic bugs or missing input validation. The "Move is safe" narrative needs qualification.</p>
</li>
<li><p><strong>Audits have systematic blind spots.</strong> A top-tier auditor reviewed this code and missed it. The bug is not subtle — it is a one-line omission. But manual review optimizes for what IS there, not what ISN'T. Tooling fills this gap.</p>
</li>
<li><p><strong>The entire exploit was a single PTB.</strong> No flash loans, no oracle manipulation, no multi-transaction setup. Open accounts, set negative fee, trade, withdraw. Clean enough to repeat 11 times in 36 minutes.</p>
</li>
</ol>
<hr />
<p><em>Analysis based on</em> <a href="https://www.darknavy.org/web3/exploits/aftermathfi-perpetuals-negative-integrator-fee-collateral-inflation/"><em>DarkNavy's on-chain disassembly</em></a><em>. Source contracts are not public — all code shown is reconstructed from bytecode.</em></p>
<p><em>Post-mortem notes maintained at</em> <a href="https://github.com/mehvetero/defi-incident-notes/blob/main/aftermath.md"><em>defi-incident-notes</em></a><em>.</em></p>
]]></content:encoded></item></channel></rss>