๐ŸŠโ€โ™‚๏ธVulnerabilities in Initial Pool Creation - Liquidity Manipulation Attacks

Deciphering Initial Liquidity Vulnerabilities and Uniswap's Countermeasure

Introduction

The Decentralized Exchange (DEX) landscape relies heavily on liquidity pools, and the initialization phase of these pools is critically vulnerable to exploitation. Uniswap V2 deploys a mechanism to mitigate this risk, one that centers around the burning of initial liquidity pool shares. This article seeks to elaborate and clarify this strategy in depth.

How Liquidity Pools Generally Work

  1. Initial Liquidity: When a liquidity pool is first created, it has zero assets and zero pool shares (liquidity tokens).

  2. First Deposit: The first liquidity provider sets the initial value of these pool shares by depositing assets into the pool. In return, they get an equivalent number of pool shares (often calculated as the geometric mean of the deposited amounts).

  3. Subsequent Deposits: Future liquidity providers deposit assets and receive pool shares based on the current value set by the pool.

Problem Statement: Initializing Liquidity Token Supply

When someone wants to become a liquidity provider (LP) by depositing tokens into an existing Uniswap pair (or forked DEX), the formula to calculate how many liquidity tokens (also known as pool shares) they receive involves dividing the amount deposited by the starting amount of tokens already in the pool.

However, this formula doesn't work if the person is the first to deposit because the starting amount of tokens would be zero, causing a division by zero problem.

2. Uniswap V1's Approach

In Uniswap V1, the initial supply of liquidity tokens was set to be equal to the amount of ETH deposited. While this was an acceptable approximation, it had limitations:

  1. The value of one liquidity token would depend on how liquidity was initially added, which can be arbitrary.

  2. Uniswap V2 allows for arbitrary pairs, not just those involving ETH, making the V1 approach less applicable.

3. Uniswap V2's Solution: The Geometric Mean

Advantages:

  1. It ensures the value of one liquidity pool share doesn't depend on how liquidity was initially added.

  2. It minimizes rounding errors.

4. Risk of Share Value Growth

Over time, the value of a liquidity pool share could grow either by accumulating trading fees or through direct donations. If it grows too much, small liquidity providers could find it too costly to join the pool.

5. Mitigation: Burning Initial Shares

How an Attacker Can Raise the Value of Liquidity Pool Shares: A Detailed Explanation with Visualization

The Setup:

Let's say an attacker aims to manipulate a liquidity pool for a token pair ABC/XYZ to make it infeasible for smaller investors to participate. The attacker's strategy is to make 1 liquidity pool share extremely valuable by contributing a large amount of one token (or both) and a very small amount of the other.

The Attack:

  1. Small Investor Deterrence: A small investor who can only afford to deposit 1 XYZ will receive far less than 1 pool share, making it practically impossible for them to participate.

Visualization:

Imagine each liquidity pool share as a "block" that represents a proportional slice of the total pool assets. In a well-balanced pool, these blocks are relatively small and easily divisible:

yamlCopy codeNormal Pool:
Blocks: โ–กโ–กโ–กโ–กโ–กโ–ก

In our attack scenario, the attacker has made each block (pool share) represent a large amount of the pool's assets:

yamlCopy codeManipulated Pool:
Blocks: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ

The size of each block (or the value of each liquidity pool share) is now so large that a small investor can only afford a tiny sliver, represented by a small fraction of a block:

mathematicaCopy codeSmall Investor's Share:
Block:  โ–ˆโ–ˆโ–ˆโ–ˆโ–ก (The small square represents what a small investor can afford)

Counter-Measure: Uniswap V2's Initial Share Burning

vbnetCopy codeWith Uniswap V2's Mechanism:
Initial Blocks Burned: (tiny pieces removed from the initial blocks)

Summary:

Uniswap V2's mechanism of burning initial liquidity pool shares serves as a protective barrier against this type of manipulation. It keeps the door open for small investors while putting up a prohibitively expensive wall for malicious actors aiming to distort the pool's economics.

Example vulnerabilities of DEXs that didnt migitage this issue

Impact

A user who joins the systems first (stakes first) can steal everybody's tokens by sending tokens to the system externally. This attack is possible because you enable staking a small amount of tokens.

Proof of Concept

See the following attack:

  1. the first user (user A) who enters the system stake 1 token

  2. another user (user B) is about to stake X tokens

  3. user A frontrun and transfer X tokens to the system via ERC20.transfer

  4. user B stakes X tokens, and the shares he receives is: shares = (_amount * totalStakeShares_) / (totalTokenBalanceStakers() - _amount); shares = (X * 1) / (X + 1 + X - X) = X/(X+1) = 0 meaning all the tokens he staked got him no shares, and those tokens are now a part of the single share that user A holds

  5. user A can now redeem his shares and get the 1 token he staked, the X tokens user B staked, and the X tokens he ERC20.transfer to the system because all the money in the system is in a single share that user A holds.

In general, since there is only a single share, for any user who is going to stake X tokens, if the system has X+1 tokens in its balance, the user won't get any shares and all the money will go to the attacker.

Force users to stake at least some amount in the system (Uniswap forces users to pay at least 1e18) That way the amount the attacker will need to ERC20.transfer to the system will be at least X*1e18 instead of X which is unrealistic

Similar to issue above

Code4rena High bug bounty payout

https://github.com/code-423n4/2021-11-vader/blob/429970427b4dc65e37808d7116b9de27e395ce0c/contracts/dex/pool/BasePool.sol#L161-L163

uint256 totalLiquidityUnits = totalSupply;
if (totalLiquidityUnits == 0)
    liquidity = nativeDeposit; // TODO: Contact ThorChain on proper approach

In the current implementation, the first liquidity takes the nativeDeposit amount and uses it directly.

However, since this number (totalLiquidityUnits) will later be used for computing the liquidity issued for future addLiquidity using calculateLiquidityUnits.

A malicious user can add liquidity with only 1 wei USDV and making it nearly impossible for future users to add liquidity to the pool.

Recommendation

Uni v2 solved this problem by sending the first 1000 tokens to the zero address.

The same should work here, i.e., on first mint (totalLiquidityUnits == 0), lock some of the first minter's tokens by minting ~1% of the initial amount to the zero address instead of to the first minter.

First lp provider received liquidity amount same as the nativeDeposit amount and decides the rate. If the first lp sets the pool's rate to an extreme value no one can deposit to the pool afterward. (please refer to the proof of concept section)

A malicious attacker can DOS the system by back-running the setTokenSupport and setting the pools' price to the extreme.

Pool is created in function createPoolADD. The price (rate) of the token is determined in this function. Since the address is deterministic, the attacker can front-run the createPoolADD transaction and sends tokens to Pool's address. This would make the pool start with an extreme price and create a huge arbitrage space.

I assume pools would be created by the deployer rather than DAO at the early stage of the protocol. If the deployer calls createPoolADD and addCuratedPool at the same time then an attacker/arbitrager could actually get (huge) profit by doing this.

Assume that the deployer wants to create a BNB pool at an initial rate of 10000:300 and then make it a curated pool. An arbitrager can send 2700 BNB to the (precomputed) pool address and make iBNB 10x cheaper. The arbitrager can mint the synth at a 10x cheaper price before the price becomes normal.

However, if a user first directly transfers Joe tokens to the contract before the first updatePool call, the block.timestamp - lastRewardTimestamp = block.timestamp will be a large timestamp value and lots of rJoe will be minted (but not distributed to users). Even though they are not distributed to the users, inflating the rJoe total supply might not be desired.

Last updated