Root cause
LiquidityUnifier holds MINTER_ROLE on the INT reward token and exposes swapV3(address token, address pool) with no on-chain factory-backed pool validation. The pool check relied only on code.length > 0, a denylist check, and the pool returning INT from token0()/token1() — all trivially satisfied by an attacker-deployed contract:
// LiquidityUnifier.sol (reconstructed from on-chain bytecode)
function swapV3(address token, address pool) external {
require(pool.code.length > 0); // ① trivially met
require(!excludedPools[pool]); // ② only explicit denylist
require(
IUniswapV3Pool(pool).token0() == INT || // ③ attacker implements this stub
IUniswapV3Pool(pool).token1() == INT
);
uint256 supplyBefore = IERC20(INT).totalSupply();
IUniswapV3Pool(pool).swap(/* ... */); // ④ callback fires → MINT
require(IERC20(INT).totalSupply() <= supplyBefore); // ⑤ supply guard
}
Inside uniswapV3SwapCallback, the contract trusts msg.sender == currentPoolV3, which the attacker satisfies because they deployed the pool. The supply guard (step ⑤) was neutralised by immediately calling Convertor.convert(INT → OTHER) inside the callback to burn the freshly minted INT, passing the snapshot check, and then calling Convertor.convert(OTHER → INT) outside the guarded scope to re-mint.
Attack steps
| Step | Action | Contract | Effect |
|---|---|---|---|
| 1 | Deploy fake Uniswap V3-interface pool with INT/WETH pair | Attacker-deployed | Satisfies token0/1 and code.length checks |
| 2 | Call swapV3(WETH, fakePool) | LiquidityUnifier | Initiates swap; sets currentPoolV3 = fakePool |
| 3 | Fake pool fires uniswapV3SwapCallback | fakePool → LiquidityUnifier | Mints 925.41M INT to LiquidityUnifier |
| 4 | Convertor.convert(INT → OTHER) inside callback | Convertor | Burns 925.41M INT; supply restored to baseline |
| 5 | validateSupply passes at step ⑤ | LiquidityUnifier | Supply guard satisfied |
| 6 | Convertor.convert(OTHER → INT) called outside guarded scope | Convertor | Re-mints ~925M INT to attacker |
| 7 | Sell 161.27M INT into real INT/WETH Uniswap V3 pool | Uniswap V3 (Base) | Extracts 5.847 WETH |
| 8 | Retain 764.15M INT in attacker wallet | — | Additional ~$254K at report-time pricing |
Impact
- Chain: Base (block 51,593,878)
- Tx:
0xed62bb27bd1058d3d7cc93d55421d6d0001ced8cb4529b02773f5126a4edb08b - Direct attacker gain: 5.847 WETH + 764M INT (~$265K at report-time pricing)
- Copycat escalation: MINTER_ROLE was not revoked promptly; subsequent copycat transactions inflated total supply to ~156 billion INT
- Governance follow-on: Attacker used accumulated INT to submit a treasury governance proposal
Lessons for auditors
- Validate pools against an on-chain factory registry.
UniswapV3Factory.getPool(tokenA, tokenB, fee)returns the canonical pool address; any other address must be rejected.code.length > 0and interface stubs are trivially satisfied by malicious contracts. - Supply guards must be truly atomic. A
totalSupplybefore/after snapshot is defeated whenever intermediate operations neutralise the minted supply within the same call frame. The full mint-and-conversion path must be enclosed in a single atomic invariant check, not split across callback and post-swap code. - MINTER_ROLE is equivalent to an admin key. Any contract holding mint authority that exposes external call paths reachable by arbitrary callers must be treated as highest-privilege infrastructure and audited for callback confusion and re-entrancy via conversion paths.
- Revoke compromised roles immediately. The window between the initial exploit and role revocation allowed multiple copycat attacks to compound supply inflation by orders of magnitude.