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

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 <= maxwas validated,fee >= 0was 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:
A protocol uses signed types for values that must be non-negative (fees, amounts, rates, prices)
Validation checks
x <= maxbut notx >= 0A 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
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.
x <= maxis not a complete bounds check on signed values. The lower bound matters. This is obvious in retrospect and invisible in review.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.
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.
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.


