Preamble
The three cardinal rules and phases to performing an inflation attack on a protocol are:
A way to get one key value to empty or almost empty
A way to inflate another key value
A way to exploit this resulting changes.
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.