Reproduced Exploit

BiFi USDT — one idle gap of 264,060 blocks is reapplied at a live utilization the attacker sets

The BiFi USDT market had not been touched since block 25,946,196. Rolled to the profit block, that is 264,060 blocks, about 36.7 days output.txt:29–output.txt:32. BiFi accrues interest lazily. The first action in a block writes block.number - lastUpdatedBlock into storage as inactiveActionDelta and…

Oct 2026•Ethereum•logic•11 min read

Loss

165,575.149730 USDT surplus after a 40,499,999.999997 USDT Morpho flash loan is repaid in full, swapped to 68…

Chain

Ethereum

Category

logic

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-BiFi_exp in the evm-hack-registry mirror. Upstream DeFiHackLabs PoC: src/test/…/BiFi_exp.sol.


Vulnerability classes: vuln/logic/reward-calculation · vuln/logic/incorrect-state-transition · vuln/logic/missing-check Reproduction: offline or online. This folder now ships an anvil_state.json (703 KB), so the offline harness runs the PoC with no network. Passing archival-fork trace: output.txt. Folder: (.). Verified source: sources/UsdtHandlerProxy_808c3b, sources/TokenHandler_47be3d, sources/interestModel_29ecab.


Key info#

Loss165,575.149730 USDT surplus after a 40,499,999.999997 USDT Morpho flash loan is repaid in full, swapped to 68.057464083126734539 WETH output.txt:19176, output.txt:19220. On-chain the same surplus was 165,574.514633 USDT and 68.057203111718789216 WETH. The test band is 0.01 WETH
Vulnerable contractBiFi UsdtHandlerProxy 0x808C3bA97268dbF9695b1EC10729E09c7e67A9E3. Logic TokenHandler 0x47Be3d1DEF039Bd85e7570864bB5148E7491A65F. Interest model 0x29Ecabde2C57B6D4183f915eBca1A25804A60dB1. Data storage 0xFdC7D058beDE981eA865Fb64D06382A2206A1C42
Attacker EOAOriginal deployer 0x7c78A049fFf71609990017B58A959E926E6ec4C6. Profit was taken by MEV bot 0xC0ffeEBABE5D496B2DDE509f9fa189C25cF29671 (c0ffeebabe.eth)
Attack contractBot helper 0xe08d97e151473a848c3d9ca3f323cb720472d015. PoC deploys BiFiExploiter and does not replay either EOA's bytecode
Attack txDeploy 0x15b47e2c0daf723dec64c75b435079d3a1f8a6058b21b9a64dda4844e877a034, block 26,210,255. Profit 0x756c68055af7a0630aa0b56891ba4de14c56c39cbebe99e47eeb4728f4d6ad14, block 26,210,256
Chain / block / dateEthereum mainnet (chain id 1) / fork parent 26,210,254, vm.roll to profit block 26,210,256 / 17 October 2026, 05:42:47 UTC (trace deadline 1792215767)
CompilerSolidity v0.6.12+commit.27d51765, optimizer on, 200 runs, on the proxy, the handler, and the interest model (_meta.json). Proxy implementation is the handler
Bug classThe first action after a long idle stores the entire block gap, and every later action in that block multiplies the frozen per-block exchange rate by that full gap at the market's current deposit and borrow totals

TL;DR#

The BiFi USDT market had not been touched since block 25,946,196. Rolled to the profit block, that is 264,060 blocks, about 36.7 days output.txt:29–output.txt:32. BiFi accrues interest lazily. The first action in a block writes block.number - lastUpdatedBlock into storage as inactiveActionDelta and freezes the per-block exchange rate. Later actions in the same block see a zero new gap, so they do not overwrite that stored delta. interestModel.getInterestAmount then does globalEXR = (perBlockRate * delta + 1e18) * frozenActionEXR using the rate implied by the totals at the moment of the call.

Those totals are permissionless. deposit, borrow, repay, and withdraw on the proxy are public delegatecalls with no role. The attacker flash-borrows 40,499,999.999997 USDT from Morpho Blue (fee 0), deposits it, borrows to raise utilization, and repay/withdraws so each utilization step re-credits deposit interest for the whole 264,060-block gap rather than for one block. Two such cycles leave 165,575.149730 USDT after the flash loan is repaid output.txt:19176. Uniswap v3 (fee 3000) turns that into 68.057464083126734539 WETH output.txt:19220. [PASS] testExploit() gas 15,539,012 output.txt:4.

The original attacker deployed in block 26,210,255. The bot c0ffeebabe took the profit one block later by calling the same public functions. This PoC reconstructs the mechanism. It does not replay either transaction's calldata.

Background — what BiFi accrues#

BiFi is a pooled lending market. The USDT handler is a proxy. Users pass amounts in 18-decimal "unified" units. The handler converts to 6-decimal USDT when it moves tokens. Interest is an exchange-rate index, not a separate reward token. TokenHandler._updateInterestAmount asks the interest model for a deposit delta and a borrow delta, writes the new global index onto the user, and adds the delta to the user's unified balance.

The index is not updated every block. _checkFirstAction compares block.number with lastUpdatedBlock. If they differ, it stores that difference and calls syncActionEXR, which freezes the per-block rate for the rest of the block. Every later call in the block uses the stored gap and the frozen rate, but it recomputes the supply and borrow rates from the live totals.

The market had been idle. getLastUpdatedBlock() is 25,946,196 output.txt:29. After vm.roll(26_210_256) the gap assert is 264,060 output.txt:32. The header's "264,059 blocks / attack block 26,210,255" is the deploy block. This trace charges the profit block, one block later, and setBlocks(26210256, 264060) is what storage records output.txt:95.

The vulnerable code#

The whole idle gap is stored once per block#

From verified TokenHandler:

SOLIDITY
function _checkFirstAction() internal returns (bool) {
    IMarketHandlerDataStorage _handlerDataStorage = handlerDataStorage;
    uint256 lastUpdatedBlock = _handlerDataStorage.getLastUpdatedBlock();
    uint256 currentBlockNumber = _blockContext();
    uint256 blockDelta = sub(currentBlockNumber, lastUpdatedBlock);
    if (blockDelta > 0) {
        _handlerDataStorage.setBlocks(currentBlockNumber, blockDelta);
        _handlerDataStorage.syncActionEXR();
        return true;
    }
    return false;
}

setBlocks(26210256, 264060) runs on the first deposit output.txt:95. After that, lastUpdatedBlock is the current block, so blockDelta is 0 and the stored 264,060 is left in place for the rest of the transaction.

That gap is applied at whatever utilization the caller has just set#

From verified interestModel._getInterestAmount / _calcInterestAmount / _getNewGlobalEXR:

SOLIDITY
uint256 delta = handlerDataStorage.getInactiveActionDelta();
(actionDepositEXR, actionBorrowEXR) = handlerDataStorage.getActionEXR();
return _calcInterestAmount(handlerDataStorageAddr, userAddr, delta, actionDepositEXR, actionBorrowEXR);

// inside _calcInterestAmount
(SIR, BIR) = _getSIRandBIRonBlock(depositTotalAmount, borrowTotalAmount);
globalDepositEXR = _getNewGlobalEXR(actionDepositEXR, SIR, delta);

function _getNewGlobalEXR(uint256 actionEXR, uint256 interestRate, uint256 delta) internal pure returns (uint256) {
    return interestRate.mul(delta).add(unifiedPoint).unifiedMul(actionEXR);
}

delta is not divided down to one block and it is not capped. SIR comes from the current totals (borrow / deposit inside _getUtilizationRate, then the jump-rate BIR, then SIR = util * BIR * spread). _updateInterestAmount then sets the user's index to this new global. The next call with the same SIR pays nothing more, because userEXR already equals globalEXR. A higher SIR produces a new global of (newSIR * 264060 + 1e18) * frozenActionEXR. The user is credited userDeposit * (newGlobal - userEXR), which is the full idle gap times the rate increase, not a single block of interest.

The proxy exposes this with no gate. deposit, withdraw, borrow are public and delegatecall the handler (UsdtHandlerProxy.sol). migration is onlyOwner. The four user actions are not.

Root cause — why it was possible#

  1. inactiveActionDelta stores the raw idle gap. The first action writes 264,060. Nothing amortizes that into per-block updates. A 36-day silence becomes one multiplier.
  2. The frozen rate and the live rate are different inputs. actionEXR is snapshotted once. SIR/BIR are recomputed from totals on every call. Moving utilization after the snapshot changes the index by rateDelta * fullGap, not rateDelta * 1.
  3. The user's index is updated, so the gap can be harvested again at the next rate. After each credit, userEXR = globalEXR. The next borrow that raises utilization opens a new full-gap delta. The repay/withdraw loop is what turns those credits into USDT.
  4. Every step is a public function. No keeper has to accrue the market, and the attacker does not need a stale position of their own. The idle state is just lastUpdatedBlock.

Preconditions#

  • The USDT market's lastUpdatedBlock is far behind the current block. Here, 25,946,196 versus 26,210,256 output.txt:29, output.txt:32.
  • deposit / borrow / repay / withdraw stay callable by any address. They are.
  • A flash loan large enough to dominate utilization. Morpho Blue lent 40,499,999.999997 USDT at fee 0 output.txt:40. The loan is repaid in full output.txt:19167.
  • Same-block execution. The stored gap is only sticky inside the block that first touched the market. A later block would store a fresh delta of 1.
  • No admin, oracle, or signature. The MEV bot won by ordering the same calls, not by a special role.

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

setUp forks https://eth.drpc.org at 26,210,254 and rolls to 26,210,256. The test contract's WETH balance before the exploit log is 0.202213274263176443 output.txt:6. That baseline is the balance-log helper's starting balance, not profit. Profit is the delta.

StepCallTrace
1Read idle stategetLastUpdatedBlock 25,946,196 output.txt:29. Gap assert 264,060 output.txt:32
2Morpho flashLoan40,499,999.999997 USDT to BiFiExploiter output.txt:39–output.txt:42
3Five deposits, unified amounts, USDT actually pulled121,257.485029, 484,544.910179, 1,795,238.892215, 7,238,802.155389, 30,860,156.557185 output.txt:136, output.txt:201, output.txt:266, output.txt:331, output.txt:396. Sum 40,499,999.999997 USDT
4First of those depositssetBlocks(26210256, 264060) output.txt:95. This freezes the gap
5deposit(0)settles interest on the new position output.txt:407
6borrow × 20each 1,498,500 USDT unified (1.4985e24) output.txt:466. Utilization, and therefore SIR, steps up
7repay(1,498,500) / withdraw(2,025,000) × 20each withdraw pulls deposit principal plus the full-gap interest just credited at the new rate
8repay / withdraw of 40,500,000 unifiedunwinds the flash principal
9Repeat 3–8second cycle, same frozen gap, same block
10Final withdraw4,463.571825 unified USDT, the residual credited interest
11Approve Morpho and repay40,499,999.999997 USDT leaves the exploiter output.txt:19167. Flash loan nets to zero output.txt:19218
12exactInputSingle USDT to WETH, fee 3000amountIn 165,575.149730 USDT output.txt:19182. Pool pays 68.057464083126734539 WETH output.txt:19184, output.txt:19203

Accounting:

TEXT
USDT after repay, before swap     165,575.149730     [output.txt:19176]
WETH bought                        68.057464083126734539
WETH before (helper baseline)       0.202213274263176443   [output.txt:6]
WETH after                         68.259677357389910982   [output.txt:8]
delta                              68.057464083126734539   [output.txt:7]
on-chain WETH the test names      68.057203111718789216
absolute error                     0.000260971408545323 WETH
assert band                        0.01 WETH               [output.txt:19221]

The USDT surplus is 0.635097 USDT above the header's on-chain 165,574.514633 USDT. The header attributes that to intra-block drift from forking the parent and rolling forward. The flash principal is not part of the profit. Gas of the passing test is 15,539,012 output.txt:4. Suite result output.txt:19234.

Diagrams#

sequenceDiagram participant E as BiFiExploiter participant M as Morpho Blue participant P as UsdtHandlerProxy participant S as DataStorage participant I as interestModel E->>M: flashLoan 40499999.999997 USDT M-->>E: USDT, fee 0 E->>P: first deposit P->>S: setBlocks 26210256, delta 264060 E->>P: borrow x20 to raise utilization P->>I: getInterestAmount at live totals I-->>P: globalEXR uses SIR times 264060 E->>P: repay and withdraw x20, two cycles P-->>E: inflated deposit interest as USDT E->>M: repay 40499999.999997 USDT E->>E: swap surplus 165575.149730 USDT E-->>E: 68.057464083126734539 WETH

Remediation#

  1. Cap inactiveActionDelta at 1, or accrue it in a loop that does not let the caller change totals between slices. A 264,060-block gap must not be one multiplier on a rate the caller just set. If gas makes a loop impractical, store the index as a function of time that does not reread live totals for the historical part.
  2. Snapshot utilization together with actionEXR. SIR/BIR for the idle gap have to be the rates that were in force during the idle period, or a rate computed before the caller's deposit and borrow. Recomputing SIR from post-borrow totals and multiplying by the stored gap is the profit.
  3. Apply the idle gap once per user per update, against the snapshotted rate. After userEXR is set to globalEXR, a later call in the same block must not open a new rateDelta * fullGap just because borrowTotalAmount changed. Same-block updates should use delta = 0 or a one-block delta.
  4. Do not leave lastUpdatedBlock stale for weeks without a keeper accrual. A permissionless accrue() that anyone can call is fine only if it uses the snapshotted rate. The bug is not that accrual is lazy. It is that the lazy update trusts the caller's own position size.
  5. The public surface can stay public. deposit and borrow are supposed to be open. Restricting them would not fix an interest index that reprices 36 days of interest inside one transaction.

How to reproduce#

This folder now ships an anvil_state.json (703 KB), so the offline harness runs the PoC with no network and passes:

BASH
_shared/run_poc.sh 2026-10-BiFi_exp -vvvvv
# replays against the committed anvil_state.json, no live node
# [PASS] testExploit()

The test can also fork a live archival node itself. setUp calls vm.createSelectFork("https://eth.drpc.org", 26_210_254) and then vm.roll(26_210_256). From this folder, with network access:

BASH
forge test --match-path test/BiFi_exp.sol -vvvvv

output.txt is the verbose trace of that online run. Expected tail:

CODE
[PASS] testExploit() (gas: 15539012)
Attacker Before exploit WETH Balance: 0.202213274263176443
WETH profit: 68.057464083126734539
Attacker After exploit WETH Balance: 68.259677357389910982
Suite result: ok. 1 passed; 0 failed; 0 skipped

The before-balance is the test contract's starting WETH, not an attacker deposit. Profit is the middle line.

Reference: profit tx 0x756c6805… (URL in test/BiFi_exp.sol). Deploy tx 0x15b47e2c…a034.

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.