DAppSocialPoolModel Alt-Withdraw Drain
Exploit Transactions
0x1f6b9ccdf21dd0247d6acc4ae4eb370558ecb8b782fa51a76c5112f62b7dcda00xe0a98402cb9b9536b4b308458ba46f23d41a51dfd6ee4102c118230a44529cbd0x9004e609f3b383b1ea6716064831359a8e47baff271f7bc3692fc8e1ca1160070xbd72bccec6dd824f8cac5d9a3a2364794c9272d7f7348d074b580e3c6e44312eVictim Addresses
0x319ec3ad98cf8b12a8be5719fec6e0a9bb1ad0d1EthereumLoss Breakdown
Similar Incidents
StakingRewards withdraw underflow drains all staked Uniswap V2 LP
36%SorraV2 staking withdraw bug enables repeated SOR reward drain
34%0x7CAE Approved-Spender Drain
33%USDTStaking Approval Drain
33%Unibot Approval Drain
33%NOON Pool Drain via Public transfer
32%Root Cause Analysis
DAppSocialPoolModel Alt-Withdraw Drain
1. Incident Overview TL;DR
DAppSocialPoolModel at 0x319Ec3AD98CF8b12a8BE5719FeC6E0a9bb1ad0D1 was drained on Ethereum through a public alt-withdraw flow that let an unprivileged adversary create an underflowed token balance for an attacker-controlled helper address. The observed exploit sequence used attacker funding transactions 0xe0a98402cb9b9536b4b308458ba46f23d41a51dfd6ee4102c118230a44529cbd and 0x9004e609f3b383b1ea6716064831359a8e47baff271f7bc3692fc8e1ca116007, then executed the drain in 0xbd72bccec6dd824f8cac5d9a3a2364794c9272d7f7348d074b580e3c6e44312e.
The root cause is an accounting bug in withdrawTokensWithAlt(address,address,uint256): the function validates the caller's token balance and alternate-withdrawer authorization, but debits the alternate account's internal balance bucket instead of the caller's. That breaks the invariant that a withdrawal must reduce only the withdrawing principal's own spendable balance. Once the helper's zero balance underflows, the helper can call ordinary withdrawTokens and empty the pool's supported-token balances.
2. Key Background
DAppSocialPoolModel is a custodial Ethereum contract that tracks user token balances internally and exposes public entry points for token deposit, alternate-withdrawer registration, and token withdrawal. Collector artifacts show that the contract was deployed on block 17631072, and later transactions configured USDT and USDC as supported tokens before the exploit block.
The attack depends on three public behaviors. First, depositTokens credits a caller's internal token balance after an ERC-20 transfer into the pool. Second, lockTokens lets any caller register an alternate address that may later act on that caller's behalf. Third, withdrawTokens spends the caller's recorded balance to transfer supported tokens out of the pool. The exploit abuses the alt-withdraw variant that should have been an extension of this model, but instead mutates the wrong account bucket.
Origin: victim decompile
function lockTokens(address arg0, uint48 arg1) public {
require(!(uint48(storage_map_a[msg.sender]) > block.timestamp), CustomError_de1af48d());
require(address(arg0), CustomError_b660e89a());
storage_map_a[msg.sender] = (uint48(arg1 + (block.timestamp))) | (uint208(storage_map_a[msg.sender]));
storage_map_a[msg.sender] = (address(arg0)) | (uint96(storage_map_a[msg.sender]));
emit LockTokens(msg.sender, address(arg0), uint48(arg1));
}
function withdrawTokens(address arg0, uint256 arg1) public {
require(!(arg1 > storage_map_a[msg.sender]), CustomError_ad3a8b9e());
storage_map_a[msg.sender] = storage_map_a[msg.sender] - arg1;
emit TokenWithdrawn(address(arg0), msg.sender, arg1);
}
3. Vulnerability Analysis & Root Cause Summary
This is an ATTACK-category bug caused by inconsistent principal selection inside the alt-withdraw path. The intended safety invariant is straightforward: for a supported token T and user U, any successful withdrawal of amount A must reduce only U's own recorded spendable balance for T by A. The submitted root-cause analysis identifies the code-level breakpoint as withdrawTokensWithAlt(address token, address alt, uint256 amount) checking the caller's balance and helper authorization, then subtracting from the alt account's token bucket rather than the caller's bucket.
That mismatch is sufficient to create a spendable balance from nothing. If the helper has zero recorded balance and the buggy subtraction runs on an unchecked path, the helper's bucket wraps to a near-2^256 value. The attacker can then switch execution to the helper and invoke the normal withdrawTokens function, which trusts the helper's now-massive balance and releases whatever supported-token balance the pool currently holds. The mechanism is token-agnostic, so the same sequence drained both USDT and USDC in the incident.
4. Detailed Root Cause Analysis
At the exploit block, the pool already held live balances of both target tokens. Collector balance diffs show pre-exploit balances of 10,334,882,398 raw USDT units and 6,591,359,286 raw USDC units at the pool address. The attacker first ensured the caller-balance check would pass by funding its own exploit path with small seed balances, then used a separate helper address to register the attacker as the authorized alternate withdrawer.
The decisive state transition occurs during the alt-withdraw call. The incident analysis states, and the validator fork test independently reproduces, that the attacker keeps its own deposited balance intact while the helper's previously empty balance bucket is driven to 0xffff...ffff for the selected token. That is the invariant break: authorization and sufficiency are checked against one principal, but storage mutation occurs on another.
Origin: validator fork test trace
DAppSocialPoolModel::withdrawTokensWithAlt(USDC, helper, 1)
emit TokenWithdrawn(param0: USDC, param1: attacker, param2: 1)
storage changes:
@ 0xe3e8f958...96d1: 0 -> 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
After that underflow, the exploit no longer needs the buggy entry point. The helper can call the standard withdrawal function with the pool's entire live token balance because the contract now believes the helper owns more than enough internal balance to cover the transfer. In the validator-run PoC, the helper withdraws the full live USDC balance and forwards it back to the attacker address.
Origin: validator fork test trace
DAppSocialPoolModel::withdrawTokens(USDC, 6591359286)
emit Transfer(from: DAppSocialPoolModel, to: helper, value: 6591359286)
USDC::transfer(attacker, 6591359286)
The ACT predicate is therefore deterministic and non-monetary: from a fork where the pool already holds a positive supported-token balance, an unprivileged adversary can use lockTokens, withdrawTokensWithAlt, and withdrawTokens to reduce the pool's supported-token balance to zero. That is exactly what the seed transaction and the independent validator PoC both demonstrate.
5. Adversary Flow Analysis
The attacker cluster consists of EOA 0x7d9bc45a9abda926a7ce63f78759dbfa9ed72e26, exploit contract 0xe897c0f9443785f8d4f0fa6e92a81066b3fbfee2, and helper contract 0xa8c6e7352b13815f6bfa87c7ffaaa6e3a7bfa849. The EOA deployed the exploit contract in transaction 0x1f6b9ccdf21dd0247d6acc4ae4eb370558ecb8b782fa51a76c5112f62b7dcda0, then funded that contract with 5,000,000 raw USDT units and 5,000,000 raw USDC units in transactions 0xe0a98402cb9b9536b4b308458ba46f23d41a51dfd6ee4102c118230a44529cbd and 0x9004e609f3b383b1ea6716064831359a8e47baff271f7bc3692fc8e1ca116007.
The drain transaction 0xbd72bccec6dd824f8cac5d9a3a2364794c9272d7f7348d074b580e3c6e44312e then executes the exploit path end to end:
- Deposit a small supported-token amount so the attacker-controlled caller satisfies the alt-withdraw balance check.
- Have the helper register the attacker path as its alternate withdrawer through
lockTokens. - Call
withdrawTokensWithAlt(token, helper, seedAmount)so the helper's zero balance underflows. - Query the pool's current token balance and call
withdrawTokens(token, poolBalance)from the helper. - Forward the withdrawn tokens from the helper back to the attacker-controlled address.
Origin: seed balance diff
{
"token": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"holder": "0x319ec3ad98cf8b12a8be5719fec6e0a9bb1ad0d1",
"before": "6591359286",
"after": "0",
"delta": "-6591359286"
}
That same pattern occurred for USDT in the same transaction. The exploit is permissionless because every call in the sequence targets public ERC-20 or pool methods, and the helper/attacker addresses are controlled entirely by the adversary.
6. Impact & Losses
The incident drained the pool's supported-token inventory that was live at the exploit block. The measured token losses in raw on-chain units are:
- USDT:
10334882398withdecimal=6 - USDC:
6591359286withdecimal=6
Those correspond to 10,334.882398 USDT and 6,591.359286 USDC removed from the pool address during the observed exploit. In addition to losing the live inventory, the protocol's internal accounting was left in a corrupted state because the helper balance bucket remained a huge wrapped value after the drain.
7. References
- Exploit deployment tx:
0x1f6b9ccdf21dd0247d6acc4ae4eb370558ecb8b782fa51a76c5112f62b7dcda0 - Attacker funding txs:
0xe0a98402cb9b9536b4b308458ba46f23d41a51dfd6ee4102c118230a44529cbd,0x9004e609f3b383b1ea6716064831359a8e47baff271f7bc3692fc8e1ca116007 - Drain tx:
0xbd72bccec6dd824f8cac5d9a3a2364794c9272d7f7348d074b580e3c6e44312e - Victim contract:
0x319Ec3AD98CF8b12a8BE5719FeC6E0a9bb1ad0D1 - Supported tokens: USDT
0xdAC17F958D2ee523a2206206994597C13D831ec7, USDC0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 - Evidence used: collector seed trace and balance diff, victim contract decompile, contract-creation history, and the validator-run mainnet-fork PoC log