Reproduced Exploit
bZx / Fulcrum `iToken` Exploit — Empty-Pool Share-Price Inflation (ERC4626-style Donation Attack)
A Fulcrum iToken is a yield-bearing lending share, priced as tokenPrice = underlyingHeld * 1e18 / totalSupply (LoanTokenLogicStandard.sol:848-860). When totalSupply and the underlying balance are tiny, an attacker can mint a
Loss
~$208K — drained the iETH, iWBTC (and other) Fulcrum lending pools
Chain
Ethereum
Category
Arithmetic / Overflow
Date
Dec 2023
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: 2023-12-bZx_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/bZx_exp.sol.
Vulnerability classes: vuln/arithmetic/rounding · vuln/logic/state-update
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not whole-compile, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source (iToken logic): LoanTokenLogicStandard.sol.
Key info#
| Loss | ~$208K — drained the iETH, iWBTC (and other) Fulcrum lending pools |
| Vulnerable contract | iYFI iToken (LoanToken proxy → LoanTokenLogicStandard logic) — 0x7F3Fe9D492A9a60aEBb06d82cBa23c6F32CAd10b |
| Logic implementation | LoanTokenLogicWeth / LoanTokenLogicStandard — 0x9712dC729916e154Daa327C36ad1b9f8E069fBA1 |
| Drained pools | iETH 0xB983E01458529665007fF7E0CDdeCDB74B967Eb6, iWBTC 0x2ffa85f655752fB2aCB210287c60b9ef335f5b6E |
| bZx protocol core | bZxProtocol 0xD8Ee69652E4e4838f2531732a46d1f7F584F0b7f (delegates to LoanOpenings / LoanMaintenance) |
| Attacker EOA | 0x5a7c7eb8d13a53d42a15d2b1d1b694ccc5141ea5 |
| Attacker contract | 0x03b7Bb750A974e0BD34795013F66B669f4110e54 |
| Attack tx 1 | 0x0fc5c0d41e5506fdb9434fab4815a4ff671afc834e47a533b3bed7182ece73b0 |
| Attack tx 2 | 0xb072f2e88058c147d8ff643694b43a42e36525b7173ce1daf76e6c06170b0e77 |
| Chain / block / date | Ethereum mainnet / 18,695,728 / Dec 1, 2023 |
| Compiler | iToken logic Solidity v0.5.17; PoC v0.8.10 (fork evm_version = cancun) |
| Bug class | Empty-vault share-price inflation via direct asset donation (ERC4626-style first-depositor / rounding attack) |
TL;DR#
A Fulcrum iToken is a yield-bearing lending share, priced as
tokenPrice = underlyingHeld * 1e18 / totalSupply
(LoanTokenLogicStandard.sol:848-860).
When totalSupply and the underlying balance are tiny, an attacker can mint a
few wei of shares, then donate a large amount of the underlying directly to
the iToken contract. The share price explodes — in this attack one share went
from 1e18 to 3.872e36 (a ~3.8 billion× inflation).
bZx denominates loan collateral in the collateral token (here iYFI) and
converts it through IPriceFeeds.queryRate, which for an iToken reads exactly
that inflated tokenPrice. So 5 wei of iYFI is now "worth" enough to
collateralize an entire lending pool's reserves. The attacker:
- Flash-borrows
~19.36 YFIfrom the SushiSwap-styleYFI/WETHpair. - Burns the attack-contract's pre-existing
5weiiYFI, emptying theiYFIpool to0underlying /0shares (a clean "first depositor" state). - Mints
5weiiYFIfor5weiYFI(price =1e18), then donates all19.36 YFIstraight into theiYFIcontract — inflating its share price to3.872e36. - Opens loans against the now-overvalued
5weiiYFIcollateral, borrowing 38.09 WETH out ofiETHand 0.2265 WBTC out ofiWBTC. - Because the collateral is so over-valued,
withdrawCollateralreturns all5weiiYFIback to the attacker (maxDrawdown == full collateral), then the attacker burns those5wei to reclaim the donated19.36 YFI. - Repays the YFI flash loan and keeps the borrowed ETH + WBTC.
The single PoC tx nets the attacker ~42.03 WETH of profit (the borrowed ETH plus the WBTC swapped to WETH, minus the YFI flash-loan repayment). The real incident repeated this across multiple iToken pools for a total of ~$208K.
Background — what a Fulcrum iToken is#
bZx's Fulcrum product issues iTokens (iETH, iWBTC, iYFI, …). An iToken
is a lending-pool share: you mint it by depositing the underlying asset, you
burn it to redeem the underlying plus accrued interest, and the underlying is
lent out to margin-traders/borrowers via the shared bZxProtocol core.
The iToken contract (0x7F3Fe9…) is a thin proxy
that delegatecalls into LoanTokenLogicWeth /
LoanTokenLogicStandard (0x9712dC…).
Borrowing happens by calling iToken.borrow(...), which forwards into the
bZx core's LoanOpenings.borrowOrTradeFromPool
(LoanOpenings.sol:34);
collateral is later released through bZxProtocol.withdrawCollateral
→ LoanMaintenance.withdrawCollateral
(LoanMaintenance.sol:104).
The exact iToken state at the fork block, observed in the trace, is what makes the attack work:
| Fact (from trace) | Value |
|---|---|
iYFI underlying held (YFI.balanceOf(iYFI)) before attack | dust (5 wei after the warm-up burn) |
iYFI.totalSupply() after the burn | 0 |
Attacker's pre-positioned iYFI balance | 5 wei |
iETH redeemable WETH reserve | 38.089742649328258427 WETH |
iWBTC redeemable WBTC reserve | 0.22651422 WBTC (22,651,422 sats) |
| Chainlink YFI/USD used by price feed | 4044382180629397000 (4.04e18) |
The whole exploit hinges on the iToken pool being reducible to an essentially
empty vault (totalSupply ≈ 0, underlying ≈ 0), at which point its share price
is attacker-controllable by donation.
The vulnerable code#
1. Share price is underlying / supply, with no virtual offset#
// LoanTokenLogicStandard.sol
function _tokenPrice(uint256 assetSupply) internal view returns (uint256) {
uint256 totalTokenSupply = _totalSupply;
return totalTokenSupply != 0 ?
assetSupply
.mul(WEI_PRECISION) // * 1e18
.div(totalTokenSupply) // / totalSupply
: initialPrice; // 1e18 when supply == 0
}
(LoanTokenLogicStandard.sol:848-860)
assetSupply comes from _totalAssetSupply, which is just the contract's raw
underlying balance plus outstanding borrows
(:885-896).
A raw transfer of the underlying token into the contract increases
assetSupply with no corresponding totalSupply increase — the classic
ERC4626 donation hole. There is no minimum supply, no dead-shares lock, and no
virtual-asset/virtual-share offset.
2. mint / burn round shares against price with no protection#
function _mintToken(address receiver, uint256 depositAmount) internal ... {
require (depositAmount != 0, "17");
_settleInterest(0);
uint256 currentPrice = _tokenPrice(_totalAssetSupply(_totalAssetBorrowStored()));
mintAmount = depositAmount.mul(WEI_PRECISION).div(currentPrice); // deposit * 1e18 / price
...
_mint(receiver, mintAmount);
}
function _burnToken(uint256 burnAmount) internal ... {
...
uint256 currentPrice = _tokenPrice(_totalAssetSupply(_totalAssetBorrowStored()));
uint256 loanAmountOwed = burnAmount.mul(currentPrice).div(WEI_PRECISION); // burn * price / 1e18
...
}
(_mintToken :378-403, _burnToken :405-431)
Mint at price 1e18 gives 5 → 5 shares; after a 19.36e18 donation the price
is 3.872e36, so burning those same 5 shares pays back
5 * 3.872e36 / 1e18 = 19.36e18 — the full donation, recovered intact.
3. Loan collateral is valued through the same inflated iToken price#
// LoanOpenings.sol
function _getRequiredCollateral(
address loanToken, address collateralToken,
uint256 newPrincipal, uint256 marginAmount, bool isTorqueLoan
) internal view returns (uint256 collateralTokenAmount) {
if (loanToken == collateralToken) {
collateralTokenAmount = newPrincipal.mul(marginAmount).divCeil(WEI_PERCENT_PRECISION);
} else {
(uint256 sourceToDestRate, uint256 sourceToDestPrecision) =
IPriceFeeds(priceFeeds).queryRate(collateralToken, loanToken); // queries iYFI price!
if (sourceToDestRate != 0) {
collateralTokenAmount = newPrincipal
.mul(sourceToDestPrecision)
.mul(marginAmount)
.divCeil(sourceToDestRate * WEI_PERCENT_PRECISION); // huge rate ⇒ ≈ 0 → rounds to dust
}
}
...
}
With collateralToken = iYFI and its price inflated to 3.872e36, queryRate
returns a gigantic sourceToDestRate, so the required collateral for borrowing
the entire iETH pool divides down to just 5 wei of iYFI. The trace
shows the loan opening as LoanOpenData({ principal: 38.089 ETH, collateral: 5 }).
4. The over-valued collateral can be fully withdrawn again#
// LoanMaintenance.sol
function withdrawCollateral(bytes32 loanId, address receiver, uint256 withdrawAmount)
external nonReentrant pausable returns (uint256 actualWithdrawAmount)
{
...
uint256 maxDrawdown = IPriceFeeds(priceFeeds).getMaxDrawdown(
loanParamsLocal.loanToken, collateralToken,
loanLocal.principal, collateral, loanParamsLocal.maintenanceMargin
);
if (withdrawAmount > maxDrawdown) actualWithdrawAmount = maxDrawdown;
else actualWithdrawAmount = withdrawAmount;
collateral = collateral.sub(actualWithdrawAmount, "withdrawAmount too high");
loanLocal.collateral = collateral;
...
}
Because the 5 wei iYFI is "worth" billions of times the loan, the position
is wildly over-margined, so getMaxDrawdown returns the entire 5 wei
(trace: getMaxDrawdown(...) → 5). The attacker withdraws all collateral while
keeping the borrowed assets.
Root cause — why it was possible#
The fundamental flaw is the unbounded, donation-manipulable iToken share price combined with using that same price to value loan collateral:
- No virtual-share / dead-share protection on the vault.
_tokenPrice = underlying * 1e18 / totalSupplycan be inflated arbitrarily by transferring the underlying directly into the contract —totalSupplydoes not move. This is the textbook ERC4626 first-depositor / donation attack. The bZx code predates the now-standard mitigation (OpenZeppelin's virtual offset / minimum liquidity lock). - The pool could be reduced to an empty-vault state. The attacker held a
tiny
5-wei share position from before; burning it broughttotalSupplyand underlying to0, giving a clean, fully-controllable starting point. - Collateral is denominated in the collateral token and priced via that
token's own (manipulable) iToken price.
_getRequiredCollateralandgetMaxDrawdownboth callIPriceFeeds.queryRate(iYFI, loanToken), which internally readsiYFI.tokenPrice(). Inflating the iYFI price simultaneously (a) shrinks the collateral required to open a loan to dust, and (b) inflates the collateral value so all of it can be withdrawn afterwards. - Integer rounding in
divCeilcollapses the required collateral to5wei. EvendivCeilrounds up only to5because the inflated rate makes the true value sub-wei;5wei iYFI fully covers an38ETH loan.
In short: a vault-pricing rounding bug (donation inflation) is laundered through the lending protocol's collateral valuation, turning "shares I can make worth anything" into "loans collateralized by nothing."
Preconditions#
- The targeted iToken pool must be reducible to an empty / near-empty vault
(
totalSupply ≈ 0, underlying ≈ 0). The attacker achieved this by pre-holding a5-weiiYFIposition and burning it — see the warm-upburnat test/bZx_exp.sol:103. - Enough of the underlying (
YFI) to donate and inflate the price. The PoC sources this from a flash swap of theYFI/WETHpair (test/bZx_exp.sol:91-94), so no upfront capital is required. - The lending pools (
iETH,iWBTC) must hold redeemable reserves to borrow out —38.089 WETHand0.2265 WBTChere. iYFIis an accepted collateral token in the bZx protocol with a loan-pricing path throughIPriceFeeds.queryRatethat reads the iToken's owntokenPrice.
Attack walkthrough (with on-chain numbers from the trace)#
All figures below are taken directly from output.txt. The PoC reproduces "attack tx 1" (the iYFI-collateralized ETH+WBTC drain).
| # | Step | iYFI underlying (YFI) | iYFI totalSupply | iYFI price | Effect |
|---|---|---|---|---|---|
| 0 | Flash-swap 19.363816309062560431 YFI from YFI/WETH pair (10% of its 193.638… YFI reserve) | — | — | — | Free working capital, repaid at end. |
| 1 | Burn attacker's pre-held 5 wei iYFI (via vm.prank(origAttacker)) | 5 → 0 | 5 → 0 | — | Pool emptied to a clean first-depositor state. |
| 2 | Mint 5 wei iYFI for 5 wei YFI | 0 → 5 | 0 → 5 | 1e18 | Attacker holds 5 wei shares at par. |
| 3 | Donate 19.363816309062560431 YFI directly to iYFI | 5 → 19.363816309062560436 | 5 | 3.872763…e36 | Share price inflated ~3.8e18× / ~3.87 billion× over par. |
| 4 | Borrow ETH: iETH.borrow(principal = 38.089742649328258427 WETH, collateral = 5 wei iYFI) | — | — | 3.872e36 | Loan opens; LoanOpenData.collateral == 5. Unwrapped to ETH and rewrapped to WETH. |
| 5 | withdrawCollateral(ETH loan) → returns all 5 wei iYFI (maxDrawdown == 5) | — | — | — | Collateral reclaimed; ETH loan effectively unsecured. |
| 6 | Borrow WBTC: iWBTC.borrow(principal = 22,651,422 sats, collateral = 5 wei iYFI) | — | — | 3.872e36 | Drains the entire iWBTC reserve. |
| 7 | Swap 0.22651422 WBTC → 4.175637839221447271 WETH (SushiSwap) | — | — | — | WBTC monetized to WETH. |
| 8 | withdrawCollateral(WBTC loan) → returns the 5 wei iYFI again | — | — | — | Collateral reclaimed again. |
| 9 | Burn 5 wei iYFI → 19.363816309062560436 YFI back (price 3.872e36) | 19.36 → 0 | 5 → 0 | — | Donated YFI fully recovered. |
| 10 | Repay flash loan: swap WETH → exact YFI and return 19.422… YFI to the pair | — | — | — | Flash swap closed. |
Net at the end of uniswapV2Call, the attack contract holds the borrowed ETH +
the WETH from the WBTC swap, minus the WETH spent to buy back YFI for flash-loan
repayment.
Profit accounting (WETH)#
| Direction | Amount (WETH) |
|---|---|
Borrowed ETH from iETH (wrapped to WETH) | +38.089742649328258427 |
| WBTC drained → swapped to WETH | +4.175637839221447271 |
| Gross before flash-loan repay | +42.265380488549705698 |
WETH spent to buy back YFI for flash-loan repayment (swapTokensForExactTokens) | −0.238171969558556942 |
| Net WETH balance after attack | 42.027208518991148756 |
Logged in the trace:
Exploiter WETH balance before attack: 0.000000000000000000
Total underlying assets in the pool before deposit/mint: 0
Total shares before deposit/mint: 0
Total underlying assets in the pool after deposit/mint: 5
Total shares after deposit/mint: 5
Exploiter shares: 5
Exploiter WETH balance after attack: 42.027208518991148756
This PoC tx alone nets ~42.03 WETH (≈ $94K at the time). The real incident repeated the pattern across several iToken pools for a total reported loss of ~$208K.
Diagrams#
Sequence of the attack#
Vault state evolution (iYFI)#
Why 5 wei collateralizes 38 ETH#
Remediation#
- Add virtual-share / virtual-asset offset to iToken pricing (the modern
ERC4626 standard fix). Computing
tokenPriceas(underlying + 1) * 1e18 / (totalSupply + virtualShares)makes donation inflation economically infeasible — the attacker would have to donate orders of magnitude more than they could ever recover. - Lock minimum / dead shares on first deposit. Permanently burn a small,
fixed number of shares to a dead address when a pool is initialized, so
totalSupplycan never be driven to a manipulable near-zero value. - Do not value loan collateral via a manipulable, single-pool share price.
IPriceFeeds.queryRatefor an iToken should derive value from the underlying asset's external oracle (Chainlink) times a conservative, bounded share-to-underlying ratio, not the rawunderlying/supplythat any donor can move. Apply sanity bounds: reject quotes that imply a share price far from its historical/TWAP value. - Track deposited underlying internally instead of using
balanceOf(this)._totalAssetSupplyreading the raw token balance is what makes donation "count" toward the price. Using an internal accounting variable that only changes onmint/burn/interest accrual closes the donation channel. - Enforce a minimum collateral amount per loan and reject loans where the
computed
collateralAmountRequiredrounds to dust relative to the principal.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella
DeFiHackLabs repo has many unrelated PoCs that fail to whole-compile under
forge test):
_shared/run_poc.sh 2023-12-bZx_exp -vvvvv
- RPC: an Ethereum mainnet archive endpoint is required (fork block
18,695,728).
foundry.tomluses an Infura archive endpoint; the original pinned mainnet key returned HTTP 401 and was rotated to a working Infura key. - Result:
[PASS] testExploit()with finalExploiter WETH balance after attack: 42.027208518991148756.
Expected tail:
Ran 1 test for test/bZx_exp.sol:ContractTest
[PASS] testExploit() (gas: 2690446)
Exploiter WETH balance before attack: 0.000000000000000000
...
Exploiter WETH balance after attack: 42.027208518991148756
Suite result: ok. 1 passed; 0 failed; 0 skipped
References: PoC header (DeFiHackLabs src/test/2023-12/bZx_exp.sol);
post-mortem — https://x.com/MetaSec_xyz/status/1730811240942088263 ;
verified sources under sources/.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-12-bZx_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
bZx_exp.sol.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "bZx / Fulcrum
iTokenExploit". - Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
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.