Curve is a popular Automated Market Maker (AMM) that uses a Liquidity Pool (LP) to get the funds. Many contracts interact with Curve to find out the going rate of a token. The get_virtual_price() function implements the logic for creating the stable swap mechanism within Curve. LP tokens are commonly priced by computing the underlying tokens per share. Essentially, assets total price/amount of LP tokens.
Reentrancy is an attack where a contract can be left then reentered with only SOME state being changed. With a partial change of state, it may be possible to recover funds or overwrite other state into a exploitable position. In order to prevent attacks like this, a reentrancy modifier is commonly used on a single contract to prevent going back into it. However, this is typically only on the main state changing functions on the contract.
So, what if we entered a contract, left it via an external call then made a read only call to the service from another contract? Since that is not a major state changing function, it likely does not have the reentrancy modifier on it. Additionally, the code may be in an unintended state, creating opportunity for financial manipulation.
When removing liquidity from Curve, there is a reentrancy modifier/decorator (written in Vyper) on the function remove_liquidity(). When calculating the price that the LP token should be swapped for the code gets the balances of the contract, balances of the user and the total supply. Once it does this, the LP token is burned (removing the tokens from circulation) and returns all of the funds to the original caller.
Above, the total_supply has been updated but NOT the individual amount of each token. This leaves the contract in a very strange place for new calls being made. Since the price of the LP token is based upon the assets total price/amount of LP tokens, we can make the amount of LP tokens very little but still keep the assets very high. While in this state, calling get_virtual_price() would have an inflated cost as a result. Any pool using this function from Curve would have been severely open to oracle manipulation at this point.
Overall, reentrancy attacks are extremely hard to mitigate. Reentrancy guards and integer overflow protection are simply not enough. Updating all of the state properly prior to an external call is crucial for the security. It is sickening for defensive people but awesome for offensive folks. To me, it's like memory safety bugs in C - it is too easy to mess up and the ecosystem should make it possible to do easily.