Pool16 lend/redeem accounting bug drains USDC without HOME backing
Exploit Transactions
0x7d2296bcb936aa5e2397ddf8ccba59f54a178c3901666b49291d880369dbcf31Victim Addresses
0xb8919522331c59f5c16bdfaa6e03a91f62EthereumLoss Breakdown
Similar Incidents
ENF Redeem Decimal Mis-Scaling
34%SilicaPools decimal-manipulation bug drains WBTC flashloan collateral
34%GPv2Settlement allowance leak lets router drain WETH and USDC
33%NFTX Doodles collateral accounting flaw enables flash-loan ETH extraction
33%Revest TokenVault withdrawFNFT accounting flaw drains RENA vault reserves
33%TRU reserve mispricing attack drains WBNB from pool
32%Root Cause Analysis
Pool16 lend/redeem accounting bug drains USDC without HOME backing
1. Incident Overview TL;DR
On Ethereum mainnet block 14326932, adversary EOA 0x7c42f2a7d9ad01294ecef9be1e38272c84607593 used its helper contract 0x580cac65c2620d194371ef29eb887a7d8dcc91bf to execute a flash‑swap‑backed sequence of three lend(uint256) calls followed by one redeem(uint256) call against the Pool16 proxy 0xb8919522331c59f5c16bdfaa6e03a91f62. This sequence extracted exactly 976,924.997605 USDC from the pool. After repaying the UniswapV2 USDC/ETH flash swap, the adversary cluster retained 957,208.997605 USDC as profit, while HOME totalSupply and all HOME balances on Pool16 remained zero before and after the attack.
The root cause is that the deployed Pool16 implementation at 0x781ad73f140815763d9a4d4752daf9203361d07d prices HOME redemptions purely from two internal bookkeeping variables poolLent and poolBorrowed and an attacker‑controlled amountHome parameter, without enforcing any linkage to HOME totalSupply or per‑address HOME balances. As a result, an unprivileged caller can redeem USDC from Pool16 even when no HOME has ever been minted and no HOME balances exist.
2. Key Background
Pool16 (HomeCoin) is an upgradeable ERC20‑based lending pool where users are intended to deposit USDC and receive HOME tokens that represent their share of the pool. The verified source Pool16.sol shows that the contract tracks both the ERC20 HOME supply and balances, and a set of legacy fields:
poolLentandpoolBorrowedare marked as deprecated in Pool14 and are documented as “always set to 0” in the upgraded architecture.- HOME ERC20 accounting is implemented via
totalSupply()and an internal_balancesmapping, with_balancesstored starting at slot0x33.
In the live system, the Pool16 proxy at 0xb8919522331c59f5c16bdfaa6e03a91f62 delegates calls to an implementation contract at 0x781ad73f140815763d9a4d4752daf9203361d07d. Storage snapshots around the exploit show:
- At block
14326931(0xda9c93), the Pool16 proxy holds978,497.348951USDC. - HOME
totalSupplyat slot0x52is0. _accruedat slot0x60is0.poolLentat slot0x68is6,667,864,245,841.poolBorrowedat slot0x69is5,689,859,839,693.loanCountat slots0x6band0x6cis28.- The HOME
_balancesmapping entries for the helper0x580cac65c2620d194371ef29eb887a7d8dcc91bfand the adversary EOA0x7c42f2a7d9ad01294ecef9be1e38272c84607593are both zero.
These values are taken directly from the pre/post storage dump:
{
"slots": {
"0x52": {
"pre": "0x0",
"post": "0x0"
},
"0x60": {
"pre": "0x0",
"post": "0x0"
},
"0x68": {
"pre": "0x000000000000000000000000000000000000000000000000000006107bae0651",
"post": "0x0000000000000000000000000000000000000000000000000000052d06698e6c"
},
"0x69": {
"pre": "0x0000000000000000000000000000000000000000000000000000052cc61316cd",
"post": "0x0000000000000000000000000000000000000000000000000000052cc61316cd"
}
}
}
Caption: Pool16 proxy storage pre/post exploit (block 14326931→14326932) for key slots, showing totalSupply and _accrued fixed at 0 while poolLent decreases.
The USDC token is FiatTokenV2_2 at 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48, and the exploit uses the canonical UniswapV2 USDC/ETH pair 0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc to obtain a flash swap.
3. Vulnerability Analysis & Root Cause Summary
The vulnerability is an accounting invariant violation between HOME shares and underlying USDC assets. In the intended design, only holders of minted HOME should be able to redeem USDC, and the pool’s USDC balance should remain consistent with HOME totalSupply and individual HOME balances. However, in the deployed implementation:
lend(uint256)andredeem(uint256)are implemented to read and write only the legacypoolLentandpoolBorrowedstorage slots and to transfer USDC, without touching HOMEtotalSupplyor the_balancesmapping.- This means the amount of USDC transferred in a redemption is computed as a pure function of
amountHome(a caller‑controlled parameter) and the internalpoolLent/poolBorrowedvalues, with no check that the caller owns any HOME or thattotalSupplyis non‑zero.
In the observed state, HOME totalSupply and all HOME balances are zero while Pool16 holds a large USDC balance. Under these conditions, an unprivileged caller can choose an amountHome input that drives redeem(uint256) to transfer out nearly all USDC from the pool, even though no HOME has ever been minted. This is the precise root cause exploited by the adversary.
4. Detailed Root Cause Analysis
Intended Accounting Invariant
Let:
Abe Pool16’s USDC balance.Sbe HOMEtotalSupply(slot0x52).LbepoolLent(slot0x68).BbepoolBorrowed(slot0x69).bal[x]be the HOME balance of addressx(_balancesmapping at slot0x33).
For any redemption of amountHome from address x, the intended invariant is:
amountHome <= bal[x],S > 0, and- the USDC paid out to
xmust be limited so thatA,L,B, andSremain mutually consistent, with no USDC redeemable whenS = 0and allbal[*] = 0.
Deployed Implementation Behavior
The verified Pool16.sol source marks poolLent and poolBorrowed as deprecated and intended to remain at zero in the upgraded design. The deployed implementation at 0x781ad73f140815763d9a4d4752daf9203361d07d, however, repurposes these slots in the actual lending and redemption logic:
- Disassembly and selector analysis show a function identified as
lend(uint256 amountUsdc)that:- Reads the USDC token address from slot
0x66. - Calls
transferFrom(msg.sender, address(this), amountUsdc)on FiatTokenV2_2. - Reads
poolLentfrom slot0x68, performs arithmetic combiningamountUsdcwith the priorpoolLentvalue, and writes a new value back to slot0x68withSSTORE.
- Reads the USDC token address from slot
- A function identified as
redeem(uint256 amountHome):- Reads
poolLent(slot0x68) andpoolBorrowed(slot0x69) into local variables. - Applies an internal formula involving
amountHome,L, andBto compute a USDC payout and an updatedL'. - Writes
L'back to slot0x68. - Calls the USDC token’s
transferto send USDC from Pool16 tomsg.sender.
- Reads
Across this routine, there are **no SLOAD or SSTORE operations touching HOME totalSupply at slot 0x52 or any _balances entries at slot 0x33. The contract never checks that msg.sender owns HOME nor that totalSupply is non‑zero before redeeming USDC.
Observed State Transition
From the pre/post storage snapshot for the Pool16 proxy:
S_pre = 0,_accrued_pre = 0,L_pre = 6,667,864,245,841,B_pre = 5,689,859,839,693,loanCount_pre = 28.S_post = 0,_accrued_post = 0,L_post = 5,690,939,248,236,B_post = 5,689,859,839,693,loanCount_post = 28.- The HOME
_balancesentries for both the helper and the adversary EOA remain zero before and after.
Computing the change in poolLent:
python - << 'PY'
pre = int('6107bae0651', 16)
post = int('52d06698e6c', 16)
print(post - pre)
PY
This yields -976,924,997,605, which equals the Pool16 proxy’s USDC balance delta in the FiatTokenV2_2 balance diff for the exploit transaction:
{
"token": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"holder": "0xb8919522331c59f5c16bdfaa6e03a91f62",
"before": "978497348951",
"after": "1572351346",
"delta": "-976924997605"
}
Caption: FiatTokenV2_2 USDC balance diff for Pool16 proxy in tx 0x7d2296bcb936aa5e2397ddf8ccba59f54a178c3901666b49291d880369dbcf31, matching the poolLent decrease.
Thus, in the exploit transaction:
delta A = -976,924,997,605(USDC leaves Pool16),delta L = -976,924,997,605,S_pre = S_post = 0,- all recorded HOME balances for the adversary cluster remain zero.
The call trace for tx 0x7d2296bcb936aa5e2397ddf8ccba59f54a178c3901666b49291d880369dbcf31 shows:
… 0x781ad73f140815763d9A4D4752DAf9203361D07D::lend(2120000000000) [delegatecall]
… 0x781ad73f140815763d9A4D4752DAf9203361D07D::lend(2120000000000) [delegatecall]
… 0x781ad73f140815763d9A4D4752DAf9203361D07D::lend(2120000000000) [delegatecall]
… 0x781ad73f140815763d9A4D4752DAf9203361D07D::redeem(8465943180104) [delegatecall]
Caption: Extract from trace.cast.log for tx 0x7d2296…dbcf31, showing three lend calls and one redeem call via the Pool16 implementation.
These three lends deposit USDC into Pool16 and update poolLent, and the final redeem chooses amountHome = 8,465,943,180,104 so that the internal formula reduces poolLent by 976,924,997,605 and transfers exactly that amount of USDC to the helper, all while HOME totalSupply and HOME balances stay at zero. This is a direct violation of the intended invariant that only minted HOME should be redeemable for USDC and that ERC20 accounting should remain consistent with the pool’s assets.
5. Adversary Flow Analysis
Adversary‑Related Cluster Accounts
The adversary‑related cluster consists of:
- EOA
0x7c42f2a7d9ad01294ecef9be1e38272c84607593- Sender of both adversary‑crafted transactions.
- Payer of gas.
- Direct recipient of net USDC profit, as shown in FiatTokenV2_2
balance_diff.json.
- Helper contract
0x580cac65c2620d194371ef29eb887a7d8dcc91bf- Deployed by the first adversary‑crafted transaction.
- Hard‑codes the USDC token, Pool16 proxy, and UniswapV2 USDC/ETH pair addresses in its storage.
- Acts as
msg.senderfor alllend(uint256)andredeem(uint256)delegatecalls to the Pool16 implementation in the exploit transaction.
Stage 1: Helper Contract Deployment
- Tx:
0xacfcaa8e1c482148f9f2d592c78ca7a27934c7333dab31978ed0aef333a28ab6(block14326928). - From:
0x7c42f2a7d9ad01294ecef9be1e38272c84607593. - To: contract creation (helper
0x580cac65c2620d194371ef29eb887a7d8dcc91bf). - Mechanism: standard type‑2 Ethereum transaction with zero ETH value and normal gas parameters.
- Effect:
- Deploys the helper contract whose constructor stores:
- USDC token:
0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48, - Pool16 proxy:
0xb8919522331c59f5c16bdfaa6e03a91f62, - UniswapV2 USDC/ETH pair:
0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc.
- USDC token:
- No ERC20 balances change; the EOA pays only native gas.
- The transaction is fully permissionless: any EOA can deploy an equivalent helper.
- Deploys the helper contract whose constructor stores:
Stage 2: Flash Swap and Lend/Redeem Exploit
- Tx:
0x7d2296bcb936aa5e2397ddf8ccba59f54a178c3901666b49291d880369dbcf31(block14326932). - From:
0x7c42f2a7d9ad01294ecef9be1e38272c84607593. - To: helper
0x580cac65c2620d194371ef29eb887a7d8dcc91bfwith calldata0xdfcbd40f…0005c8cd8a7000. - Mechanism: UniswapV2 USDC/ETH flash swap plus delegatecalls into Pool16.
Execution flow (from trace.cast.log and balance diffs):
- The helper initiates a USDC/ETH flash swap on the UniswapV2 pair
0xb4e16…c9dc, borrowing USDC. - Via delegatecall into
0x781ad73f140815763d9a4d4752daf9203361d07d, the helper callslend(uint256)three times withamountUsdc = 2,120,000,000,000each, depositing USDC from the helper into Pool16 and incrementingpoolLent. - The helper then calls
redeem(uint256)once withamountHome = 8,465,943,180,104, again via delegatecall into the Pool16 implementation. redeem(uint256)computes a USDC payout based solely onamountHome,poolLent, andpoolBorrowed, writes the updatedpoolLentback to slot0x68, and transfers976,924.997605USDC from Pool16 to the helper.- The helper repays the UniswapV2 flash swap, leaving the adversary EOA with
957,208.997605USDC net.
The FiatTokenV2_2 balance_diff.json for this transaction summarizes the final ERC20 balances:
- Pool16 proxy (
0xb891…f62):delta = -976,924.997605USDC. - UniswapV2 USDC/ETH pair (
0xb4e16…c9dc):delta = +19,716.000000USDC. - Adversary EOA (
0x7c42…7593):delta = +957,208.997605USDC.
No HOME tokens are minted or transferred at any point; HOME totalSupply and all HOME balances remain zero, meaning the adversary’s profit is entirely due to the broken lend/redeem accounting.
6. Impact & Losses
The measurable impact on Pool16 in the exploit transaction 0x7d2296bcb936aa5e2397ddf8ccba59f54a178c3901666b49291d880369dbcf31 is:
- The Pool16 proxy’s USDC balance decreases by
976,924.997605USDC. - The adversary EOA’s USDC balance increases by
957,208.997605USDC. - The UniswapV2 USDC/ETH pair’s USDC balance increases by
19,716.000000USDC (the flash swap fee/price impact). - HOME
totalSupplyand all HOME balances on Pool16 remain exactly zero before and after, so the pool’s recorded HOME liabilities do not change.
From the protocol’s perspective, nearly one million USDC of assets are removed from Pool16 with no corresponding reduction in recorded HOME obligations. This leaves the pool severely under‑collateralized relative to its intended design and represents a direct economic loss to the protocol and its users.
7. References
- Pool16 proxy (HOME lending pool):
0xb8919522331c59f5c16bdfaa6e03a91f62on Ethereum mainnet. - Pool16 implementation contract:
0x781ad73f140815763d9a4d4752daf9203361d07d. - USDC (FiatTokenV2_2):
0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48. - UniswapV2 USDC/ETH pair:
0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc. - Adversary EOA:
0x7c42f2a7d9ad01294ecef9be1e38272c84607593. - Helper contract:
0x580cac65c2620d194371ef29eb887a7d8dcc91bf. - Exploit transaction:
0x7d2296bcb936aa5e2397ddf8ccba59f54a178c3901666b49291d880369dbcf31(block14326932). - Helper deployment transaction:
0xacfcaa8e1c482148f9f2d592c78ca7a27934c7333dab31978ed0aef333a28ab6(block14326928). - Pool16 verified source and layout:
artifacts/root_cause/seed/1/0x366049d336e73cfaf39c6a933780ca4c96ea084c/src/PoolCore/Pool16.sol. - Pool16 implementation bytecode, disassembly, and function selectors:
artifacts/root_cause/data_collector/iter_3/contract/1/0x781ad73f140815763d9a4d4752daf9203361d07d/. - Pool16 storage snapshots and state diff around the exploit:
artifacts/root_cause/data_collector/iter_4/storage/1/0xb8919522331c59f5c16bdfaa6e03a91f62/. - Exploit tx metadata, trace, and USDC balance diffs:
artifacts/root_cause/seed/1/0x7d2296bcb936aa5e2397ddf8ccba59f54a178c3901666b49291d880369dbcf31/. - Helper deploy tx metadata and trace:
artifacts/root_cause/seed/1/0xacfcaa8e1c482148f9f2d592c78ca7a27934c7333dab31978ed0aef333a28ab6/.