On December 13, ElasticSwap, a DeFi swap protocol for rebasing tokens, experienced a liquidity pool exploit on one of its pairs costing liquidity providers $500,000. This transaction, which can be viewed here, affected an AMPL-USDC pool and sparked a thorough investigation into the exploit and how it was carried out. In this post, I will explore the details of the exploit and share a proof-of-concept exploit that I developed based on my findings. This was a fun and interesting challenge to reverse-engineer, and I hope that my findings will be helpful for other DeFi developers and users.


Background

To help with context I’ve described some core concepts that are helpful to understand the exploit.

What are rebasing tokens?

Rebasing tokens are a type of cryptocurrency that automatically adjusts the supply of tokens in circulation based on a predetermined set of rules. This type of token is designed to maintain a stable value relative to another asset or group of assets, such as a basket of currencies or a commodity like gold. When the value of the token is higher than the target value, the supply of tokens is increased, which can lead to deflation. When the value of the token is lower than the target value, the supply of tokens is decreased, which can lead to inflation.

Rebasing token problems

Rebasing tokens can present challenges for swap protocols like Uniswap because they require the protocol to constantly adjust the supply of tokens in the liquidity pool in order to maintain the desired value of the tokens. This can put additional strain on the protocol and increase the likelihood of security vulnerabilities and exploits. For example, if a malicious actor is able to manipulate the supply of rebasing tokens in the liquidity pool, they could potentially profit at the expense of other users.

Introducing ElasticSwap

ElasticSwap is a DeFi swap protocol that is designed to support rebasing tokens.It is able to adapt to the changes in supply that can occur with rebasing tokens, without the need for someone to manually call a function on the pool. This helps to avoid losing value that has been accrued through a rebase, and reduces the likelihood of impermanent loss. In comparison, other swap protocols like Uniswap v2 / Sushi require the manual calling of a sync() function every time a rebase occurs. ElasticSwap’s approach offers a more seamless and efficient way to manage rebasing tokens in a liquidity pool.


Internal accounting woes

Like most swap protocols, ElasticSwap uses internal accounting to keep track of the number of tokens it has. These tokens are referred to as the baseToken (AMPL) and quoteToken (USDC). Since the balances of these tokens are stored in the contract state of the pool, any actual changes in the balances of the baseToken or quoteToken contracts will not be reflected in the pool’s accounting until they are manually checked and updated, typically during a swap. When tokens go through a rebase, their supply changes, causing a difference between the internal accounting and the real balance. This is exactly what ElasticSwap is designed to handle, so it shouldn’t be a concern.

Except it is.

When using internal accounting, it is important to ensure that you do not mix calculations with the current balances from the token contract. This is because internal accounting may not be up-to-date, while the balance check from the token contract reflects the latest data. This mismatch can cause unexpected behaviors.


Withdrawing liquidity

The issue mentioned above is present in the function removeLiquidity, which allows you to exchange your LP tokens for baseTokens and quoteTokens. The following is a simplified explanation of the liquidity removal process:

1) Get the current balance of both baseToken and quoteToken directly from token contract.

uint256 baseTokenReserveQty = IERC20(baseToken).balanceOf(address(this));
uint256 quoteTokenReserveQty = IERC20(quoteToken).balanceOf(address(this));

2) Use the current balances to calculate how many tokens the user should receive.

uint256 baseTokenQtyToReturn =
    (_liquidityTokenQty * baseTokenReserveQty) / totalSupplyOfLiquidityTokens;

uint256 quoteTokenQtyToReturn = 
    (_liquidityTokenQty * quoteTokenReserveQty) / totalSupplyOfLiquidityTokens;

3) Reduce the internal accounting balance for quoteToken by the amount to be sent to user.

uint256 internalQuoteTokenReserveQty = internalBalances.quoteTokenReserveQty;

internalBalances.quoteTokenReserveQty = internalQuoteTokenReserveQty =
    internalQuoteTokenReserveQty - quoteTokenQtyToReturn;

internalBalances.kLast =
    internalBaseTokenReserveQty * internalQuoteTokenReserveQty;

4) Burn LP tokens and send baseTokens and quoteTokens to user.

    _burn(msg.sender, _liquidityTokenQty);
    IERC20(baseToken).safeTransfer(_tokenRecipient, baseTokenQtyToReturn);
    IERC20(quoteToken).safeTransfer(_tokenRecipient, quoteTokenQtyToReturn);

The steps above show a simplified explanation of the withdrawal process, as well as the problem. The internal accounting balance for quoteToken is updated using the actual balance directly from the quoteToken contract. However, in step 3, where we subtract the amount to be sent to the user from the quoteToken internal balance, this amount could be increased by transferring tokens to the pool just before the call to removeLiquidity is made. This would cause the amount to be subtracted to be proportionally larger than the out-of-date internal balance, leading to the internal balance being updated to an incorrect, much smaller value than it should be. Once the internal accounting is incorrect the pricing has been manipulated.


It’s not easy

The variable quoteTokenReserveQty needs to be manipulated to be as close as possible to the internal accounting quoteToken reserve. This can be achieved by sending quoteTokens to the pool just before a liquidity withdrawal, so that the internal accounting is not aware of the quoteTokens it has received. This process is straightforward, but simply sending quoteTokens is not enough to manipulate quoteTokenReserveQty. It is calculated using the following formula:

(_liquidityTokenQty * quoteTokenReserveQty) / totalSupplyOfLiquidityTokens;

This formula essentially gives you a percentage of the total number of quoteTokens that the contract currently holds. This percentage depends on how many LP tokens you have relative to the total supply of LP tokens. For example, if there are 1230 quoteTokens and you have 12 out of 100 LP tokens, you would get (12 * 1230) / 100 = 147 quoteTokens, representing 12/100 of the 1230 tokens available.

If you own a small portion of the total LP, you need to send a large amount of quoteTokens to the pool in order to effectively manipulate quoteTokenReserveQty. However, this is not practical as it would require an infeasible amount of quoteTokens. Therefore, in order to manipulate quoteTokenReserveQty effectively, you also need to own a large portion of the LP tokens.

We want to achieve the following conditions:

  • Keep the internal balance for quoteToken low so it is easier to subtract a significant amount.
  • Own a large portion of the LP to make quoteTokenReserveQty large so subtraction is easier.

However, these conditions contradict each other. Keeping the internal accounting for quoteToken low means you cannot deposit liquidity, while increasing your portion of the LP requires you to deposit liquidity which increases internal balances. How can we work around this?


Single-sided liquidity provisions

Elasticswap is designed to support rebasing tokens, which can cause changes in the supply of tokens. When a rebase occurs, ElasticSwap will set aside any excess tokens to prevent them from affecting the pricing. This is called price decay. The set-aside funds will be held until one of two things happens:

  • Another rebase brings the supply back into alignment (although it is unlikely that the supply will ever be perfectly aligned)
  • A user can add liquidity to fill in the gap

There are three ways to add liquidity to ElasticSwap: the ordinary way, where both input amounts are “proportional”, and two special ways that can only be used when there has been a price decay.

One of these special ways to deposit assets is by performing a single-sided liquidity add. For example, let’s say there is a pool with 100 baseTokens and 100 quoteTokens, resulting in a price ratio of 1:1. Then a rebase occurs that reduces the baseToken balance by 50%, so the ratio becomes 1:2. The extra quoteToken amounts would be set aside to restore the original price ratio of 1:1 (in this case, 50 quoteTokens would be set aside).

Therefore, there would be 50 quoteTokens sitting aside, and since there has been a “decay” in the price, it is possible to do a single-sided liquidity add of only baseTokens to make use of the “aside” quoteToken liquidity again.

This concept can be applied to the situation where we have our two contradicting conditions, as described above. We can obtain our LP tokens and perform a single-sided deposit of baseTokens, which does not increase the internal accounting balance of quoteTokens.


The exploit process

Now that we have all the necessary information to understand how the exploit works, here are the steps that the attacker took:

  1. The attacker waited for a rebase that reduces the balance of baseToken, allowing them to perform a single-sided liquidity add. The AMPL token did a rebase to increase supply on this transaction, an hour before the attack.

  2. The attacker added single-sided liquidity and obtained LP tokens, without increase internal quoteToken balance.

  3. The attacker decided to add more liquidity in the ordinary way, increasing their portion of LP ownership.

  4. The attacker transferred a specific amount of quoteToken to the pool, such that when combined with their owned LP portion, it would result in a value that, when subtracted from the internal accounting of quoteToken, would lead to a very small value.

  5. This small value was then written as the new quoteToken internal accounting balance amount, breaking pricing.

  6. The attacker used the function swapQuoteTokenForBaseToken to swappp a small amount of quoteToken for lots of baseToken.

  7. The attacker adds all liquidity and remove it, resulting in an “equal” ratio of baseToken and quoteToken.

  8. The attacker paid off any flash loans, converted all funds for Ether and transaction ended.

Proof-of-concept

Based on the transaction data, I have created an attack contract that uses the same exploit pattern. It is accessible here. The exploit contract is named Attack.sol and it runs the exploit on the forked mainnet just before the actual attack took place. All you need to do is place an RPC url in foundry.toml.

TL;DR

A bug existed in the liquidity withdrawal function that subtracts an outdated balance by an up-to-date balance. The AMPL token went through a rebase that increased its supply, allowing for an opportunity for the attacker to create a mismatch between the outdated balance and the up-to-date balance. This mismatch was then used to manipulate asset pricing, allowing the attacker to steal funds.