EIP-4626 Inflation/ Sandwich Attack Deep Dive And How to Solve It
TL;DR EIP-4626 tokenised vaults are susceptible to inflation attacks if the exchange rate of assets deposited and ERC20 shares minted isn’t handled gracefully. In this article, we dissect the attack and discuss the solutions
Outline
0. Intro
1. Dissecting the Inflation Attack
2. Solutions
3. Summary
0. Intro
Issuing an ERC20 token to represent the underlying shares of users’ deposited funds in a vault has been a common practice in the DeFi space and EIP-4626 is the proposal to standardise vaults of this genre.
However, there’s a discussion surrounding the risk of the proposal: click me. In short, an attacker with enough funds can sandwich attack depositors by:
- the attacker deposits to the vault to mint shares
- the attacker also transfers ERC20 tokens directly to the vault
- the spied-on depositor deposits
as long as the total assets of the attacker surpass the depositors’ deposit amount, depositors will get 0 shares for the exchange rate between assets deposited and the ERC20 representation of the vault is tricked.
Since depositors get 0 shares, the attacker can withdraw all the assets in the vault as the attacker is the only one with shares from the first step.
IMO, it’s brilliant to come up with an original attack by paying attention to the nuances of smart contract design and a big shout out to the author of the post!
Thus, this article doesn’t intend to take credit from the author but aims to serve as another source of understanding the attack.
Let’s dive into the attack with the editor’s selection of melodic death metal: Eluveitie — Inis Mona
1. Dissecting the Inflation Attack
The steps of the attack are demonstrated above already but let’s take the mathematical approach here to dissect the attack.
Firstly, take a look at the calculation between the assets deposited and the ERC20 representation of the vault:
// modified from https://ethereum-magicians.org/t/address-eip-4626-inflation-attacks-with-virtual-shares-and-assets/12677
function _convertToShares(uint256 assets) returns (uint256 shares) {
uint256 totalSupply = totalSupply();
return supply == 0
? assets.mulDiv(10**decimals(), 10**_asset.decimals())
: assets.mulDiv(totalSupply, totalAssets());
}
- when it’s the first deposit,
totalSupply()
of ERC20 of the vault is0
and we simply scale up or down the number of assets to decimals of ERC20 of the vault withmulDiv()
- otherwise, by first dividing the input
assets
amount astotalAssets()
, we know the portion of assets compared to the total existing assets in the vault; later, by multiplyingassets / totalAssets()
bytotalSupply
we get a fair amount of shares based on the portion of assets input in the function - using
mulDiv()
is to make sure there’s no precision loss by multiplying first and then dividing
Next, we’ll need to assume some numbers such that the attack can work: say the assets deposited to the vault are WETH, whose decimals are 18. The ERC20 as the shares of the vault has also 18 decimals, e.g. an ETH-Vault whose decimals are 18.
First Deposit
Now, there’s a first depositor that wants to deposit 1 (1 * 1e18 wei)
WETH and the tx is spied on by the attacker. Here is the breakdown:
| totalSupply() | totalAssets()
---------------------------------------------------------
original state | 0 | 0
---------------------------------------------------------
(after) Step 1 | 1 | 1
---------------------------------------------------------
(after) Step 2 | 1 | 1e18 + 1
---------------------------------------------------------
(after) Step 3 | 1 | 2 * 1e18 + 1
- the attacker front-runs the depositor and deposits
1 wei
WETH and gets 1 share: sincetotalSupply
is0
, shares =1 * 10**18 / 10**18 = 1
- the attacker also transfers
1 * 1e18 wei
WETH, making thetotalAssets()
WETH balance of the vault become1e18 + 1 wei
- the spied-on depositor deposits
1e18 wei
WETH. However, the depositor gets 0 shares:1e18 * 1 (totalSupply) / (1e18 + 1) = 1e18 / (1e18 + 1) = 0
. Since the depositor gets0
shares,totalSupply()
remains at1
- the attacker still has the
1
only share ever minted and thus the withdrawal of that1
share takes away everything in the vault, including the depositor’s1e18 wei
WETH
It’s clear that the reason why 1e1 / (1e18 + 1)
gets rounded to 0 is the division gets rounded, but it’s also worth noting that divisions in Solidity are always rounded towards zero, not rounded down.
Random Deposit
The previous example demonstrates the scenario of an attacker sandwich attacking the first depositor. The attack, however, can occur whenever the attacker’s resources are abundant:
| totalSupply() | totalAssets()
--------------------------------------------------------------
original state | 1e17 | 1e17
--------------------------------------------------------------
(after) Step 1 | 1e17 + 1 | 1e17 + 1
--------------------------------------------------------------
(after) Step 2 | 1e17 + 1 | 1e36 + 1e17 + 1
--------------------------------------------------------------
(after) Step 3 | 1e17 + 1 | 1e36 + 1e18 + 1e17 + 1
- originally there were
.1
WETH (1e17 wei
) in the vault and1e17 wei
shares of ERC20 were minted - the attacker sees a depositor wanting to deposit
1 (1 * 1e18 wei)
WETH to the vault - thus, the attacker again mints
1 wei
ERC20 with1 wei
WETH; however, this time the attacker has to send an extra1e36 wei
WETH to the vault - though depositing
1 (1 * 1e18 wei)
WETH, the depositor still ends up with0
shares in this scenario:shares = 1e18 * (1e17 + 1) / (1e36 + 1e17 + 1) = (1e35 + 1e18) / (1e36 + 1e17 + 1) = 0
Realistically it’s unlikely that an attacker would like to risk funds of 10¹⁸ more times than the potential profit.
With this example, we know that even though a vault is always susceptible to attack, the likelihood of it does go down through the accumulation of funds in the vault.
Exchange Rate
As we’ve walked through the steps of different attack scenarios already, let’s introduce the last term in the attack: exchange rate.
The exchange rate between shares and assets deposited describes how many units of shares one can get by depositing certain amounts of assets.
| totalSupply() | totalAssets()
---------------------------------------------------------
(after) Step 1 | 1 | 1
---------------------------------------------------------
(after) Step 2 | 1 | 1e18 + 1
---------------------------------------------------------
(after) Step 3 | 1 | 2 * 1e18 + 1
In our first scenario, the exchange rates in each step are
- Step 1: 1 share per asset (WETH)
- Step 2: 1 share per
1e18 + 1
WETH, which means the exchange rate inflates with the extra transfer of the attacker - Step 3: since the exchange rate is 1 share per
1e18 + 1
WETH at Step 2, the depositor depositing1e18 wei
WETH is even less than 1 share and thus the depositor gets0
shares. And the exchange rate inflates again to 1 share per2 * 1e18 + 1
WETH
With these explanations, we can see why the attack is called Inflation Attack: inflating the exchange rate between shares and assets.
A Quick Sum-up
Before getting into the next section, let’s briefly sum up the root cause of the attack: the exchange rate is manipulated by extra transfers that don’t go through the regular deposit()
of the vault.
If we look at the _convertToShares()
function again, we can translate the exchange rate manipulation as the dividend, which is the depositor’s deposit, is made smaller than the divisor manipulated by the attacker.
// modified from https://ethereum-magicians.org/t/address-eip-4626-inflation-attacks-with-virtual-shares-and-assets/12677
function _convertToShares(uint256 assets) returns (uint256 shares) {
uint256 totalSupply = totalSupply();
return supply == 0
? assets.mulDiv(10**decimals(), 10**_asset.decimals())
: assets.mulDiv(totalSupply, totalAssets());
}
Thus, is it possible to simply make the dividend larger than the divisor?
2. Solutions
In the original post, two solutions seem eligible and practical: adding extra decimals and keeping internal accounting.
Keeping an internal balance sheet is pretty straightforward: direct ERC20 transfers to the vault won’t be counted as part of totalAssets()
and can radically stop the attack.
This approach, however, raises several questions by the author, such as “What to do with the extra funds”? “Vaults should be able to receive donations”, etc.
Thus, I’ll elaborate on the adding extra decimals approach only.
Extra Decimals
This solution is already hinted at in the question of the last section:
is it possible to simply make the dividend larger than the divisor?
and the answer is Yes! By giving extra decimals to the shares ERC20 token to make them larger than those of assets deposited, we can intentionally make the dividend larger than the divisor.
The reason why decimals weren’t brought up earlier is that we assumed the decimals of shares to be 18
, the same as WETH. If we look at the formula assets.mulDiv(10**decimals(), 10**_asset.decimals())
, we can see that when decimals()
and _asset.decimals()
are the same, they cancel off each other.
However, if say the decimals of shares are now 36 (which is adding 18
extra decimals to the original 18
):
assets.mulDiv(10**36, 10**18) = assets.mul(10**18)
the return shares are then scaled up by 10**18
and let’s see how this can affect the attack at the first deposit.
| totalSupply() | totalAssets()
---------------------------------------------------------
(after) Step 1 | 1e18 | 1
---------------------------------------------------------
(after) Step 2 | 1e18 | 1e18 + 1
---------------------------------------------------------
(after) Step 3 | ~2e18 | 2 * 1e18 + 1
- the attacker deposits
1 wei
WETH and gets1e18
shares:1 * 10**36 / 10**18 = 10**18 = 1e18
. We can see that the exchange rate this time is no longer1
share per WETH, but1e18
shares per WETH - the attacker transfers
1e18 wei
WETH andtotalAssets()
still becomes(1e18 + 1) wei
. The exchange rate is now 1 share per(1e18 + 1) / 1e18
WETH - the spied-on depositor deposits
1e18 wei
WETH. This time, the depositor gets1e18 wei
shares:1e18 * 1e18 / (1e18 + 1) = 1e36 / (1e18 + 1) = 999,999,999,999,999,999 wei
; let’s round it up to1e18
for easier demonstration - since the depositor no longer gets
0
but1e18
shares instead, the attacker redeeming1e18
shares won’t result in the withdrawal of all assets in the vault
The reason why the depositor gets ~1e18
shares this time at Step 3 is because, with the extra decimals, the exchange rate becomes more accurate: instead of the original 1 share per 1e18 + 1
WETH which rounds down any WETH amount less than 1e18 + 1
, 1 share per (1e18 + 1) / 1e18
WETH means that only WETH amount less than (1e18 + 1) / 1e18 ~ 1
will be rounded down to 0.
We can see that simply by increasing the decimals of the shares token, the attack is invalidated, to an effective extent — the attack can still happen while the cost escalates.
In this case, the attacker can only execute the attack by transferring 1e36 wei
WETH to the vault so that the shares of the depositor’s 1e18 wei
WETH deposit become 0 again: shares = 1e18 * 1e18 / (1e37 + 1e18) = 1e36 / (1e36 + 1e18) = 0
| totalSupply() | totalAssets()
---------------------------------------------------------
(after) Step 1 | 1e18 | 1
---------------------------------------------------------
(after) Step 2 | 1e18 | 1e36 + 1e18
---------------------------------------------------------
(after) Step 3 | 1e18 | 1e36 + 2 * 1e18
As for the random deposit scenario, it now takes an attacker 1e54
to carry out the attack: shares = 1e18 * (1e35 + 1e18) / (1e54 + 1e17 + 1) = (1e53 + 1e36) / (1e54 + 1e17 + 1) = 0
.
And the conclusion is still: possible but unlikely.
| totalSupply() | totalAssets()
--------------------------------------------------------------
original state | 1e35 | 1e17
--------------------------------------------------------------
(after) Step 1 | 1e35 + 1e18 | 1e17 + 1
--------------------------------------------------------------
(after) Step 2 | 1e35 + 1e18 | 1e54 + 1e17 + 1
--------------------------------------------------------------
(after) Step 3 | 1e35 + 1e18 | 1e54 + 1e18 + 1e17 + 1
Voila! That’s all we have to do for this solution. What about its cons?
Cons
First, the extra decimals added for the prevention of the attack can be less intuitive when users inspect the contract states on Etherscan.
Secondly, there will be some extra work for front-end engineers to handle with the extra decimals.
But these two are the only ones I can think of, and neither are they anything serious compared to the benefit.
Side Note
Some might wonder whether it’s possible to somehow directly revert ERC20 token transfers to the vault.
The answer is NO because ERC20 doesn’t have a tokensReceived()
function as ERC-777: thetokensReceived()
function implemented by the caller of a transfer will always get evoked and thus we can insert our desired logic (reversion) in tokensReceived()
if the asset deposited to the vault is an ERC-777 token.
Also, if we’re talking Ether instead of WETH as the assets deposited to the vault, then we can utilise the receive()
function specialised for Ether and choose to always revert transfers in it.
3. Summary
The root cause of the attack is that the exchange rate is manipulated to be more expensive than the depositor’s deposit amount can afford.
While the attack happens most easily at the first deposit, it’s still theoretically possible but gradually becomes impractical as the vault accumulates enough funds to fight against it.
By increasing the decimals of the ERC20 representation of the vault’s shares, the cost of the attack rises accordingly, and we can thus prevent the attack effectively with minimal side effects!
Lastly, leave any comments down below if you’d like to discuss or find any errors! Until the next 😊