Another look at the security of ERC4626 Vaults

Monethic.io
Coinmonks
7 min readMar 4, 2024

--

Due to rising popularity of ERC4626 vaults, their wide usage, I spent some time recently experimenting with them and testing common attack vectors. In the end, I decided to share the results with the community.

The ERC4626 vaults are common DeFi components frequently used by multitude of protocols. What’s important, they usually are part of something bigger, for example, the funds are reinvested somewhere and come back to the vault in form of a yield, increasing value of every share. The main concept of vault is to be a “bank” which users can deposit assets to. As they deposit, they are given back a receipt — shares, which are units of the vault token itself. Each share represent proportional ownership of the underlying share’s assets.

This article won’t cover the basics of what are ERC4626 vaults, as it’s already described in many places, instead, focus on few interesting attack vectors and their relation to OpenZeppelin’s implementation, and its update 4.9 which added additional layer of security to the vaults.

What should be kept in mind, is that while there is increased security in OZ’s implementation, not every vault on the planet relies on the updated version, thus it’s important to understand what are the main lines of defense to be able to spot a potential vulnerability in the vault contracts.

And what’s so special about OpenZeppelin’s patch 4.9, why is it mentioned? Well, it was a major vault security upgrade, which to some point mitigates inflation attacks and “vault reset” attack. The whole update is perfectly described here. In short it introduces concept of decimalsOffset, which makes inflation or vault reset attacks too costly for the attacker.

This article has a corresponding github repo, with some unit tests, so you can play yourself with the vault behavior. You can find the repo here.

The well-known inflation attack now and then

The inflation attack is already well documented and it’s fairly common. It’s reason is that standard formula that is responsible for calculating vaults deposits. When user deposits assets and wants to receive shares back,

receivedShares = (depositedAssets * NumberOfAlreadyIssuedShares) / totalAssetsInTheVault

knowing that totalAssetsInTheVault is calculated as

return _asset.balanceOf(address(this));

using a basic math knowledge we might already know that increasing the denominator, will cause the nominator to be divided by a larger number, thus shares received will be less. Thus, a donation of underlying token, to the vault address, as a first depositor, might cause the subsequent users to receive 0 or less shares (depending on mitigation level). The inflation is already extensively discussed in article by MixBytes.

The short glossary:

  • shares is the number of shares that will be equivalent to the given amount of assets.
  • assets is the amount of the underlying asset you want to convert into shares.
  • totalSupply() represents the total number of shares currently in existence for the vault.
  • totalAssets() represents the total value of the underlying assets held by the vault.

Now, to see how the inflation attack PoC works, and how it doesn’t work anymore in OZ’s ERC4626, you can simply manipulate the repo’s Vault.t.sol import section, to use old or updated version of ERC4626 by commenting out the unused one:

//import "../../lib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC4626-old.sol";
import "../../lib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC4626.sol";

For example, if for both cases you run just

forge test --match-test test_fiveUsersJustDepositAndWithdraw_1 -vv

You will observe that nothing special happens, and that a vault, without any external inflows, just keeps the same price of share per asset.

As mentioned earlier, everything interesting happens when external inflows happen. You can use the following test from the repo to see the inflation attack PoC, what’s interesting, try to use it with old and updated version of ERC4626, to see the difference:

forge test --match-test test_inflation_attack -vv

Old vs New version:

What’s the key takeaway? One of methods to quickly identify candidate for inflation vulnerability is to identify, if the audited vault employs the old, or rather the new approach to calculating shares. Of course, other caveats should be also taken into account, like dead shares mint, or minimal shares mint requirement, which also partially mitigates the potential inflation attack.

The vault reset attack now and then

The vault reset attack was already described well by kankodu on twitter

In short, as the vault exists and generates yield, it transfers back the funds to itself, so it does the same as the attacker in previous example — increases the denominator, by increasing the overall vault’s balance of underlying assets.

This is perfectly normal and expected, because that adds a real use case for the vault — user deposits funds, get shares back, and after some time, those shares are worth more than initially, and user can make profit redeeming them. Otherwise who would freeze their funds somewhere for nothing?

The aforementioned threat says that, if a vault’s shares are concentrated in one place, and someone can use it to redeem all the shares, bringing the vault to a “clear” state, then the share price will start from 1, and if someone is able to e.g. use a flashloan to borrow all shares, redeem, then deposit again and return the same number of shares, then that user can keep the profit. It will be simply because initially the shares are worth “more” than after “resetting” the vault.

Again, this is mitigated in aforementioned OZ’s patch, but it’s interesting to keep in mind that some vaults may behave this way. You might want to test it yourself running following tests. To see how the “natural” price increase of vaults work, use

forge test --match-test test_fiveUsersJustDepositAndWithdraw_with_yield -vv

with the new vault version. This is a commented out fuzz test that shows, that once anything was donated to the vault, then the price will never return to 1. On the other hand, if you import the “old” version, you can see that it’s possible to get back to price 1 token per 1 share.

The proper direction of rounding

Lastly, this is something complatetly not related to OZ’s update, but while playing with the vault it was tempted to see in real life what exactly happens with the rounding.

Table above is based on arbitrary execution’s article. So essentially, what will happen if you change the rounding direction? If in ERC4626.sol we change Ceil to Floor or reverse in e.g. previewDeposit function, which is directly responsible for calculating shares users receive, let’s see what happens:

forge test --match-test test_deposit_with_incorrect_rounding -vv

So for example here, due to incorrect rounding, we can see that user will receive 1 share for 1 wei, while with correct rounding user is eligible for 0 shares (or normally, a protocol would revert on 0 shares). While at this point, it seems unlikely to do severe damage to the procotol, it would be leaking value on every deposit.

The same can be tested with test_redeem_with_incorrect_rounding — here, it would require modification of function previewRedeem()

Finally, the slippage

This one is without a PoC, but considering all of the above, which demonstrates that the pricing in the vault is subject to change over vault lifetime, especially after a donation happens, and as well as when rounding is in place, a secure implementation of a vault should guarantee users the possibility to control maximal slippage they are willing to incur. Usually that means giving users a parameter like minSharesOut etc. It is, however, unlikely to achieve similar volatility as with an AMM where price can fluctuate up and down, if you play with the inflation example, you will realize that the price of a share might go up after a donation / yield transfer, but it’s unlikely to go down — even, if someone starts redeeming large amounts of shares, unless the full reset is possible. Therefore the slippage related vulnerabilities are limited, however, it should always be checked against certain implementation, because every small difference matters.

References and recommended reading:

[1] MixBytes on ERC4626 https://mixbytes.io/blog/overview-of-the-inflation-attack

[2] @kankodu on twitter about reset attack https://twitter.com/kankodu/status/1685320718870032384

[3] Arbitrary execution on ERC4626 https://www.arbitraryexecution.com/blog/shared-vulnerabilities-between-erc-4626-vaults-and-vault-like-contracts-deep-dive-part-3

[4] OZ’s patch https://gist.github.com/Amxx/ec7992a21499b6587979754206a48632

[5] Current vault https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/extensions/ERC4626.sol

[6] Old vault https://raw.githubusercontent.com/OpenZeppelin/openzeppelin-contracts/6b17b334300f8268acdfc88b77b87395f6aa5174/contracts/token/ERC20/extensions/ERC4626.sol

--

--

Monethic.io
Coinmonks

We are providing security services for smart contracts & web3. Find us on twitter https://twitter.com/Monethic_io