EIP-4626 Inflation/ Sandwich Attack Deep Dive And How to Solve It

田少谷 Shao
9 min readMar 31, 2023

--

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

Montserrat, Spain

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:

  1. the attacker deposits to the vault to mint shares
  2. the attacker also transfers ERC20 tokens directly to the vault
  3. 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 is 0 and we simply scale up or down the number of assets to decimals of ERC20 of the vault with mulDiv()
  • 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 multiplying assets / totalAssets() by totalSupply 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
  1. the attacker front-runs the depositor and deposits 1 wei WETH and gets 1 share: since totalSupply is 0, shares = 1 * 10**18 / 10**18 = 1
  2. the attacker also transfers 1 * 1e18 weiWETH, making the totalAssets()WETH balance of the vault become 1e18 + 1 wei
  3. 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 gets 0 shares, totalSupply() remains at 1
  4. the attacker still has the 1 only share ever minted and thus the withdrawal of that 1 share takes away everything in the vault, including the depositor’s 1e18 weiWETH

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
  1. originally there were .1 WETH (1e17 wei) in the vault and 1e17 wei shares of ERC20 were minted
  2. the attacker sees a depositor wanting to deposit 1 (1 * 1e18 wei)WETH to the vault
  3. thus, the attacker again mints 1 wei ERC20 with 1 wei WETH; however, this time the attacker has to send an extra 1e36 wei WETH to the vault
  4. though depositing 1 (1 * 1e18 wei) WETH, the depositor still ends up with 0 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 depositing 1e18 wei WETH is even less than 1 share and thus the depositor gets 0 shares. And the exchange rate inflates again to 1 share per 2 * 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
  1. the attacker deposits 1 wei WETH and gets 1e18 shares: 1 * 10**36 / 10**18 = 10**18 = 1e18. We can see that the exchange rate this time is no longer 1 share per WETH, but 1e18 shares per WETH
  2. the attacker transfers 1e18 wei WETH and totalAssets() still becomes (1e18 + 1) wei. The exchange rate is now 1 share per (1e18 + 1) / 1e18 WETH
  3. the spied-on depositor deposits 1e18 wei WETH. This time, the depositor gets 1e18 wei shares: 1e18 * 1e18 / (1e18 + 1) = 1e36 / (1e18 + 1) = 999,999,999,999,999,999 wei; let’s round it up to 1e18 for easier demonstration
  4. since the depositor no longer gets 0 but 1e18 shares instead, the attacker redeeming 1e18 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 😊

--

--