Reproduced Exploit

Fence tranche pool — the UTC rollover credits a scheduled principal payment that was never paid, and draw pays it again

A Valinor Digital / Fence Finance tranche pool on Avalanche lets anyone open a facility, name themselves borrower, fund principal, and draw it back. Cycle 1 is a wash: Aave V3 flash-lends 1,013,353.611686 USDC, the new facility is funded with it, draw returns it, and the flash is repaid with a 506.…

Oct 2026•Avalanche•untagged•10 min read

Loss

1,013,353.611686 USDC (1013353611686), the pool's entire balance, on the second draw output.txt:357, output.t…

Chain

Avalanche

Category

untagged

Date

Oct 2026

Source

DeFiHackLabs

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2026-10-FenceTranchePool_exp in the evm-hack-registry mirror. Upstream DeFiHackLabs PoC: src/test/…/FenceTranchePool_exp.sol.


Vulnerability classes: vuln/logic · vuln/defi · vuln/input-validation Reproduction: the PoC compiles and runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Offline pass. anvil_state.json is present. The trace forks <rpc url> at block 98,731,442 output.txt:362. [PASS] testExploit and [PASS] testSecondDrawRevertsWithoutRollover. Suite: 2 passed in 23.94ms output.txt:755.


Key info#

Loss1,013,353.611686 USDC (1013353611686), the pool's entire balance, on the second draw output.txt:357, output.txt:546. Pool balance after is 0 output.txt:358
Attacker's own cashAave V3 premium 506.676806 USDC (506676806). FLASHLOAN_PREMIUM_TOTAL is 5 output.txt:384. The premium is transferred in from aUSDC holder 0x625E7708…, not minted output.txt:387. It is consumed repaying the flash. Borrower USDC after cycle 1 is 0 output.txt:531
Vulnerable contractTranche pool 0xE25CB545Bdd47a8Ec2d08001cb5661B00D47621a. UNVERIFIED. Draw selector 0x8e27c105. sources/ is empty
Facility / strategy0x0e710A20376ae1e18316B0A270889d665a6918b2. Read during the second draw via 0xe44d0797 output.txt:539
Attacker EOA (historical)0x6ff1c3ffa622e1062168f3497a275be9cc2b1246
Historical helper0xf09dcf4ede2a88bd316ff355dfc333a281a57805
This PoCAttacker 0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e output.txt:374. Fresh Borrower 0x959951c51b3e4B4eaa55a13D1d761e14Ad0A1d6a output.txt:379
Attack txsFirst draw 0x19ab145e7f771549ca2f1da4b1768d7f728834015b2480ddb5be19c9769899fd (2026-10-27 23:56:41 UTC). Second draw 0x3f6b23292a2e9c2956c7ac56d0b1f79b0271b49edec2156d2675ddf1de058c77 (2026-10-28 00:00:03 UTC)
Chain / block / dateAvalanche / fork block 98,731,442 = 2026-10-27 23:56:40 UTC, day index 20,753 output.txt:362. Warp to 1,793,145,603 = 2026-10-28 00:00:03 UTC, day index 20,754 output.txt:533
CompilerPool unverified. PoC pragma ^0.8.13, trace compiled with solc 0.8.36 output.txt:1. Selectors are not in the public 4byte databases, per the test
Bug classAt the UTC day boundary the pool subtracts the scheduled principal from outstanding principal without pulling USDC. draw allows requested <= funded - outstanding, so the phantom reduction restores a draw that was already taken

TL;DR#

A Valinor Digital / Fence Finance tranche pool on Avalanche lets anyone open a facility, name themselves borrower, fund principal, and draw it back. Cycle 1 is a wash: Aave V3 flash-lends 1,013,353.611686 USDC, the new facility is funded with it, draw returns it, and the flash is repaid with a 506.676806 USDC premium the attacker supplied. Pool balance is unchanged output.txt:525. Outstanding principal now equals funded principal, so a second draw of the same size must fail. It does fail if time does not move output.txt:744.

vm.warp to the next UTC midnight makes the same draw (0x8e27c105, identical arguments) transfer 1,013,353.611686 USDC from the pool to the borrower output.txt:548. No second flash, no repayment, no role. A storage word on the pool changes its low 32 bits from 0x6adfe980 (2026-10-27 00:00:00 UTC) to 0x6ae13b00 (2026-10-28 00:00:00 UTC), which is the SCHEDULE_DAY the finalize call set output.txt:558. The pool treated that boundary as "principal was paid".

Background#

The pool is unverified on Snowtrace. The PoC does not guess function names. It sends the selectors captured from the two historical transactions and ABI-encodes typed arguments so the calldata matches those transactions. The test says the typed encoding is byte-for-byte the on-chain calldata. Human-readable names are unknown.

Selectors, from test/FenceTranchePool_exp.sol:

StepSelectorRole in the test
Open facility0xb1f70fbcnames the attacker contract as borrower
Configure0x200634bapasses the draw amount
Fund principal0xcec66073transferFrom borrower to pool
Finalize0x2d02d600sets schedule day 0x6ae13b00
Draw0x8e27c105same arguments both times

The facility struct is the same on every call: strategy 0x0e710A20…, borrower and recipient = the new contract, asset = USDC 0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E, decimals 6, two scale fields of 1e18. The pool calls back into the borrower (0x717505a9 is visible on the second draw output.txt:544). The borrower fallback returns 1. The test says the historical helper did the same for 0xe9e6e950, 0x85b40b16, and 0x717505a9.

Aave V3 pool is 0x794a61358D6845594F94dc1DB02A252b5b4814aD. Premium 5 / 10,000. The test computes (amount * 5 + 5000) / 10000 = 506,676,806 wei of USDC, and the trace transfers that exact figure output.txt:387. Repay is amount plus premium = 1,013,860.288492 USDC (1013860288492) output.txt:502. 1,013,353.611686 + 506.676806 = 1,013,860.288492.

The vulnerable code#

RECONSTRUCTED from the @Summary in test/FenceTranchePool_exp.sol and the trace. The pool source is not in sources/.

SOLIDITY
// unverified pool 0xE25CB545…  draw selector 0x8e27c105
function draw(uint256 amount, Facility[] facilities, address asset, ...) external {
    // on a new UTC day, settlement runs first:
    // outstanding -= scheduledPrincipal;   // no USDC is pulled
    // the schedule timestamp advances by 86400
    uint256 capacity = fundedPrincipal - outstandingPrincipal;
    require(amount <= capacity);
    IERC20(asset).transfer(recipient, amount);
    outstandingPrincipal += amount;
}

Evidence that is actually in the trace, not a decompile:

  • Before the warp, a second 0x8e27c105 reverts with data 15 output.txt:744. Pool balance stays 1,013,353.611686 output.txt:751. So draw capacity is not unlimited. The first cycle really did consume it.
  • After warp(1793145603) the same calldata succeeds output.txt:533, output.txt:538.
  • USDC moves pool → borrower for the full 1,013,353.611686 output.txt:548. The borrower did not transfer USDC in on this call.
  • The packed word at storage slot 0xada50131…9e7e keeps amount 0xebf09501a6 (which is 1,013,353,611,686) and changes the timestamp from 0x6adfe980 to 0x6ae13b00 output.txt:558. Difference is 86,400 seconds. That is the settlement day rolling forward inside the draining draw.

Root cause#

  1. Settlement credits a payment the borrower did not make. The schedule says principal is due at 2026-10-28 00:00:00 UTC (SCHEDULE_DAY = 0x6ae13b00). Crossing that timestamp reduces outstanding principal. Nothing in the second draw's trace is a USDC transferFrom of the borrower. The only USDC transfer is the pool paying the borrower.
  2. draw trusts funded - outstanding. After cycle 1 those are equal, so capacity is zero and the negative control reverts. After the phantom reduction, outstanding is low again and the same amount fits. The pool then pays with its idle USDC, which was 1,013,353.611686 before and after cycle 1 output.txt:370, output.txt:525 and 0 after cycle 2 output.txt:571.
  3. The borrower path is permissionless. A fresh EOA deploys a fresh contract, passes its own address as borrower and recipient, and the pool accepts 0xb1f70fbc / 0x200634ba / 0xcec66073 / 0x2d02d600. No admin transaction appears in the trace. The only input that is not the flash principal is the 506.676806 USDC premium.
  4. The callback that returns 1 is enough. The pool asks the borrower to approve the step (0x717505a9 on the draining draw). It does not ask an oracle, a whitelist, or a repayment token.

Preconditions#

  • The pool holds 1,013,353.611686 USDC at fork block 98,731,442 output.txt:370. The draw amount is that balance. A smaller idle balance would cap the theft at whatever is idle. The bug still restores draw capacity.
  • Aave V3 will flash-lend that amount of USDC, and the attacker can pay 5 bps. Premium 506.676806 USDC output.txt:511.
  • The attacker can set the facility's schedule to the next UTC midnight (0x6ae13b00) and can warp, or wait, across it. Without the warp the second draw reverts output.txt:744.
  • Opening a facility does not require a role. The PoC uses makeAddr("attacker") and new Borrower().
  • The borrower must answer the pool's callbacks with true. The fallback does.

Attack walkthrough (with on-chain numbers from the trace)#

#ActionAmountTrace
0Fork one block before the historical first draw98,731,442. Pool USDC = 1,013,353.611686output.txt:362, output.txt:370
1Deploy Borrower from a fresh EOAattacker 0x9dF0C6…, borrower 0x959951…output.txt:374, output.txt:379
2Read Aave premium and fund it from aUSDCpremium parameter 5; transfer 506.676806 USDCoutput.txt:381, output.txt:384, output.txt:387
3flashLoanSimple of the draw amount1,013,353.611686 USDC to the borroweroutput.txt:398, output.txt:411
4Inside the callback: open, configure, fund, finalize, draw. Then approve amount + premium back to Aaverepay 1,013,860.288492 USDC (principal 1,013,353.611686 + premium 506.676806)output.txt:502, output.txt:511
5Pool and borrower after cycle 1pool still 1,013,353.611686; borrower 0output.txt:525, output.txt:531
6Warp across UTC midnight1,793,145,603 = 2026-10-28 00:00:03 UTCoutput.txt:533
7drawAgain → 0x8e27c105pool transfers 1,013,353.611686 USDC to the borrower. Schedule word moves 0x6adfe980 → 0x6ae13b00output.txt:537, output.txt:548, output.txt:558
8Logs and assertsdrained 1013353611686; pool 0output.txt:357, output.txt:565, output.txt:571
9Negative control, no warp0x8e27c105 reverts with 15. Pool still 1,013,353.611686output.txt:744, output.txt:751

USDC identity

PieceUSDC
Idle in the pool before and after cycle 11,013,353.611686
Flash principal1,013,353.611686
Premium, attacker capital506.676806
Repaid to Aave1,013,860.288492
Taken on the second draw1,013,353.611686
Left in the pool0

1,013,353.611686 + 506.676806 = 1,013,860.288492. The premium is spent. The 1,013,353.611686 that ends on the borrower is the pool's cash, not the flash. [PASS] testExploit at output.txt:355. [PASS] testSecondDrawRevertsWithoutRollover at output.txt:583.

Diagrams#

sequenceDiagram participant A as Borrower participant V as Aave V3 participant P as Tranche pool A->>V: flashLoanSimple 1013353.611686 USDC V->>A: USDC A->>P: open, configure, fund, finalize schedule 2026-10-28 A->>P: draw 1013353.611686 P->>A: USDC A->>V: repay 1013860.288492 USDC Note over P: outstanding equals funded, pool unchanged Note over A: warp to 2026-10-28 00:00:03 UTC A->>P: draw the same amount Note over P: settlement reduces outstanding, no USDC in P->>A: 1013353.611686 USDC from the pool

Remediation#

  1. Do not reduce outstanding principal until USDC is in the pool. Settlement should transferFrom the borrower for the scheduled amount, measure the received delta, and reduce outstanding by that delta only. A timestamp crossing midnight is not a payment.
  2. draw capacity must use cash that was funded and not yet drawn, minus what was actually repaid. Recomputing funded - outstanding after a book entry that no token transfer supports will pay the idle balance to the borrower. The negative control is the specification: the second draw reverts until a real repayment lands.
  3. Do not let the borrower pick the schedule timestamp that triggers the credit. 0x2d02d600 accepted 0x6ae13b00 from the attacker. A due date that restores draw capacity has to be set by the lender, and it has to arm a pull, not a forgiveness.
  4. Verify the pool. The selectors are not in public databases and sources/ is empty. The functions that move 1,013,353.611686 USDC are the ones that were not verified.

How to reproduce#

Offline, from the committed state. _shared/run_poc.sh loads anvil_state.json, starts anvil, and rewrites the test's http://127.0.0.1:8551 URL.

BASH
# from the registry root
_shared/run_poc.sh 2026-10-FenceTranchePool_exp -vvvvv
  • Chain / fork: Avalanche, block 98,731,442. Warp to timestamp 1,793,145,603.
  • Committed result in output.txt (finished in 23.94ms):
CODE
[PASS] testExploit()
  USDC drained from pool : 1013353611686
  Pool USDC balance after : 0
[PASS] testSecondDrawRevertsWithoutRollover()
Suite result: ok. 2 passed; 0 failed; 0 skipped

Reference: the two draw transactions in the test header — 0x19ab145e… and 0x3f6b2329….

References#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.