I simply removed the .env stuff and replaced all tests with the proof of concept.
Run with yarn compile and then yarn test.
The tests use my own bare-bones contracts rather than mocks. I tried to build them to match production behavior as much as possible, but they are worth examining to verify that that's the case. In any case the exploit is simple enough that I'm confident that my setup here didn't interfere.
To withdraw funds from the AaveV3YieldSource.sol, one must call redeemToken(uint256 _redeemAmount). The redeemAmount is the amount of tokens received in exchange for shares burned.
SharesBurned is calculated as redeemAmount / tokensPerShare. The calculated
amount of shares are burned, and then redeemAmount tokens are returned. Unfortunately, because Solidity division always rounds down,
there is some potential to exploit this calculation. If the vault contains:
10,000 collateralToken
and
100 shares
then tokensPerShare = 100. Given that you own 1 share of the vault, calling redeemToken(100)
will burn your share and grant you 100 tokens, a fair amount. However, calling
redeemToken(199) will burn your share and grant you 199 tokens, stealing the extra 99 from
other shareholders. Since the max amount that can be stolen through this method is the amount
that will be rounded down, or tokensPerShare, the potential for an exploit gets much larger as
tokensPerShare rises.
An exploiter can take advantage of this by artificially manipulating tokensPerShare to initially be very high. If they mint 1 share, then deposit, say, 1000 USDC, then tokensPerShare will be ~10^9. Once other people have minted shares at this inflated price, the exploiter can then simply create a contract that iterates through the following:
- Mint 1 share for $1000 USDC
redeemToken($1999 USDC), which burns their newly minted share- Pocket the change and repeat using an updated exchange rate.
This exploit can be run by anyone at any time, but it only really makes sense to execute it once the gas cost per iteration is less than the profit per iteration. For USDC, I estimate that on Optimism that threshold would be reached naturally when tokensPerShare is between 5,000 and 50,000 (each iteration profits you 0.5-5 cents). This is clearly a long way off, but eventually that value might be reached and then the contract would be essentially unuseable since all yields would be going towards exploiters.
Alternatively an attacker can start the vault off with a very high exchange rate. If they are the first to deposit, they can mint 1 share and then transfer in a large amount of the collateral token, as demonstrated in the proof of concept. This can artificially raise tokensPerShare to very high values right from the start of the contract. Using this method they can easily steal basically all of the tokens held by the pool whenever they choose to.
The proof of concept involves an attacker doing the latter. At the end of the test, the exploit initial investment, profit / iteration, and total profit are logged. Overall the exploit is pretty easy to pull off, low risk, and could steal nearly all of the vault funds. The risk to the exploiter is that someone else exploits the contract before they do, which is both possible and loses the exploiter their initial investment too, but either way everyone else's cash is getting stolen.
Minimum viable exploit requires:
-
An initial deposit ramping share price up to ~50 cents per share (this costs 50 cents)
-
Nobody else running the exploit before the exploiter can
Given these two requirements, an exploiter can profitably steal ~80% of the pool, with ~10% of that theft paying the gas costs of the operation.
An initial deposit of $5 would lead to being able to steal ~98% of the pool with lower relative gas costs. Running this exploit with such small initial investments on a reasonably large pool would take enough computation to fill multiple blocks, so a larger initial deposit is a little more realistic. A deposit of about 1% of the pool TVL can steal ~90% of the pool TVL within a block, or possibly more if the contract is more optimized than my example exploit contract.
supplyTokenTo accepts a depositAmount, but only pulls the tokens that will actually be used
to mint shares. redeemToken could do the same thing; accepting a tokenAmount but then only
giving callers the tokens that were actually gained from burning shares. Another alternative
would be for the function to accept a shares argument rather than a _redeemAmount argument.