All incidents

Wise Lending WBTC Donation Drain

Share
Oct 13, 2023 14:39 UTCAttackLoss: 33.54 wstETH, 0.34 WETH +7 morePending manual check1 exploit txWindow: Atomic
Estimated Impact
33.54 wstETH, 0.34 WETH +7 more
Label
Attack
Exploit Tx
1
Addresses
1
Attack Window
Atomic
Oct 13, 2023 14:39 UTC → Oct 13, 2023 14:39 UTC

Exploit Transactions

TX 1Ethereum
0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555
Oct 13, 2023 14:39 UTCExplorer

Victim Addresses

0x84524baa1951247b3a2617a843e6ece915bb9674Ethereum

Loss Breakdown

33.54wstETH
0.339996WETH
98.97aWETH
200.09DAI
16,161.48sDAI
1,302.84aDAI
5,108.84aUSDC
26,082.61aUSDT
50USDC

Similar Incidents

Root Cause Analysis

Wise Lending WBTC Donation Drain

1. Incident Overview TL;DR

In Ethereum mainnet transaction 0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555 at block 18342121, an unprivileged adversary used a Balancer flash loan to borrow 50 WBTC, minted two fresh Wise position NFTs, deposited 1 satoshi of WBTC into each NFT, and then transferred 49.99999998 WBTC directly into Wise Lending at 0x84524baa1951247b3a2617a843e6ece915bb9674. Wise treated that unsolicited transfer as pool assets during _cleanUp, but it did not mint corresponding deposit shares. Each attacker-held WBTC share was therefore repriced to roughly one third of the entire pool, after which the attacker borrowed assets across other Wise pools and drained 49.98496326 WBTC from the WBTC pool through repeated exact-amount withdrawals that burned 0 shares.

The root cause is the interaction of two public, permissionless accounting paths. First, contracts/MainHelper.sol::_cleanUp socializes raw ERC20 balance increases into pseudoTotalPool and totalPool by calling _increaseTotalAndPseudoTotalPool, even when no deposit shares were minted. Second, contracts/MainHelper.sol::calculateLendingShares floors amount * totalDepositShares / pseudoTotalPool, and contracts/WiseLending.sol::withdrawExactAmount accepts that floored result. After the donation, the attacker can choose withdrawal amounts small enough that the share burn rounds to zero while the pool balance still decreases. The realized EOA cluster native ETH balance increased from 72.121950080247058500 ETH to 241.414875378389068206 ETH, a net delta of 169.292925298142009706 ETH, with 0.110044056426845282 ETH of sender gas already reflected in the sender's post-state delta.

2. Key Background

Wise Lending tracks lender ownership through share accounting, not by recording per-user token balances. The important WBTC pool getters before the exploit were getTotalDepositShares(WBTC) == 1 and getPseudoTotalPool(WBTC) == 1, meaning the pool only held Wise's bootstrap dummy share and bootstrap pseudo balance before the adversary transaction.

The protocol also depends on permissionless Position NFTs. The verified PositionNFTs contract at 0x9d6d4e2afab382ae9b52807a4b36a8d2afc78b07 makes the next NFT id predictable and exposes public minting:

function getNextExpectedId()
    public
    view
    returns (uint256)
{
    return totalReserved + totalSupply();
}

function mintPositionForUser(
    address _user
)
    external
    returns (uint256)
{
    return _mintPositionForUser(
        _user
    );
}

Because these entrypoints are public, any EOA can create fresh collateral and withdrawal NFTs in the same transaction. Combined with public Balancer flash liquidity and public Uniswap V3 liquidity, the full exploit path is ACT-feasible for an unprivileged searcher.

3. Vulnerability Analysis & Root Cause Summary

This incident is an ATTACK category protocol exploit, not a pure MEV unwind. The violated invariant is: pool assets added to accounting must be matched by proportional share issuance, and every positive exact-amount withdrawal must burn a nonzero amount of lending shares. Wise breaks the first half of that invariant in _cleanUp, which reads the raw ERC20 balance of the lending contract and socializes any excess into pool accounting without minting new shares. Wise breaks the second half in the exact-amount withdrawal path, because calculateLendingShares floors the share burn and withdrawExactAmount accepts the result. When the pool has only three deposit shares after two attacker dust deposits, a direct WBTC donation reprices each attacker share to roughly 16.66 WBTC. From there, the attacker can borrow against the inflated share value and repeatedly withdraw amounts whose computed share burn is 0, leaving the withdrawing NFT's one WBTC share intact while totalPool and pseudoTotalPool continue to fall. The exploit therefore combines a donation-accounting bug with a zero-share-withdraw rounding bug.

The relevant victim-side code shows the two breakpoints directly:

function calculateLendingShares(
    address _poolToken,
    uint256 _amount
)
    public
    view
    returns (uint256)
{
    uint256 shares = getTotalDepositShares(_poolToken);
    if (shares <= 1) {
        return _amount;
    }

    uint256 pseudo = getPseudoTotalPool(_poolToken);
    if (pseudo == 0) {
        return _amount;
    }

    return _amount * shares / pseudo;
}

function _cleanUp(
    address _poolToken
)
    internal
{
    uint256 amountContract = IERC20(_poolToken).balanceOf(address(this));
    uint256 totalPool = getTotalPool(_poolToken);
    uint256 bareToken = getTotalBareToken(_poolToken);

    if (_checkCleanUp(amountContract, totalPool, bareToken)) {
        return;
    }

    uint256 diff = amountContract - (totalPool + bareToken);
    _increaseTotalAndPseudoTotalPool(_poolToken, diff);
}
function withdrawExactAmount(
    uint256 _nftId,
    address _poolToken,
    uint256 _withdrawAmount
)
    external
    syncPool(_poolToken)
    returns (uint256)
{
    uint256 withdrawShares = _preparationsWithdraw(
        _nftId,
        msg.sender,
        _poolToken,
        _withdrawAmount
    );

    _coreWithdrawToken(
        msg.sender,
        _nftId,
        _poolToken,
        _withdrawAmount,
        withdrawShares
    );
}

function _coreWithdrawBare(
    uint256 _nftId,
    address _poolToken,
    uint256 _amount,
    uint256 _shares
)
    internal
{
    _updatePoolStorage(
        _poolToken,
        _amount,
        _shares,
        _decreaseTotalPool,
        _decreasePseudoTotalPool,
        _decreaseTotalDepositShares
    );

    _decreaseLendingShares(_nftId, _poolToken, _shares);
}

4. Detailed Root Cause Analysis

The transaction starts from a publicly reconstructible pre-state in which the WBTC pool is effectively empty from an accounting perspective. Position NFT id 31 is the next available id, the WBTC pool has only Wise's bootstrap share, and the Balancer Vault at 0xBA12222222228d8Ba445958a75a0704d566BF2C8 holds enough WBTC to fund the flash-loan leg.

The attacker first created two fresh WBTC positions. The seed trace shows PositionNFTs::mintPositionForUser(0x3AA228...) returning NFT 31 and PositionNFTs::mintPositionForUser(0x0D1693...) returning NFT 32. Each NFT then received a 1 satoshi WBTC deposit. At that point the pool had exactly three deposit shares: the Wise bootstrap share plus one share in each attacker-controlled NFT.

The critical accounting corruption occurs when the attacker transfers WBTC directly to the Wise contract instead of calling depositExactAmount. The trace shows the raw token transfer and then a borrow against the now-inflated collateral:

PositionNFTs::mintPositionForUser(0x3AA228...) -> tokenId 31
PositionNFTs::mintPositionForUser(0x0D1693...) -> tokenId 32
WiseLending::depositExactAmount(32, WBTC, 1)
WiseLending::depositExactAmount(31, WBTC, 1)
WBTC::transfer(WiseLending, 4999999998)
WiseLending::borrowExactAmount(32, WETH, 339996372423526589)

On the next synced Wise action, _cleanUp compares the raw WBTC balance held by the contract against totalPool + bareToken, computes diff = amountContract - (totalPool + bareToken), and calls _increaseTotalAndPseudoTotalPool(diff). That function increases pseudoTotalPool and totalPool, but it never increases totalDepositShares or any position's lending-share balance. Because there are only three total shares after the two dust deposits, each attacker share is suddenly worth roughly one third of the entire WBTC pool. The root cause report's breakpoint statement is therefore accurate: share value was inflated by an unsolicited transfer that became protocol-accounted collateral without corresponding share issuance.

The second bug is triggered during exact-amount withdrawal. _preparationsWithdraw returns calculateLendingShares(_poolToken, _amount), and calculateLendingShares floors the division. After the donation, the attacker can repeatedly choose amount < pseudoTotalPool / totalDepositShares, so the computed burn is 0 even though the withdrawal amount is positive. _coreWithdrawBare still decreases totalPool and pseudoTotalPool, but _decreaseTotalDepositShares and _decreaseLendingShares receive _shares = 0, so the withdrawing NFT keeps its single WBTC share.

The trace shows this zero-share pattern directly. The first withdrawal moves 1.666666656 WBTC and emits a FundsWithdrawn event with shares: 0; subsequent withdrawals continue with the same pattern until the helper has extracted 49.98496326 WBTC in aggregate:

WiseLending::withdrawExactAmount(31, WBTC, 1666666656)
emit FundsWithdrawn(... amount: 1666666656, shares: 0, ...)
WiseLending::withdrawExactAmount(31, WBTC, 1111111104)
emit FundsWithdrawn(... amount: 1111111104, shares: 0, ...)
...
WBTC::transfer(0x0D1693..., 4998496326)

That zero-share drain is what turns the share-inflation bug into a full exploit. One NFT keeps its inflated WBTC share value for borrowing other pool assets, while the other NFT drains the donated WBTC back out to help close the flash loan. The combination causes permanent protocol loss across multiple non-WBTC Wise pools and leaves the adversary with realized profit.

5. Adversary Flow Analysis

The adversary used a single adversary-crafted Ethereum transaction, 0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555, with the following account roles:

  • 0xc0ffeebabe5d496b2dde509f9fa189c25cf29671: sender EOA and net-positive native balance recipient.
  • 0x3aa228a80f50763045bdfc45012da124bd0a6809: helper contract that owned NFT 31 and executed the repeated WBTC withdrawals.
  • 0x0d1693819f3ec064a1c6150e11f1b6fa33537ef4: flash-loan receiver that owned NFT 32, donated WBTC, borrowed the cross-pool assets, and swapped WETH back into WBTC.
  • 0x25b71878850d008ec4237c55f0a59198bcc72b43: payout EOA that received 169.267446979085204912 ETH in the native balance diff.

The execution flow is:

  1. Balancer flash-loans 50 WBTC to the attacker receiver contract.
  2. The attacker mints NFTs 31 and 32 through the public mintPositionForUser path.
  3. Each NFT receives a 1 satoshi WBTC deposit, creating two attacker-controlled WBTC shares on top of Wise's bootstrap share.
  4. The attacker transfers 49.99999998 WBTC directly into Wise, ensuring _cleanUp will reprice the pool without issuing new shares.
  5. NFT 32 borrows assets across multiple Wise pools, including wstETH, WETH, aWETH, DAI, sDAI, aDAI, aUSDC, aUSDT, and USDC.
  6. NFT 31 executes twenty WBTC exact-amount withdrawals that each burn 0 shares, draining 49.98496326 WBTC from the pool.
  7. The attacker swaps enough borrowed WETH back into WBTC to close the flash-loan leg, repays Balancer, and routes the remaining proceeds into native ETH profit.

This flow is fully ACT-feasible. Every meaningful entrypoint in the sequence is public: Balancer flash loans, PositionNFT minting, Wise deposit/borrow/withdraw methods, and Uniswap V3 swaps.

6. Impact & Losses

Wise Lending suffered permanent loss across multiple pools after the inflated WBTC share was used as temporary collateral amplification. The measured asset losses from the incident are:

  • wstETH: 33538664799002267467 units (18 decimals)
  • WETH: 339996372423526589 units (18 decimals)
  • aWETH: 98969695913405122899 units (18 decimals)
  • DAI: 200094287736946980059 units (18 decimals)
  • sDAI: 16161480100000000000000 units (18 decimals)
  • aDAI: 1302840070263627457089 units (18 decimals)
  • aUSDC: 5108839054 units (6 decimals)
  • aUSDT: 26082605241 units (6 decimals)
  • USDC: 50000000 units (6 decimals)

The realized adversary EOA cluster native ETH balance rose from 72.121950080247058500 ETH before the transaction to 241.414875378389068206 ETH after it, for a delta of 169.292925298142009706 ETH. The sender's gas cost was 0.110044056426845282 ETH, and the sender's balance-diff entry is already net of that fee.

7. References

  • Seed transaction: 0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555
  • Seed tx metadata: /workspace/session/artifacts/collector/seed/1/0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555/metadata.json
  • Seed tx trace: /workspace/session/artifacts/collector/seed/1/0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555/trace.cast.log
  • Seed tx balance diff: /workspace/session/artifacts/collector/seed/1/0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555/balance_diff.json
  • Wise Lending verified source: https://etherscan.io/address/0x84524baa1951247b3a2617a843e6ece915bb9674#code
  • Wise Security verified source: https://etherscan.io/address/0x5f8b6c17c3a6ef18b5711f9b562940990658400d#code
  • PositionNFTs source artifact: /workspace/session/artifacts/collector/seed/1/0x9d6d4e2afab382ae9b52807a4b36a8d2afc78b07/src/PositionNFTs.sol
  • Victim contract addresses:
    • WiseLending: 0x84524baa1951247b3a2617a843e6ece915bb9674
    • WiseSecurity: 0x5f8b6c17c3a6ef18b5711f9b562940990658400d
    • WiseOracleHub: 0xd2caa748b66768ac9c53a5443225bdf1365dd4b6
    • PositionNFTs: 0x9d6d4e2afab382ae9b52807a4b36a8d2afc78b07