Detailed breakdown of the Wise lending hack

Detailed breakdown of the Wise lending hack

·

7 min read

Preamble

The three cardinal rules and phases to performing an inflation attack on a protocol are:

  1. A way to get one key value to empty or almost empty

  2. A way to inflate another key value

  3. A way to exploit this resulting changes.

"Daniel Von Fange"

This attack like all inflation attack follows this rule.

Context

Wise lending is a money market that allows lending through over collateralization of debt positions. Wise implements a non Isolated market design, which allows one to supply any token in the market as collateral and borrow another token with it. Tokens are segmented into pools, and each pool has a TotalAmount and TotalShares. The pool in question here is the PLP-stETH-Dec2025 pool. When depositing and withdrawing from the pool, Wise always favours the protocol over the user.

Conversion of asset to shares mechanism:

    function calculateLendingShares(
        address _poolToken,
        uint256 _amount,
        bool _maxSharePrice
    )
        public
        view
        returns (uint256)
    {
        return _calculateShares(
            lendingPoolData[_poolToken].totalDepositShares * _amount,
            lendingPoolData[_poolToken].pseudoTotalPool,
            _maxSharePrice
        );
    }

    function _calculateShares(
        uint256 _product,
        uint256 _pseudo,
        bool _maxSharePrice
    )
        private
        pure
        returns (uint256)
    {
        return _maxSharePrice == true
            ? _product % _pseudo == 0
                ? _product / _pseudo
                : _product / _pseudo + 1
            : _product / _pseudo;
    }

Withdraw from pool

When withdrawing _maxSharePrice is set to true :

$$Shares = (\frac{Amount * TotalShares}{TotalAmount}) + 1$$

Deposit into pool

When Depositing maxSharePrice is set to false :

$$Shares = \frac{Amount * TotalShares}{TotalAmount}$$

Rounding Directions

So the pool rounds down the shares the user gets when depositing asset into the pool and rounds up the shares it burns when the user is withdrawing asset from the pool. So in all cases, the pool always favours itself over the user.

[PHASE ONE] A way to get one key value to empty or almost empty

The two key values in this pool is the TotalAmount and TotalShares. Represented as pseudoTotalPool and totalDepositShares respectively in the pool. From hence forth we would be using the former names.

struct LendingPoolEntry {
        uint256 pseudoTotalPool;
        uint256 totalDepositShares;
        uint256 collateralFactor;
}
mapping(address => LendingPoolEntry) public lendingPoolData;

At block 18992907, which is a block before the attack, the balances of this variables are as follows.

Further traces shows that the balances of this pool was changed like so:

TotalDepositShares (TotalShares):

PseudoTotalPool (TotalAmount):

So in essense the deployer started the pool by setting the exchange rate between the amount and shares as 1:1. One unit asset gets you one unit share. And in that spirit the malicious contract deposited 1e10 unit of assets to the pool.

Pool balance

TotalAmount = 1e10 + 1

TotalShares = 1e10 + 1

The attacker completes the first phase by withdrawing its 1e10 asset earlier deposited.

As seen it got back 1e10 + 15261 unit of the asset back. Which is clearly more than what it easier deposited, how did that come about? my guess is that the extra assets is from interest accrued, being that the attacker is supplying to the lending market, and lending markets accrue interest overtime. Also note that the deposit of 1e10 asset was made in previous blocks before the attack, meaning enough time to accrue interest on supply. This is an educated guess.

Pool balance

TotalAmount = 2

TotalShares = 1

So with this now, the ratio of the asset to pool has been clearly unbalanced.

Previous ratio = 1:1

Current ration = 1:2

With this now, phase one is done and dusted. As the ratio of the market has been successfully almost emptied and the ratio of the shares to asset changed successfully.

[PHASE TWO] A way to inflate another key value

The attacker proceeds to inflate the TotalAmount with a series of deposit and withdraws, while leaving the TotalShares at 1.

Let's see how this was achieved by solving the first four actions.

Deposit Amount

Amount = 3

$$Shares = \frac{Amount * TotalShares}{TotalAmount}$$

$$ Shares = \frac{3 * 1 }{2} = 1$$

Pool balance

TotalShares = 1 + 1 = 2

TotalAmount : 2 + 3 = 5

Withdraw Amount

Amount = 1

$$Shares = \frac{Amount * TotalShares}{TotalAmount}$$

$$ Shares = \frac{1 * 2 }{5} =0+ 1 = 1$$

Pool balance

TotalShares = 2 - 1 = 1

TotalAmount = 5 - 1 = 4

Deposit Amount

Amount = 7

$$Shares = \frac{Amount * TotalShares}{TotalAmount}$$

$$ Shares = \frac{7 * 1 }{4} =1$$

Pool balance:

TotalShares = 1+1 = 2

TotalAmount = 4 + 7 = 11

Withdraw Amount

Amount = 1

$$Shares = \frac{Amount * TotalShares}{TotalAmount}$$

$$ Shares = \frac{1 * 2 }{11} =0 + 1 = 1$$

Pool balances

TotalShares = 2 - 1 = 1

TotalAmount = 11 - 1 = 10

Rounding mechanism

Deposit

The objective of the deposits is to always get a result that returns a decimal, with the whole number always being 1 . So the result of all the fractions must always be 1 point any number (1.x) . Since the context of the execution is the EVM, the EVM will discard any number after the point (EVM rounds down to zero).

$$Result = \frac{Numerator}{ Denominator}$$

$$ Numerator - Denominator < Denominator $$

$$Numerator = (Denominator * 2) - 1$$

And of-course we want to donate the highest number possible while still maintaining this invariant.

Withdrawal

For withdrawal we want a number that always results to a decimal but with the whole number being zero, so the result of all fractions should be 0.x. Since the context of the execution is the EVM, the EVM will discard any number after the point (EVM rounds down to zero).

$$Result = \frac{Denominator}{ Numerator}$$

$$ Denominator > Numerator$$

Since Wise rounds up at withdrawal, the result will always be 1 in this context.

Continuation.

The attacker continues the series of deposit and withdrawal until the current state of the pool is:

Pool balance

TotalShares = 1

TotalAmount = 36,472,996,377,170,786,404

Then it makes a final deposit of 218,837,978,263,024,718,430

$$ Shares = \frac{Amount * TotalShares}{TotalAmount}$$

$$ Shares = \frac{218,837,978,263,024,718,430 * 1}{36,472,996,377,170,786,404} = 6$$

Pool balance

TotalAmount = 218837978263024718430 + 36472996377170786404 = 255310974640195504834

TotalShares = 6 + 1 = 7

[PHASE THREE] A way to exploit this resulting changes

Remember that the last process had exactly 6 shares minted to the exploiter, now the goal of this phase is to burn those shares in return for their equivalent value in PLP-stETH-Dec2025 token while still stealing more from the market. Let's see how the exploiter achieves this.

To perform this part of the exploit the exploiter uses 6 new contracts, each performing the same action as the other. As seen in the image below.

Each contract is transferred PLP-stETH-Dec2025 token, which is the token of the manipulated pool. Each contract performs four actions after getting the token transfer.

Deposit into manipulated pool

The attacker deposits the earlier balance transferred to it into the manipulated pool and it mints it exactly 1 share.

Borrow from another market

The attacker uses this deposit as collateral to borrow from another market, borrowing the max LTV of 75% from the wstETH pool.

Transfer borrowed asset

The attacker transfers the entire borrowed assets to the master exploit contract.

Burn its share balance

In the first action the attack contract was minted 1 share, it then proceeds to burn that share with just withdrawal of 1 unit of the deposit asset, and in the process intentionally loosing 99.9 percent of its deposited asset.

NOTE: Refer to the withdrawal section of "rounding mechanism" on Phase two, to see how this is achieved.

The action of burning the share further inflates the cost of a share in the manipulated pool. Meaning more value for the current 6 shares in the pool.

These four actions are performed five more times by five different contracts, each contract leaving the initial 6 shares more valuable than before.

PLP-stETH-Dec2025 Pool balance

Before Phase 3

TotalAmount = 255310974640195504834

TotalShares = 7

After Phase 3

TotalAmount = 469,361,815,219,056,461,244

TotalShares = 7

So this means that each share price has been inflated by approximately 84%. So this means the master attack contract now has the borrowed assets and the collateral in shares from the manipulated pool. The master contract goes ahead to collect its loot by withdrawing 469,361,815,219,056,461,244 PLP-stETH-Dec2025 , which burns its 6 shares.