Introduction
On December 12th, 2022 ElasticSwap was exploited for $854k on Avalanche and Ethereum. The exploit was due to inconsistencies in the methods by which liquidity was calculated when adding and removing liquidity. This opened up the possibility for an attacker to unbalance the pool using a donation.
ElasticSwap
ElasticSwap was an AMM created to work with elastic supply (rebasing) tokens which have supplies that algorithmically increase or decrease. These tokens require special handling which is why they aren't supported by standard AMMs like UniSwap V3. ElasticSwap's innovation was to adapt to supply changes of the rebasing tokens in trading pairs without LPs needing to call functions on the pools every time a rebasing event happens. The intended purpose of this was to make it less likely that the value that gets accrued in a rebase is lost via impermanent loss.
The Exploit
The attacker was able to perform a price manipulation attack on the pool because when a user adds liquidity to a pool via the addLiquidity
function, it uses a constant k value algorithm (k = x * y) to keep track of the added liquidity via baseTokenReserveQty
and quoteTokenReserveQty
which are updated in the call to calculateAddLiquidityQuantities
:
function addLiquidity(
...
) external nonReentrant() isNotExpired(_expirationTimestamp) {
uint256 totalSupply = this.totalSupply();
MathLib.TokenQtys memory tokenQtys =
MathLib.calculateAddLiquidityQuantities(
_baseTokenQtyDesired,
_quoteTokenQtyDesired,
_baseTokenQtyMin,
_quoteTokenQtyMin,
IERC20(baseToken).balanceOf(address(this)),
totalSupply,
internalBalances
);
internalBalances.kLast =
internalBalances.baseTokenReserveQty *
internalBalances.quoteTokenReserveQty;
however when a user withdraws liquidity via the removeLiquidity
function the baseTokenReserveQty
and quoteTokenReserveQty
are calculated using the token balances of the pool:
uint256 baseTokenReserveQty =
IERC20(baseToken).balanceOf(address(this));
uint256 quoteTokenReserveQty =
IERC20(quoteToken).balanceOf(address(this));
which are used to calculate the amount of baseTokenQtyToReturn
and quoteTokenQtyToReturn
that the LP receives:
uint256 baseTokenQtyToReturn =
(_liquidityTokenQty * baseTokenReserveQty) /
totalSupplyOfLiquidityTokens;
uint256 quoteTokenQtyToReturn =
(_liquidityTokenQty * quoteTokenReserveQty) /
totalSupplyOfLiquidityTokens;
In a constant k value algorithm AMM there is an implicit invariant that after a user adds or removes an amount of liquidity the ratio of x and y should not change, as this would mean that adding or removing liquidity changes the price of the base or quote token of the pool since the spot price is calculated as x / y or y / x. This invariant ensures that the price of the pool can't be manipulated by adding or removing liquidity as doing so would make it easy to arbitrage the devalued token and drain value from the pool, which is what happened in this exploit.
From the above implementation we can see that by making a donation to the pool the attacker could subsequently receive more of the baseTokenReserveQty
or quoteTokenReserveQty
when withdrawing by increasing the denominator in the above calculations, leading to the pool becoming unbalanced and breaking the invariant that price shouldn't change after removing liquidity.
This is precisely what the attacker did by adding liquidity to the TIC-USDC pool, donating USDC to the pool, removing the added liquidity and finally swapping USDC for TIC in the unbalanced pool to exploit the reduced price and drain funds from the pool. The full transaction flow for the attack conducted on Avalanche can be seen here and the attack that was frontrun by an MEV bot on Ethereum can be seen here.
Preventing The Exploit
While the attack was conducted in 2022 and the state of Solidity fuzzers at the time was not as robust as it is now we'll look at how the attack could've been prevented with a clearly defined property and an Echidna/Medusa test that would've broken the property and brought the issue to the developer's attention. We'll be using Recon to bootstrap a Echidna/Medusa/Foundry test suite in minutes that we can add our property to and quickly find a violation to with Echidna.
If you've never setup Recon on a repo before check out this guide or watch this tutorial.
The Property
As explained above, since the ElasticSwap liquidity pool uses a constant product algorithm for determining token values we can define our property as: the price of the pool shouldn't change after adding/removing liquidity. Essentially, we care that the ratio of base tokens to quote tokens, and vice versa remains the same after adding/removing liquidity because if these ratios are different it means the price has changed.
The Test
After adding Foundry on top of Hardhat which ElasticSwap was using and scaffolding our test suite with Recon we can define our property to be tested in the Properties
contract. Knowing that AMM pools are subject to change of token price after swaps, we can choose the functions in our TargetFunctions
contract to limit the scope of the called functions to only functions that add/remove liquidity and functions on the tokens used in the pool to see if they break our invariant. If we were wanting to ensure this property holds over the entire system, we could add all external functions in the system to the TargetFunctions
to have a greater guarantee and filter the swap functions as we know and accept that they will break the invariant.
We define our property as a function in the Properties
contract that ensures the price of the pool doesn't change after any call sequence:
function invariant_spot_price_doesnt_change() public returns (bool) {
uint256 currentPrice = _getBaseSpotPrice();
return currentPrice == initialBaseSpotPrice;
}
Where the _getBaseSpotPrice
function is defined in the Setup
contract as:
function _getBaseSpotPrice() internal returns (uint256) {
(uint256 baseTokenReserveQty, uint256 quoteTokenReserveQty,) = exchange.internalBalances();
return quoteTokenReserveQty / baseTokenReserveQty;
}
the initialBaseSpotPrice
is set by a call to this at the end of the setup
function after the Exchange
has been seeded with liquidity to store the starting price of the contract.
After running Echidna our invariant is broken after ~5 minutes and we get the following output:
Echidna has generated a call sequence that replicates the exploit performed by the actual hacker and allows unbalancing the pool which subsequently means the price of tokens in the pool is incorrect. This not only breaks the invariant but also allows swapping to arbitrage the price difference and subsequently profit, which leads to a loss of funds for the pool.
We can subsequently define a unit test in our CryticToFoundry
contract that allows us replicate this exploit at any time and if we were to fix the issue that causes it in the underlying Exchange
contract we could use the test to determine if our solution does in fact resolve the issue. This unit test replicates the issue for a different call sequence:
function test_breakInvariant() public {
vm.startPrank(address(this));
exchange_addLiquidity(247920512443508,724290160891);
eRC20_transfer(1386619558024359);
exchange_removeLiquidity(721537869293,1,1);
vm.stopPrank();
assertTrue(_getBaseSpotPrice() == initialBaseSpotPrice);
}
Conclusion
We've seen how a simple property definition on a system can lead to an exploit path being uncovered by Echidna. This demonstrates once again the benefit of defining invariants of a smart contract system as tests which can be evaluated by a fuzzer to ensure that there's no call sequence that a user could make on the system that breaks them and leads to a loss of user or protocol funds.