Skip to main content

Command Palette

Search for a command to run...

How One Missing Assert Drained $1.14M From Aftermath Finance — A Signed Integer Exploit on Sui

Updated
8 min readView as Markdown
How One Missing Assert Drained $1.14M From Aftermath Finance — A Signed Integer Exploit on Sui
M
Smart contract security researcher.

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.


TL;DR

  • Protocol: Aftermath Finance Perpetuals (Sui Network)

  • Date: April 29, 2026

  • Loss: $1,139,927 USDC across 11 transactions in 36 minutes

  • Root cause: Signed fixed-point fee value accepted without non-negativity check. fee <= max was validated, fee >= 0 was not.

  • Auditor: OtterSec (November 2025) — did not catch this issue

  • Fix: One assert: assert!(integrator_taker_fee >= 0)


Background: What Aftermath Perps Does

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.

The protocol supports an integrator system — 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.

This is the system the attacker exploited.


The Vulnerable Code Path

The perpetuals contracts are not open-source. The reconstruction below is based on DarkNavy's on-chain bytecode disassembly, which preserved the decompiled Move code in full.

On-Chain Addresses

Component Address
Clearing House Package 0x21d001e8b07da2e3facb3e2d636bbaef43ba3c978bd84810368840b7d57c5068
USDC Clearing House Object 0x95969906ca735c9d44e8a44b5b7791b4dacaddf70fbdfbda40ccd3f8a9fd4920
Interface Wrapper 0x9e208bed81b7072fa75af8f9eaca42ced3ec8154bb04f5b948c2a9455125d136
Signed Fixed-Point Library 0x46234ba81f3a5ba6571383233df0f9ea5fe60a3a327537be1f5fec447bced693
Attacker 0x1a65086c85114c1a3f8dc74140115c6e18438d48d33a21fd112311561112d41e

Step 1: Fee Validation (the bug)

When a trade executes, the clearing house calculates taker fees:

// 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 <= max_taker_fee, invalid_integrator_taker_fee);
    
    // missing: assert!(integrator_taker_fee >= 0, invalid_integrator_taker_fee);
    
    // ... compute fees
}

The comparison uses a signed fixed-point type. The less_than_eq function in the fixed-point library does signed comparison:

// signed fixed-point library
public fun less_than_eq(a: u256, b: u256): bool {
    return (a ^ SIGN_BIT) <= (b ^ SIGN_BIT);
}

The XOR with SIGN_BIT converts to two's complement ordering. Any negative value has SIGN_BIT set, so after XOR it becomes a small number — smaller than zero (which has SIGN_BIT unset after XOR). Result: negative <= 0 is always true.

Step 2: Collateral Calculation (the damage)

After fees are "validated," the clearing house applies them to the taker's collateral:

// 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);
}

When integrator_fee_total is negative:

collateral_delta = filled_value - (taker_fee + NEGATIVE_FEE)
                 = filled_value - (taker_fee - |fee|)
                 = filled_value - taker_fee + |fee|

The negative fee adds to the collateral instead of subtracting. The taker account receives phantom USDC credit that does not correspond to any real deposit.

Step 3: Cash Out

The attacker then calls two functions to convert phantom collateral into real USDC:

// 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);

The to_balance conversion function in the fixed-point library actually checks for negative values:

public fun to_balance(value: u256, scale: u256): u64 {
    if (value >= SIGN_BIT) abort 0;  // rejects negative!
    return (value / scale) as u64;
}

But this check is at the exit boundary, not at the entry point. By the time the flow reaches to_balance, 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 to_balance.

Defense-in-depth failure: 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.


The Attack Transaction by Transaction

The attacker followed the same pattern in each PTB (Programmable Transaction Block):

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

100 USDC deposited on maker side → 100,000+ USDC withdrawn from taker side. The delta is pure phantom collateral.

All 11 Profitable Transactions

# Time (UTC) Transaction Net USDC Gain
1 08:55:50 531W14qrdyoD8tZrA34CzSU8pe4Dz1bNEAEcq2mC7E7u ~103K
2 08:55:54 FQALUkZD1NnY9cDYoah2Zus6Z4TSp6voj1k2XKuA863Y ~103K
3 09:06:40 u4zE7XBnPKxETVPBfF7XtKYd694qEHa6QTx1LDHwujT ~103K
4 09:11:40 2s3ybJFhwzzLSMTsugGSAN9b1SgjaL1uc8yPaqVbJ1wz ~103K
5 09:13:30 9UJLFirEqNuXEPkVZQL1MfNHZTfHy2zDH99MyoSb7k61 ~103K
6 09:14:47 CKfb8nYzTA9XWSs6A3PpaCT6HqQFp48nBLBPwEV4EGPC ~103K
7 09:15:27 7P8TFYuvACtoPG79SpsBhucEiJ7MvmdVj11DomLjgrXu ~103K
8 09:18:13 BqWYBVHnx8USdbKH24BNJD1gB7WxZWGeGz4WqMVmEFMn ~103K
9 09:18:52 CSaHz1ABSeUbaLv7zdqFkH4ibLxEBxPWqYTBmXyCXVXB ~103K
10 09:19:05 ~103K
11 09:31:29 ~103K

Total: $1,139,927 USDC net gain. Seeded with only $1,100 USDC across maker accounts.


Why the Audit Missed It

OtterSec audited Aftermath Perps in November 2025. The vulnerability was introduced in August 2025 — it was in-scope during the audit.

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:

The code reads correct. assert!(fee <= max_fee) 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?"

Manual review checks what IS there. A linter checks what ISN'T there.

A static rule — "signed fixed-point value accepted as function parameter without assert!(value >= 0) in the validation path" — would have flagged calculate_taker_fees on the first scan.


The Pattern: Upper-Bound-Only Validation on Signed Types

This is not unique to Aftermath or Move. The pattern appears wherever:

  1. A protocol uses signed types for values that must be non-negative (fees, amounts, rates, prices)

  2. Validation checks x <= max but not x >= 0

  3. A negative value inverts arithmetic downstream

Other instances of this pattern class:

  • Dango DeFi (April 2026, Sui): Insurance fund donations accepted without checking amount > 0. Negative donations extracted collateral. $410K drained, returned by whitehat.

  • Traditional finance: Signed integer underflows in banking transaction processors have caused similar issues — a negative transfer amount credited instead of debited.

The defense is simple: every entry point that accepts a value which must be non-negative needs an explicit lower-bound assert, regardless of what inner functions check.


What Would Have Caught This

At the code level

// One line. That's the entire fix.
assert!(integrator_taker_fee >= 0, invalid_integrator_taker_fee);

At the tooling level

A lint rule scanning for signed-type parameters without non-negativity asserts. The rule would flag any function that:

  • Accepts a signed fixed-point value as input

  • Uses that value in arithmetic (subtraction, addition to balances)

  • Has no assert!(value >= 0) or equivalent guard before the arithmetic

This is the class of bug that manual review systematically misses and static analysis systematically catches.


Timeline

Date Event
August 29, 2025 Vulnerability introduced in Aftermath Perps codebase
November 2025 OtterSec audit conducted — issue not flagged
April 29, 2026 08:55 UTC First exploit transaction
April 29, 2026 09:31 UTC Last exploit transaction (11 total, 36 minutes)
April 29, 2026 Protocol paused, clearing house set to is_emergency = true
Post-exploit $1.14M USDC drained. Only perps affected; AMM, staking, other products safe

Key Takeaways

  1. Signed types in financial code are a loaded gun. If a value must be non-negative, validate at the entry point — not at a downstream conversion function.

  2. x <= max is not a complete bounds check on signed values. The lower bound matters. This is obvious in retrospect and invisible in review.

  3. Move's type safety didn't help here. 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.

  4. Audits have systematic blind spots. 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.

  5. The entire exploit was a single PTB. 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.


Analysis based on DarkNavy's on-chain disassembly. Source contracts are not public — all code shown is reconstructed from bytecode.

Post-mortem notes maintained at defi-incident-notes.