- Type: Exploit
- Network: Polygon
- Total lost: 138 ETH
- Category: Reentrancy
- Exploited contracts:
- Attack transactions:
- Attacker Addresses:
- Attack Block:: 34716801
- Date: Nov 23, 2022
- Reproduce:
forge test --match-contract Exploit_Qi_ReadOnlyReentrancy -vvv
- Take a flashloan
- Add a lot of liquidity to an eth/{something} pool to get LP tokens
- Remove liquidity triggering fallback when receiving ETH
- Request a loan from a system using
get_virtual_price
as a price oracle for LP tokens - Loan will be given taking a higher-than-real price for LP tokens
- Repay flashloan
Understanding this attacks needs some familiarity with AMMs and how crypto system compose with each other.
We will start by reviewing Curve's Stable Swaps. There's no need to go deep into the math, we can just understand that is is an AMM that is optimized for trades between assets that are really close in price (different from constant product AMMs).
The graph below should give us a fairly good intuition of how the curve in an stableswap look like:
Stableswaps keep a value D
as the swap-invariant (like constant product keeps k
). Just like in constant product, this is a semi-invariant in in practice as it will increase due to fees.
Now, we must understand some particularities of Curve before moving forward. We probably can't do it better than the folks at ChainSecurity have already done, so we really recommend you go and read the blog post. Let's do a very condensed summary just in case you don't have time to read the full story today:
Curve Pools have a get_virtual_price
that returns the prices of LP tokens (calculated as D / total_lp_token_supply ~= total_underlying_supply / total_lp_token_supply
).
When you burn
your LP tokens, this code is executed:
// _amount is the amount of lp tokens burn
// amount[i] is the total amount of each token
CurveToken(lp_token).burnFrom(msg.sender, _amount)
for i in range(N_COINS):
value: uint256 = amounts[i] * _amount / total_supply
if i == 0:
raw_call(msg.sender, b"", value=value)
else:
assert ERC20(self.coins[1]).transfer(msg.sender, value)
Note that if i == 0
the value
is sent natively. This means msg.sender
is suddenly executed. At this point, the contract has burned the LP tokens but has only sent part of the underlyings. If you remember, get_virtual_price
was total_underlying_supply / total_lp_token_supply
: this number is now off, as only a part of the total_underlying_supply
has been sent while all of the corresponding total_lp_token_supply
has been burned.
Here is a the implementation of get_virtual_price
:
@view
@external
def get_virtual_price() -> uint256:
"""
@notice The current virtual price of the pool LP token
@dev Useful for calculating profits
@return LP token virtual price normalized to 1e18
"""
D: uint256 = self.get_D(self._balances(), self._A())
# D is in the units similar to DAI (e.g. converted to precision 1e18)
# When balanced, D = n * x_u - total virtual value of the portfolio
token_supply: uint256 = ERC20(self.lp_token).totalSupply()
return D * PRECISION / token_supply
If an attacker finds a protocol that uses get_virtual_price
as a price oracle for LP tokens, they could be exploited.
Unfortunately, this is exactly what QiDao and Market XYZ did. For the purposes of this reproduction, we are gonna concentrate on QiDAO but the concept for Market XYZ is the same.
At this point, you only need to know that QiDAO is a lending platform which used MasterPriceOracle to get the prices for their collateralized borrows. Unfortunately, this oracle used get_virtual_price
, which we now know how to exploit.
- Ensure that the tokens addresses provided match the addresses from the targeted pool or check if they are whitelisted.
- Use a reentrancy mutex if arbitrary tokens are meant to be handled.
- Review the checks-effects-interactions pattern and evaluate the steps at which tokens flow in and out the contract.