All incidents

Pool16 lend/redeem accounting bug drains USDC without HOME backing

Share
Mar 05, 2022 12:46 UTCAttackLoss: 976,925 USDCManually checked1 exploit txWindow: Atomic

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:

  • poolLent and poolBorrowed are 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 _balances mapping, with _balances stored starting at slot 0x33.

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 holds 978,497.348951 USDC.
  • HOME totalSupply at slot 0x52 is 0.
  • _accrued at slot 0x60 is 0.
  • poolLent at slot 0x68 is 6,667,864,245,841.
  • poolBorrowed at slot 0x69 is 5,689,859,839,693.
  • loanCount at slots 0x6b and 0x6c is 28.
  • The HOME _balances mapping entries for the helper 0x580cac65c2620d194371ef29eb887a7d8dcc91bf and the adversary EOA 0x7c42f2a7d9ad01294ecef9be1e38272c84607593 are 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) and redeem(uint256) are implemented to read and write only the legacy poolLent and poolBorrowed storage slots and to transfer USDC, without touching HOME totalSupply or the _balances mapping.
  • This means the amount of USDC transferred in a redemption is computed as a pure function of amountHome (a caller‑controlled parameter) and the internal poolLent/poolBorrowed values, with no check that the caller owns any HOME or that totalSupply is 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:

  • A be Pool16’s USDC balance.
  • S be HOME totalSupply (slot 0x52).
  • L be poolLent (slot 0x68).
  • B be poolBorrowed (slot 0x69).
  • bal[x] be the HOME balance of address x (_balances mapping at slot 0x33).

For any redemption of amountHome from address x, the intended invariant is:

  • amountHome <= bal[x],
  • S > 0, and
  • the USDC paid out to x must be limited so that A, L, B, and S remain mutually consistent, with no USDC redeemable when S = 0 and all bal[*] = 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 poolLent from slot 0x68, performs arithmetic combining amountUsdc with the prior poolLent value, and writes a new value back to slot 0x68 with SSTORE.
  • A function identified as redeem(uint256 amountHome):
    • Reads poolLent (slot 0x68) and poolBorrowed (slot 0x69) into local variables.
    • Applies an internal formula involving amountHome, L, and B to compute a USDC payout and an updated L'.
    • Writes L' back to slot 0x68.
    • Calls the USDC token’s transfer to send USDC from Pool16 to msg.sender.

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 _balances entries 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.sender for all lend(uint256) and redeem(uint256) delegatecalls to the Pool16 implementation in the exploit transaction.

Stage 1: Helper Contract Deployment

  • Tx: 0xacfcaa8e1c482148f9f2d592c78ca7a27934c7333dab31978ed0aef333a28ab6 (block 14326928).
  • 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.
    • No ERC20 balances change; the EOA pays only native gas.
    • The transaction is fully permissionless: any EOA can deploy an equivalent helper.

Stage 2: Flash Swap and Lend/Redeem Exploit

  • Tx: 0x7d2296bcb936aa5e2397ddf8ccba59f54a178c3901666b49291d880369dbcf31 (block 14326932).
  • From: 0x7c42f2a7d9ad01294ecef9be1e38272c84607593.
  • To: helper 0x580cac65c2620d194371ef29eb887a7d8dcc91bf with calldata 0xdfcbd40f…0005c8cd8a7000.
  • Mechanism: UniswapV2 USDC/ETH flash swap plus delegatecalls into Pool16.

Execution flow (from trace.cast.log and balance diffs):

  1. The helper initiates a USDC/ETH flash swap on the UniswapV2 pair 0xb4e16…c9dc, borrowing USDC.
  2. Via delegatecall into 0x781ad73f140815763d9a4d4752daf9203361d07d, the helper calls lend(uint256) three times with amountUsdc = 2,120,000,000,000 each, depositing USDC from the helper into Pool16 and incrementing poolLent.
  3. The helper then calls redeem(uint256) once with amountHome = 8,465,943,180,104, again via delegatecall into the Pool16 implementation.
  4. redeem(uint256) computes a USDC payout based solely on amountHome, poolLent, and poolBorrowed, writes the updated poolLent back to slot 0x68, and transfers 976,924.997605 USDC from Pool16 to the helper.
  5. The helper repays the UniswapV2 flash swap, leaving the adversary EOA with 957,208.997605 USDC net.

The FiatTokenV2_2 balance_diff.json for this transaction summarizes the final ERC20 balances:

  • Pool16 proxy (0xb891…f62): delta = -976,924.997605 USDC.
  • UniswapV2 USDC/ETH pair (0xb4e16…c9dc): delta = +19,716.000000 USDC.
  • Adversary EOA (0x7c42…7593): delta = +957,208.997605 USDC.

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.997605 USDC.
  • The adversary EOA’s USDC balance increases by 957,208.997605 USDC.
  • The UniswapV2 USDC/ETH pair’s USDC balance increases by 19,716.000000 USDC (the flash swap fee/price impact).
  • HOME totalSupply and 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): 0xb8919522331c59f5c16bdfaa6e03a91f62 on Ethereum mainnet.
  • Pool16 implementation contract: 0x781ad73f140815763d9a4d4752daf9203361d07d.
  • USDC (FiatTokenV2_2): 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.
  • UniswapV2 USDC/ETH pair: 0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc.
  • Adversary EOA: 0x7c42f2a7d9ad01294ecef9be1e38272c84607593.
  • Helper contract: 0x580cac65c2620d194371ef29eb887a7d8dcc91bf.
  • Exploit transaction: 0x7d2296bcb936aa5e2397ddf8ccba59f54a178c3901666b49291d880369dbcf31 (block 14326932).
  • Helper deployment transaction: 0xacfcaa8e1c482148f9f2d592c78ca7a27934c7333dab31978ed0aef333a28ab6 (block 14326928).
  • 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/.