All incidents

bZx/Fulcrum iETH oracle manipulation enables undercollateralized WETH borrowing

Share
Feb 18, 2020 03:13 UTCAttackLoss: 6,796.01 WETHManually checked1 exploit txWindow: Atomic

Root Cause Analysis

bZx/Fulcrum iETH oracle manipulation enables undercollateralized WETH borrowing

1. Incident Overview TL;DR

In Ethereum mainnet block 9,504,627, transaction 0x762881b07feb63c436dee38edd4ff1f7a74c33091e534af56c9f7d49b5ecac15 exploited bZx/Fulcrum iETH by manipulating the protocol's sUSD/ETH pricing inputs and then borrowing against overvalued sUSD collateral.

The adversary used iETH's flashBorrowToken(uint256,address,address,string,bytes) to temporarily receive 7,500 WETH, executed a callback that (1) manipulated the Kyber/Uniswap V1 spot-derived rate for sUSD, (2) acquired a large sUSD balance via Synthetix Depot, and (3) invoked borrowTokenFromDeposit(...) with borrowAmount=0 so iETH computed the borrow size from the manipulated oracle path. iETH then paid out 6,796.012817135270528952 WETH (unwrapped to ETH via WethHelper) to the adversary-controlled contract, and the flash loan was repaid before the transaction completed.

The root cause is that borrow sizing for borrowTokenFromDeposit depended on an oracle path that reads manipulable same-transaction DEX spot prices (Kyber getExpectedRate and Uniswap V1 getEthToTokenInputPrice / getTokenToEthInputPrice). Because the attacker moved those prices immediately before the borrow sizing call in the same transaction, the protocol computed an undercollateralized borrow amount.

2. Key Background

Key system components involved:

  • Victim lending market (iETH): Fulcrum iToken for ETH/WETH at 0x77f973fcaf871459aa58cd81881ce453759281bc. iETH exposes a flash-loan-like primitive flashBorrowToken(...) that transfers the loan token and then executes an arbitrary callback via ArbitraryCaller.sendCall.
  • bZx core + oracle integration: iETH delegates to LoanTokenLogicV4, which calls into bZx core (bZxProxy at 0x1cf226e9413addaf22412a2e182f9c0de44af002) for borrow sizing via getBorrowAmount(...), passing an oracle address (bZxOracle).
  • Oracle price source (manipulable): The observed borrow-sizing path queries Kyber's on-chain getExpectedRate (and downstream logic that reads Uniswap V1 spot prices). These spot prices are functions of current pool reserves and are therefore manipulable intra-transaction by executing swaps.
  • Collateral acquisition (sUSD): The flow uses Synthetix Depot at 0x172e09691dfbbc035e37c73b62095caa16ee2388 to exchange ETH for sUSD, enabling the attacker to obtain sufficient sUSD collateral on-chain within the same transaction.

3. Vulnerability Analysis & Root Cause Summary

This incident is an oracle-manipulation borrowing attack.

iETH's borrowTokenFromDeposit(...) can compute a borrow amount when borrowAmount=0. That computation calls bZx core getBorrowAmount(...) with the configured oracle address. In the seed transaction, the oracle evaluation path queries Kyber getExpectedRate(...), which (as observed in the trace) triggers Uniswap V1 spot-price reads (getEthToTokenInputPrice / getTokenToEthInputPrice). Because the attacker can trade against the relevant Uniswap V1 exchange just prior to this oracle query, the computed sUSD/ETH valuation is attacker-controlled within the same transaction.

The violated safety invariant is:

  • When originating a borrow from deposit, the oracle rate used to value collateral must be manipulation-resistant; otherwise, borrowAmount can exceed what is economically collateralized.

The deterministic breakpoint in this exploit is the oracle query for borrow sizing that occurs after the attacker executes reserve-changing swaps in the same transaction, causing getBorrowAmount(...) to be computed from manipulated spot pricing.

4. Detailed Root Cause Analysis

4.1 Victim Code Path: Flash Borrow + Borrow From Deposit

The iETH implementation explicitly supports (a) transferring assets to a borrower, (b) executing an arbitrary callback, and then (c) checking that balances are restored.

Verified iETH LoanTokenLogicV4.flashBorrowToken (excerpt):

function flashBorrowToken(
    uint256 borrowAmount,
    address borrower,
    address target,
    string calldata signature,
    bytes calldata data)
    external
    payable
    nonReentrant
    returns (bytes memory)
{
    _checkPause();
    _settleInterest();

    uint256 beforeEtherBalance = address(this).balance.sub(msg.value);
    uint256 beforeAssetsBalance = ERC20(loanTokenAddress).balanceOf(address(this))
        .add(totalAssetBorrow);

    burntTokenReserved = beforeAssetsBalance;

    if (borrowAmount != 0) {
        _transfer(loanTokenAddress, borrower, borrowAmount, "39");
    }

    // arbitrary call via ArbitraryCaller
    (bool success, bytes memory returnData) = arbitraryCaller.call.value(msg.value)(
        abi.encodeWithSelector(
            0xde064e0d, // sendCall(address,bytes)
            target,
            callData
        )
    );
    require(success, "call failed");

    burntTokenReserved = 0;

    require(
        address(this).balance >= beforeEtherBalance &&
        ERC20(loanTokenAddress).balanceOf(address(this)).add(totalAssetBorrow) >= beforeAssetsBalance,
        "40"
    );

    return returnData;
}

The borrow sizing used when borrowAmount=0 in borrowTokenFromDeposit is computed via _getBorrowAmountForDeposit, which calls into bZx core and passes the oracle address (bZxOracle).

Verified iETH LoanTokenLogicV4._getBorrowAmountForDeposit (excerpt):

borrowAmount = IBZx(bZxContract).getBorrowAmount(
    loanTokenAddress,
    collateralTokenAddress != address(0) ? collateralTokenAddress : wethContract,
    bZxOracle,
    depositAmount,
    marginAmount
);

4.2 Oracle Breakpoint: Same-Tx Kyber Expected Rate and Uniswap V1 Spot Reads

In the seed transaction trace, bZx core/oracle evaluation performs a Kyber getExpectedRate query and (within that call) reads Uniswap V1 spot prices.

Call trace excerpt (debug_traceTransaction callTracer), showing function selectors and targets:

{
  "to": "0x818e6fecd516ecc3849daf6845e3ec868087b755",
  "type": "STATICCALL",
  "input": "0x809a9e55..." 
}
  • 0x809a9e55 is getExpectedRate(address,address,uint256).

Within the same oracle query path, the trace shows Uniswap V1 exchange spot-price function calls:

0xcd7724c3  // getEthToTokenInputPrice(uint256)
0x95b68fe7  // getTokenToEthInputPrice(uint256)

Because Uniswap V1 spot prices are direct functions of pool reserves at the moment of the call, and because the attacker makes reserve-changing trades immediately beforehand, the oracle value used for borrow sizing is attacker-controlled within the same transaction.

4.3 Why This Enables Undercollateralized Borrowing

The exploit combines these properties:

  1. iETH computes borrowAmount using an external oracle path (bZx oracle integration).
  2. That oracle path reads same-transaction DEX spot prices (Kyber expected-rate route, including Uniswap V1 spot queries).
  3. The attacker can perform trades that move the relevant spot price immediately before the borrow-sizing call.

As a result, the computed borrow amount reflects an inflated valuation of the attacker's sUSD collateral. iETH then originates and pays out a large WETH amount that is not economically collateralized.

5. Adversary Flow Analysis

5.1 Participants

  • Tx sender (EOA, gas payer): 0xb8c6ad5fe7cb6cc72f2c4196dca11fbb272a8cbf
  • Primary attacker-controlled contract (borrower/receiver): 0x360f85f0b74326cddff33a812b05353bc537747b
  • Victim iETH: 0x77f973fcaf871459aa58cd81881ce453759281bc
  • Arbitrary caller used by iETH: 0x000f400e6818158d541c3ebe45fe3aa0d47372ff
  • KyberNetworkProxy: 0x818e6fecd516ecc3849daf6845e3ec868087b755
  • Synthetix Depot: 0x172e09691dfbbc035e37c73b62095caa16ee2388
  • WETH: 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2
  • sUSD: 0x57ab1ec28d129707052df4df418d58a2d46d5f51

5.2 End-to-End Transaction Flow (Single Transaction)

All steps below occur within the single seed transaction 0x762881...ac15.

  1. Initiate flash borrow from iETH

    • Call: iETH.flashBorrowToken(borrowAmount=7500e18, borrower=0x360f85..., target=0x360f85..., ...).
    • Effect: iETH transfers 7,500 WETH to 0x360f85... and executes an arbitrary callback via ArbitraryCaller.sendCall.
  2. Convert WETH to ETH for trading

    • The borrower contract calls WETH.withdraw(7500e18) to obtain 7,500 ETH for downstream swaps.
  3. Manipulate sUSD pricing inputs via Kyber/Uniswap

    • The borrower contract calls Kyber swapEtherToToken(sUSD, ...) 19 times: one call with 540 ETH and eighteen calls with 20 ETH.
    • These swaps change the Uniswap V1 exchange reserves used by Kyber's expected-rate logic and therefore change the spot-derived oracle rate.
  4. Acquire additional sUSD via Synthetix Depot

    • The borrower contract calls Synthetix Depot exchangeEtherForSynths() with 6,000 ETH, receiving a large sUSD balance.
  5. Borrow against manipulated collateral valuation

    • The borrower contract calls:
      • iETH.borrowTokenFromDeposit(borrowAmount=0, leverageAmount=2e18, initialLoanDuration=604800, collateralTokenSent=<sUSD amount>, borrower=0x360f85..., receiver=0x360f85..., collateralTokenAddress=sUSD, ...).
    • Because borrowAmount=0, iETH computes the borrow size using _getBorrowAmountForDeposit, which calls bZxProxy.getBorrowAmount(..., bZxOracle, ...).
    • During this sizing step, the oracle path queries Kyber getExpectedRate and Uniswap V1 spot-price functions in the same transaction (Section 4.2).
  6. Payout + unwrap to ETH

    • iETH transfers 6,796.012817135270528952 WETH to WethHelper at 0x3b5bdccdfa2a0a1911984f203c19628eeb6036e0 and calls claimEther(receiver=0x360f85..., amount=6796012817135270528952).
    • WethHelper unwraps WETH and sends the equivalent ETH to the attacker-controlled contract.
  7. Repay the flash borrow

    • The borrower contract repays 7,500 WETH by calling WETH.deposit{value: 7500 ETH}() and WETH.transfer(iETH, 7500e18).
  8. Profit realization

    • The attacker-controlled contract ends the transaction with a large net ETH increase (see Section 6).

6. Impact & Losses

Measured value movements (from prestateTracer-based native balance deltas and observed WETH unwrap):

  • iETH economic loss (payout attributable to the undercollateralized borrow): 6,796.012817135270528952 WETH unwrapped to ETH.
  • Attacker profit (native ETH):
    • Attacker receiver contract 0x360f85... native balance delta: +2,378.153715421627764132 ETH.
    • Tx sender 0xb8c6ad... gas cost: -0.41988685 ETH.
    • Net cluster delta (receiver profit minus gas): +2,377.733828571627764132 ETH.

7. References

  • Seed transaction: https://etherscan.io/tx/0x762881b07feb63c436dee38edd4ff1f7a74c33091e534af56c9f7d49b5ecac15
  • Victim iETH contract: 0x77f973fcaf871459aa58cd81881ce453759281bc
  • bZxProxy (core): 0x1cf226e9413addaf22412a2e182f9c0de44af002
  • KyberNetworkProxy: 0x818e6fecd516ecc3849daf6845e3ec868087b755
  • Synthetix Depot: 0x172e09691dfbbc035e37c73b62095caa16ee2388
  • WETH: 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2
  • sUSD: 0x57ab1ec28d129707052df4df418d58a2d46d5f51

Supporting artifacts used for deterministic validation:

  • Call trace (debug_traceTransaction callTracer): /workspace/session/artifacts/auditor/iter_0/tx_calltrace.json
  • Receipt logs: /workspace/session/artifacts/auditor/iter_0/tx_receipt.json
  • Balance diffs (prestateTracer diffMode): /workspace/session/artifacts/collector/seed/1/0x762881b07feb63c436dee38edd4ff1f7a74c33091e534af56c9f7d49b5ecac15/balance_diff.json
  • Verified iETH implementation (LoanTokenLogicV4): /workspace/session/.tmp/src_ieth_impl/LoanTokenLogicV4/Contract.sol