Unknown Escrow Contract Drain via Integer Overflow in Deposit Function (Ethereum, 2026-03-22)

An unverified escrow-like contract at 0xf0a105d93eec8781e15222ad754fcf1264568c97 on Ethereum Mainnet was fully drained in block 24,707,679 (timestamp 2026-03-22 UTC) through an integer overflow in its deposit function 0x317de4f6. The deposit function accumulates entry amounts into a running total using unchecked arithmetic before passing that total to transferFrom. By supplying two entries whose amounts sum to 1 mod 2^256, the attacker caused only 1 raw USDT unit to be pulled from them via transferFrom, while the per-entry amounts stored in the claim mapping remained uninflated — giving the attacker a stored claimable balance of 97,812,920,709 raw USDT (the full pool) at a cost of 1 raw unit. The attacker then called claim(), which faithfully transferred the stored amount, draining 97,812.92 USDT. The stolen USDT was swapped to approximately 45.34 ETH ($97,500 USD) via the Uniswap V3 USDT/WETH pool, and sent to the attacker EOA 0x7bd736631afbe1d3795a94f60574f7fa0ae89347 through two self-destructing disposable contracts.

Root Cause

Vulnerable Contract

  • Name: Unknown (no verified source; version string 0.2.15 embedded in bytecode)
  • Address: 0xf0a105d93eec8781e15222ad754fcf1264568c97
  • Proxy: No
  • Source type: Recovered [approximation] — runtime bytecode decompiled; confidence medium

Vulnerable Function

  • Function name: 0x317de4f6 (deposit)
  • Signature: 0x317de4f6(address token, (address beneficiary, uint256 amount)[] entries)
  • Selector: 0x317de4f6
  • File: recovered.sol (decompiled approximation; not verified source)

Vulnerable Code

// [recovered — approximation, confirmed against decompiled source]
// Contract: 0xf0a105d93eec8781e15222ad754fcf1264568c97
// Confidence: high

// Storage layout (inferred):
//   slot 0: address _owner; bool _paused
//   slot 1: mapping(address => struct{address token; uint256 amount}[]) _claim

function deposit(address token, Entry[] calldata entries) external {
    require(!_paused);

    // Accumulate total to pull via transferFrom — NO OVERFLOW CHECK
    uint256 total = 0;
    for (uint256 i = 0; i < entries.length; i++) {
        total += entries[i].amount;  // <-- VULNERABILITY: unchecked addition
    }
    require(total > 0, "BADINPUT");

    // Pull `total` from caller — wraps silently if overflow occurred
    token.transferFrom(msg.sender, address(this), total);  // <-- only 1 unit pulled when total wraps to 1

    // Store each entry's full (uninflated) amount in the claim mapping
    for (uint256 i = 0; i < entries.length; i++) {
        if (entries[i].beneficiary != address(0) && entries[i].amount > 0) {
            _storeClaimEntry(entries[i].amount, token, entries[i].beneficiary);
            // _storeClaimEntry has SafeMath — but per-entry amounts are never overflowed
        }
    }
}

Note on claim(): claim() is NOT vulnerable. It correctly reads each entry’s stored amount from _claim[msg.sender] and calls token.transfer(msg.sender, amount). It transfers the stored value, not balanceOf(this). The function behaves as designed — the inflated stored amount is the result of the deposit overflow, not a flaw in claim itself.

Why It’s Vulnerable

Expected behavior: total accumulates the sum of all entry amounts and transferFrom pulls exactly that many tokens from the caller. Each entry’s amount is then stored in the claim mapping. A user who deposits 500 USDT can later claim 500 USDT.

Actual behavior: The accumulation of total uses unchecked addition (compiled with Solidity 0.7.6, where overflow wraps silently). An attacker can supply two entries with different beneficiary addresses whose amounts sum to 1 mod 2^256:

EntryBeneficiaryAmount
0garbage address2^256 - 97,812,920,708 (≈ 2^256)
1AttackerExploitContract97,812,920,709
  • total = (2^256 - 97,812,920,708) + 97,812,920,709 = 1 (mod 2^256)
  • transferFrom pulls 1 raw unit from the attacker
  • _storeClaimEntry stores each entry independently (different beneficiaries, no cross-entry SafeMath triggered):
    • _claim[garbageAddr] = [{USDT, 2^256 - 97,812,920,708}] (uncollectable — no key)
    • _claim[attackerContract] = [{USDT, 97,812,920,709}] ← full pool balance stored
  • Attacker calls claim() → receives 97,812,920,709 raw USDT

Why the beneficiary split is necessary: _storeClaimEntry (0xb38) accumulates amounts for duplicate (beneficiary, token) pairs using SafeMath. If both entries shared the same beneficiary, the stored total would also overflow to 1, yielding no profit. Using distinct beneficiaries keeps the entries in separate mapping slots, bypassing the SafeMath guard in storage.

Missing fix: The accumulation loop must use SafeMath (or Solidity ≥ 0.8) to revert on overflow:

require(total + entries[i].amount >= total, "SafeMath: addition overflow");
total += entries[i].amount;

Attack Execution

High-Level Flow

  1. Attacker EOA (0x7bd736631afbe1d3795a94f60574f7fa0ae89347) deploys AttackerFactory via a contract-creation transaction. The factory’s constructor contains the entire exploit logic.
  2. AttackerFactory deploys AttackerExploitContract (a disposable contract containing the exploit steps).
  3. AttackerFactory calls func_0xb79113c6() on AttackerExploitContract to trigger the exploit.
  4. AttackerExploitContract calls unlock() on the Uniswap v4 PoolManager (0x000000000004444c5dc75cb358380d2e3de08a90), entering a callback context.
  5. Inside the unlockCallback callback: the PoolManager’s take() function transfers 1 raw USDT unit to AttackerExploitContract, with the outstanding debt settled later via settle().
  6. AttackerExploitContract approves the victim contract for unlimited USDT spending, then calls func_0x317de4f6() with two crafted entries whose amounts sum to 1 mod 2^256: Entry[0] {beneficiary=garbageAddr, amount=2^256−97,812,920,708} and Entry[1] {beneficiary=AttackerExploitContract, amount=97,812,920,709}. The overflowed total causes transferFrom to pull only 1 raw USDT unit, while _claim[AttackerExploitContract] is stored with 97,812,920,709 as the claimable amount.
  7. AttackerExploitContract calls claim() on the victim contract. claim() reads the stored amount 97,812,920,709 from _claim[msg.sender] and transfers it — draining ~97,812.92 USDT to AttackerExploitContract.
  8. AttackerExploitContract repays the 1-unit USDT flash-borrow to the PoolManager via settle(), retaining ~97,812.92 USDT net.
  9. AttackerExploitContract approves the DEX router and swaps all USDT to WETH (via Uniswap V3 USDT/WETH 0.3% pool), receiving ~45.34 WETH.
  10. AttackerExploitContract calls WETH.withdraw() to unwrap WETH to ETH, then SELFDESTRUCTs — forwarding all ETH to AttackerFactory, which in turn SELFDESTRUCTs to the attacker EOA.

Detailed Call Trace

All calls derived from trace_callTracer.json and decoded_calls.json.

  • [depth 0] EOA 0x7bd7... → CREATE 0x328a0b37f5b26cc8101b2eed064bb93866762dee (AttackerFactory)
    • [depth 1] AttackerFactory constructor → CREATE 0x0ed84fce07933675974eb9406f8af8b708301902 (AttackerExploitContract)
    • [depth 1] AttackerFactory → AttackerExploitContract: CALL 0xb79113c6 (trigger)
      • [depth 2] AttackerExploitContract → Uniswap v4 PoolManager (0x000000000004444c5dc75cb358380d2e3de08a90): CALL 0x48c89491 unlock(bytes) with empty data
        • [depth 3] PoolManager → AttackerExploitContract: CALL 0x91dd7346 unlockCallback(bytes) (callback)
          • [depth 4] AttackerExploitContract → PoolManager: CALL 0x0b0d9c09 take(address,address,uint256) — args: (USDT, AttackerExploitContract, 1)
            • [depth 5] PoolManager → USDT: CALL 0xa9059cbb transfer(address,uint256) — transfers 1 raw unit USDT to AttackerExploitContract
          • [depth 4] AttackerExploitContract → USDT: STATICCALL 0x70a08231 balanceOf(address) — queries victim contract USDT balance (returns ~97,812.92e6)
          • [depth 4] AttackerExploitContract → USDT: CALL 0x095ea7b3 approve(address,uint256) — approves VulnerableTarget (0xf0a105...) for uint256.max
          • [depth 4] AttackerExploitContract → VulnerableTarget (0xf0a105...): CALL 0x317de4f6 (deposit) — two-entry overflow calldata: Entry[0] {garbageAddr, 2^256−97,812,920,708} + Entry[1] {AttackerExploitContract, 97,812,920,709}; overflowed total = 1
            • [depth 5] VulnerableTarget → USDT: CALL 0x23b872dd transferFrom(address,address,uint256) — pulls 1 raw unit USDT from AttackerExploitContract (overflowed total); stores 97,812,920,709 in _claim[AttackerExploitContract]
          • [depth 4] AttackerExploitContract → VulnerableTarget: CALL 0x4e71d92d claim() — triggers the vulnerable drain
            • [depth 5] VulnerableTarget → USDT: CALL 0xa9059cbb transfer(address,uint256) — transfers 97,812,920,709 raw units (~97,812.92 USDT) to AttackerExploitContract
          • [depth 4] AttackerExploitContract → PoolManager: CALL 0xa5841194 sync(address) — syncs PoolManager’s internal USDT accounting (arg: USDT)
            • [depth 5] PoolManager → USDT: STATICCALL 0x70a08231 balanceOf(address) — reads PoolManager’s own USDT balance
          • [depth 4] AttackerExploitContract → USDT: CALL 0xa9059cbb transfer(address,uint256) — repays 1 raw unit USDT to PoolManager (flash repayment)
          • [depth 4] AttackerExploitContract → PoolManager: CALL 0x11da60b4 settle() — finalizes PoolManager debt accounting
            • [depth 5] PoolManager → USDT: STATICCALL 0x70a08231 balanceOf(address) — confirms PoolManager balance restored
          • [depth 4] AttackerExploitContract → USDT: CALL 0x095ea7b3 approve(address,uint256) — approves DEXRouter (0x00000000008892d0...) for uint256.max
          • [depth 4] AttackerExploitContract → USDT: STATICCALL 0x70a08231 balanceOf(address) — reads own USDT balance (~97,812.92e6)
          • [depth 4] AttackerExploitContract → DEXRouter (0x00000000008892d0...): CALL 0xafeae12b swapV3(...) — swaps ~97,812.92 USDT → ~45.34 WETH via UniV3 0.3% pool
            • [depth 5] DEXRouter → UniV3 USDT/WETH Pool (0x4e68ccd3...): CALL 0x128acb08 swap(address,bool,int256,uint160,bytes)
              • [depth 6] Pool → WETH: CALL 0xa9059cbb transfer(address,uint256) — transfers 45,341,889,877,898,810,010 wei WETH to AttackerExploitContract
              • [depth 6] Pool → DEXRouter: CALL 0xfa461e33 uniswapV3SwapCallback(int256,int256,bytes) — requests USDT payment
                • [depth 7] DEXRouter → USDT: CALL 0x23b872dd transferFrom(address,address,uint256) — pulls 97,812,920,708 raw USDT from AttackerExploitContract to pool
          • [depth 4] AttackerExploitContract → WETH: STATICCALL 0x70a08231 balanceOf(address) — reads WETH balance
          • [depth 4] AttackerExploitContract → WETH: CALL 0x2e1a7d4d withdraw(uint256) — unwraps 45,341,889,877,898,810,010 wei WETH to ETH
            • [depth 5] WETH → AttackerExploitContract: CALL (value transfer, 45.34 ETH)
    • [depth 2] AttackerExploitContract → AttackerFactory: SELFDESTRUCT (forwards 45.34 ETH)
    • [depth 1] AttackerFactory → EOA (0x7bd7...): SELFDESTRUCT (forwards 45.34 ETH)

Financial Impact

Primary source: funds_flow.json (attacker_gains, net_changes).

AssetAmountDirection
USDT drained from victim (0xf0a105...)97,812.920709 USDT (raw: 97,812,920,709; log index 3)VulnerableTarget → AttackerExploitContract
USDT used as initial deposit0.000001 USDT (1 raw unit)PoolManager → AttackerExploitContract → VulnerableTarget
USDT repaid to PoolManager (flash repayment)0.000001 USDT (1 raw unit)AttackerExploitContract → PoolManager
WETH received from UniV3 swap45.341889877898810010 WETHUniV3 Pool → AttackerExploitContract
ETH received by attacker EOA45.341889877898810010 ETHAttackerExploitContract → Factory → EOA

Net attacker gain: ~45.34 ETH (approximately $97,500 USD at ~$2,150/ETH at time of attack).

Victim loss: The VulnerableTarget contract (0xf0a105...) lost its entire USDT balance. The claim() transfer was 97,812.920709 USDT (raw: 97,812,920,709; log index 3). The victim’s net USDT change is −97,812.920708 USDT (raw: −97,812,920,708; confirmed by funds_flow.json net_changes). The 1-raw-unit difference reflects that 1 unit was first deposited into the victim before claim() drained 97,812,920,709 units; the victim’s pre-attack balance was 97,812,920,708 units (~$97,812.92 at peg).

Flash-borrow cost: 1 raw USDT unit (negligible; no fee charged for the PoolManager unlock/take/settle pattern).

Protocol solvency: The vulnerable contract was completely drained. Its USDT balance is zero after this transaction. The attacker’s self-destructing contract design leaves no on-chain evidence of the exploit contract code post-execution.

Evidence

ItemValue
Transaction status0x1 (success) — confirmed by receipt.json
Block number24,707,679 (0x179025f)
Block timestamp0x69bee377 = 2026-03-22 UTC
TX index in block0x0 (first transaction in block)
Selector 0x317de4f6 (deposit)Unresolved from 4byte; identified via trace and decompilation
Selector 0x4e71d92d (claim)Resolved: claim() via 4byte database
USDT Transfer (claim payout) log index 3From 0xf0a105... to 0x0ed84fce..., raw amount 97812920709 = 97,812.920709 USDT
Deposit Transfer log index 2From 0x0ed84fce... to 0xf0a105..., raw amount 1 (1 raw unit USDT)
WETH Transfer log index 6From UniV3 Pool to 0x0ed84fce..., raw amount 45341889877898810010 wei
Victim USDT net change-97812920708 raw units = -97,812.920708 USDT (from funds_flow.json)
Attacker ETH gain45341889877898810010 wei = 45.34188987789881001 ETH (from funds_flow.json attacker_gains)
Uniswap v4 PoolManager address0x000000000004444c5dc75cb358380d2e3de08a90 (Etherscan returns ContractName: PoolManager; not traditional Permit2)
Approve log (exploit → victim) log index 10x0ed84fce... approves 0xf0a105... for uint256.max USDT
Approve log (exploit → DEXRouter) log index 50x0ed84fce... approves 0x00000000008892d0... for uint256.max USDT