All incidents

Blacklist-Zero Double Payout

Share
Jun 27, 2023 14:13 UTCAttackLoss: 5,955.47 USDTPending manual check1 exploit txWindow: Atomic

Root Cause Analysis

Blacklist-Zero Double Payout

1. Incident Overview TL;DR

The incident centers on 0xac899ef647533e0de91e269202f1169d7d47ae92, an unverified BSC contract that held 5955466788004705247296 USDT immediately before block 29469588. In the seed transaction 0xe1bf84b7a57498c0573361b20b16077cc933e4c47aa0821bcea5b158a60ef505, an attacker-controlled contract borrowed temporary USDT from DODO, deposited that amount into the victim, claimed it back, and then used the leftover allowance created by claim(uint256,uint256) to pull the same amount a second time. The victim finished the transaction with zero USDT, while the attacker contract kept 5955466788004705247296 USDT after repaying the flash loan.

The root cause is a payout helper inside claim(uint256,uint256): it calls approve(caller, amount) and then transfer(caller, amount) for the same payout. That sequence creates two spend paths over the same balance. The exploit is ACT because the victim's slot-0x0a mapping is a blacklist, not an allowlist: deposit() and claim() continue when the caller's mapping value is zero, which is the default state for a fresh adversary contract.

2. Key Background

The victim contract is unverified, so the analysis relies on runtime bytecode, transaction traces, and on-chain storage. Two user-accounting trees are relevant: storage rooted at slot 0x05 and slot 0x08. deposit(uint256,uint256) increases those balances after taking tokens with USDT.transferFrom, and claim(uint256,uint256) decreases them before paying tokens out.

The exploitability question hinged on slot 0x0a. The corrected bytecode reading shows that this slot is a blacklist bit. A caller is allowed through both claim() and deposit() only when mapping(slot 0x0a)[caller] == 0. Independent RPC reads confirm that the seed attacker contract had value zero before the exploit transaction, and slot 0x14, the global pre-check touched by deposit(), was also zero at the pre-state.

Victim blacklist gate in claim():
00001405: PUSH1 0xff
00001407: AND
00001408: ISZERO
00001409: PUSH2 0x1447
0000140c: JUMPI
0000140d: ... REVERT
00001447: JUMPDEST

Victim blacklist gate in deposit():
00001e55: PUSH1 0xff
00001e57: AND
00001e58: ISZERO
00001e59: PUSH2 0x1e97
00001e5c: JUMPI
00001e5d: ... REVERT
00001e97: JUMPDEST

3. Vulnerability Analysis & Root Cause Summary

This is an ATTACK-category payout-logic bug in the victim's withdrawal flow. The intended invariant is straightforward: if a user claims x, the victim should reduce the user's internal balance by x and move exactly x tokens out of the victim. The contract violates that invariant by first approving the caller for x and then transferring x directly, leaving a reusable allowance behind. Because the approval survives the direct transfer, the caller can immediately invoke USDT.transferFrom(victim, caller, x) and withdraw a second copy of the same amount. The exploit does not depend on privileged access because the slot-0x0a check is default-open for fresh addresses. Public flash liquidity from DODO is sufficient to fund the temporary deposit that manufactures the claimable balance.

4. Detailed Root Cause Analysis

The exploit begins from the publicly reconstructible pre-state right before block 29469588. At that point, the victim holds 5955466788004705247296 USDT, the DODO pool at 0x9ad32e3054268b849b84a8dbcc7c8f7c52e4e69a holds enough USDT to flash-loan the same amount, slot 0x14 is zero, and mapping(slot 0x0a)[attacker] is zero.

Independent pre-state storage reads for victim 0xac899ef647533e0de91e269202f1169d7d47ae92:
slot 0x14 @ block 29469587  = 0x00
keccak256(attacker, 0x0a)   = 0x31c68c2f8854af923c32299b6ab34bc03710d1c676f38c38ee118af392d70fec
blacklist slot @ 29469587   = 0x00
blacklist slot @ 29469588   = 0x00

Inside claim(uint256,uint256) at PCs 0x13b9-0x17cb, the victim first checks that the caller is not blacklisted, then reads the caller's balances from the slot-0x05 and slot-0x08 accounting trees, and subtracts the requested amount through the SafeMath helper at 0x2b46. The payout step then jumps to helper 0x2b90.

Relevant victim helper flow:
00002b90: JUMPDEST
00002b91: PUSH2 0x2b9b
00002b94: DUP4
00002b95: DUP4
00002b96: DUP4
00002b97: PUSH2 0x3278
00002b9a: JUMP

00003278: JUMPDEST
00003293: PUSH4 0x095ea7b3   ; approve(address,uint256)
...
00002bb6: PUSH4 0xa9059cbb   ; transfer(address,uint256)

That helper is the breakpoint. The jump into 0x3278 emits an approve(caller, amount) call, and the remainder of 0x2b90 emits a transfer(caller, amount) call. Since the contract never clears or consumes the allowance itself, the caller receives both a direct payout and a second spend authority over the same amount.

The seed trace proves the sequence end to end:

Seed trace excerpt:
0xAC899Ef647533E0dE91E269202f1169d7D47Ae92::claim(0, 5955466788004705247296)
  BEP20USDT::approve(attacker, 5955466788004705247296)
    emit Approval(owner: victim, spender: attacker, value: 5955466788004705247296)
  BEP20USDT::transfer(attacker, 5955466788004705247296)
    emit Transfer(from: victim, to: attacker, value: 5955466788004705247296)
  BEP20USDT::allowance(victim, attacker) -> 5955466788004705247296
  BEP20USDT::transferFrom(victim, attacker, 5955466788004705247296)
    emit Transfer(from: victim, to: attacker, value: 5955466788004705247296)
    emit Approval(owner: victim, spender: attacker, value: 0)

Because deposit() is reachable for any unblacklisted address, an adversary can first create an internal claimable balance with temporarily borrowed USDT, then immediately realize the duplicate spend path, and finally repay the flash loan. No attacker-specific bytecode, secret, or pre-authorization is required.

5. Adversary Flow Analysis

The adversary lifecycle has two on-chain stages. First, transaction 0x0692ba15273e1111beea3f1d2167ebcd1a4727053688c87730f2edeaa9f748b7 deployed the seed helper contract from EOA 0xab90a897cf6c56c69a4579ead3c900260dfba02d. The contract address derived from deployer nonce 30 is 0x25d3e7e85108d03b0e778c4b5853b02206871d72, matching the address seen in the exploit trace.

Second, transaction 0xe1bf84b7a57498c0573361b20b16077cc933e4c47aa0821bcea5b158a60ef505 executed the exploit:

  1. The attacker contract borrowed 1243763239827755213151683 USDT from the public DODO flashloan pool.
  2. It approved the victim to take funds and called deposit(0, 5955466788004705247296), which moved that USDT into the victim and credited the attacker in the victim's internal accounting.
  3. It called claim(0, 5955466788004705247296), which reduced the internal balance, approved the attacker for the same amount, and transferred the same amount directly.
  4. It observed that the victim had granted allowance(victim, attacker) = 5955466788004705247296 and immediately called USDT.transferFrom(victim, attacker, 5955466788004705247296).
  5. It repaid the DODO loan and retained the duplicated payout as profit.

This sequence is permissionless under the ACT model because a fresh adversary contract starts unblacklisted, public liquidity funds the deposit, and the exploit predicate is fully realized inside one transaction.

6. Impact & Losses

The measurable on-chain loss in the validated incident is 5955466788004705247296 USDT, all taken from victim contract 0xac899ef647533e0de91e269202f1169d7d47ae92. The attacker contract balance moved from 0 to 5955466788004705247296 USDT by the end of the seed transaction, and the victim's USDT balance moved from 5955466788004705247296 to 0.

The sender EOA separately paid 727992000000000 wei of BNB gas, but that does not affect the token-denominated exploit predicate. The incident therefore demonstrates a full drain of the victim's available USDT balance at the chosen pre-state.

7. References

  1. Seed exploit transaction: 0xe1bf84b7a57498c0573361b20b16077cc933e4c47aa0821bcea5b158a60ef505
  2. Related deployment transaction: 0x0692ba15273e1111beea3f1d2167ebcd1a4727053688c87730f2edeaa9f748b7
  3. Victim contract: 0xac899ef647533e0de91e269202f1169d7d47ae92
  4. USDT token on BSC: 0x55d398326f99059ff775485246999027b3197955
  5. DODO flashloan pool: 0x9ad32e3054268b849b84a8dbcc7c8f7c52e4e69a
  6. Corrected blacklist gate evidence from victim bytecode and storage
  7. Seed trace showing deposit(), claim(), Approval, Transfer, and transferFrom()
  8. Balance-diff evidence showing the attacker profit and victim depletion