Hack Analysis: Saddle Finance, April 2022
Saddle Finance, an automated market maker (AMM) on Ethereum, was the victim of a series of transactions on April 30, 2022, that exploited deployed smart contracts.
Over the course of three attack transactions, roughly $11m in crypto was taken. One of these three attacks was a whitehat who rescued roughly 25% of this total figure from being taken by blackhats. The exploit relied on a previously known bug that was not sufficiently patched.
In this article, we will be taking a look at the attack that took place, identifying what enabled the attack, and then creating our own simplified recreation of the attack where we will be exploiting the smart contracts ourselves. You can read the full PoC here.
This article was written by Hephyrius.eth, an Immunefi Whitehat Scholar.
Saddle Finance is an AMM that specializes in stable swaps of assets that are pegged or similar in nature. Saddle aims to reduce the slippage that users experience when they are trading. Crucially, a lot of the code used by Saddle uses many core ideas and concepts from Curve, much of which has been reimplemented to meet Saddle’s needs.
Curve as an AMM has two distinct swap pools, Standard and Meta. A Standard pool is a pool that acts as a normal AMM pool where tokens provided as liquidity are those that can also be swapped via the pool. In Curve, one of the most popular pools is the 3-curve pool, where DAI, USDT, and USDC can be swapped against one another.
The other type of pool is the Meta pool. This pool consists of a pegged token and a token that represents liquidity in a standard pool. Essentially, Meta pools allow liquidity in one pool to be used in additional pools. For instance, a depositor can deposit $1000 of DAI in Curve’s 3-pool and then deposit this as liquidity in the MIM-3 pool, allowing them to earn yield from trades in both the 3-pool and MIM pools respectively, while also providing deeper liquidity within both pools.
Now that we have a basic understanding of what Saddle Finance does and the two pools used by the protocol, we can explore the root cause of the attack that occurred in this transaction against the sUSD — Saddle DAI/USDC/USDT Meta pool.
As mentioned, Saddle Finance uses elements of Curve within its protocol. One thing the Saddle Finance team did was to reimplement Curve’s Meta pool in Solidity, instead of forking the original Vyper code. In doing so, errors were introduced.
The main error is inconsistencies in the way that price calculations of assets within a pool are completed. Specifically, LP tokens in Meta pools are mispriced, in comparison to the underlying asset within a pool. This means that for $1 dollar of a token more or less than $1 of LP tokens may be retrieved.
The cause of this mispricing is the omission of math that calculates the value of tokens given, based upon the base virtual price of the LP token. Over time, the LP token’s virtual price grows as fees are taken. However, the swap function did not account for the virtual price correctly. Interestingly, the math within the contracts is correct for deposit, withdrawing and swapping underlying tokens, the error is only present when swapping a token for an LP token.
The bug within the pricing was present within the library called
MetaSwapUtils, a library that is used for calculating swaps, deposits and withdrawals. Specifically, line 424 does not calculate the base virtual price of the LP token during calculations, which is inconsistent with the line 277 where this check in the calculation is correctly implemented.
dy = xp[tokenIndexTo].sub(y).sub(1);
dyFee = dy.mul(self.swapFee).div(FEE_DENOMINATOR);
dy = dy.sub(dyFee).div(self.tokenPrecisionMultipliers[tokenIndexTo]);
In previous exploits on forks of Saddle, including Nerve and Synapse, attackers used this pricing inconsistency to drain funds. However, this specific bug was never exploited directly on Saddle as Saddle reacted quickly and paused Meta pool swaps before migrating to a supposedly fixed
MetaSwapUtils. The fixed library includes LP virtual price scaling that was missing. In the patched file, these changes are from Lines 447–451. In the code snippet below we can see the scaling that resolves the vulnerability.
Despite this migration, it seems that the incorrect swap calculation present in the original code was still present in live pools. For instance, within the sUSD — Saddle DAI/USDC/USDT Meta pool that was exploited.
Proof of Concept
Now that we understand the fatal flaw and theoretical underpinnings of this attack, we can formulate our own proof of concept (PoC) similar to the attack. We’ll start by selecting an RPC provider that has archive access. For this demonstration, the free eth public rpc aggregator provided by Ankr should be sufficient. We will be using block number 14684300 as our fork block.
Our PoC needs to run through a number of steps on chain to be successful. Here is a high-level overview of what we will be implementing in our attack:
- Obtain USDC funds via flash loan
- Swap USDC for sUSD
- Swap sUSD for Saddle DAI/USDT/USDC LP token via Meta pool
- Swap LP token for sUSD again via Meta pool
- Swap sUSD for USDC
- Repay flash loan
Let’s begin by creating a simple contract, such as the one seen in code snippet 2. This contract is fairly unremarkable. It simply deploys a contract that is ownable and has access to IERC20 token interfaces.
Now that we have a basic contract, we can start adding the meat. Let’s begin by looking at snippet 3. Here we introduce a number of functions, namely
attack(). Alongside this, we introduce the
The start function will be the function that we call as an attacker to start our heist. This is pretty self-explanatory. The first line of the start function calls Euler loans, in order to borrow $15m in USDC. We are using Euler because it does not take a fee from us when we flash loan from them. We must only repay what we borrowed before the end of the transaction.
Our call to Euler leads to Euler calling
onFlashLoan, which is a hook in our smart contract that Euler uses to pass control back to us. At the start of this function, we call our internal attack function, which we will place our heist logic within. This callback requires two things to occur in order for the flash loan to be considered successful. First, we must allow Euler to transfer USDC from our contract. This is essential, as when flow passes back to Euler, the
flashLoan function will attempt to transfer funds back to the lending contract. If it cannot transfer the full $15m USDC, the transaction will revert.
Second, we must return a bytes32 encoding of “
ERC3156FlashBorrower.onFlashLoan” in order to comply with the ERC3156 specification. Euler reverts if it does not receive a signature, or the signature is not correct.
In this next snippet, we take the $15m that we borrowed from Euler and exchange it for some Synthetix sUSD via the Curve sUSD v2 pool. This is a standard Curve pool that contains sUSD, DAI, USDC and USDT. After this swap, we receive 14.8 million sUSD.
We can now take this sUSD and exchange it for Saddle LP tokens via the Meta pool. We can see this process in snippet 5, where we exchange our 14.8 million sUSD for 9.8 million LP tokens.
We then reverse this procedure in snippet 6. By swapping back and forth through the mispriced pool, we take advantage of the mispricing, which leads to us having a lot more sUSD than we started with. In this case, our 14.8 million becomes a little over 16.8 million– an increase of over 2 million sUSD from a simple flaw.
In the original attack, the attacker at this point swaps back and forth between sUSD and the LP token in order to remove all available liquidity from the Meta pool. We won’t be doing this in our PoC, as we demonstrate a $2 million gain in a few lines of code already!
What we need to do now is convert our sUSD back to USDC in order to repay our flashloan via Euler. We can do so by following snippet 7, where we exchange our entire balance of sUSD for USDC via the same Curve pool we used earlier. This is essentially us reversing the process and ends with us converting our 16.8 million sUSD into $17 million USDC. This is more than the original $15 million that we need to repay to Euler, which means that when flow of control passes back to the flashloan contract, the flash loan will be successfully repaid, via our earlier approval in our
Once our flash loan has ended, we are in a position where we have a smart contract with over $2m in profits–all of which were obtained in under 90 lines of fairly unsophisticated Solidity. Our entire attack script can be seen in snippet 8 below.
As smart contract security researchers, we need to be aware of past or known exploits against a code base, or similar code, as these attack vectors may be present in the code we are working with, such as with this attack.
We walked through a simpler attack than the one that occurred, namely we only took $2m of the available liquidity of the meta pool and related standard pool. We propose as an exercise to the reader to expand this PoC so that all sUSD liquidity is drained from the Meta pool, and that as much liquidity as possible is taken from the standard pool whose LP token is within the Meta pool. This will challenge your understanding of the protocols involved as well as your understanding of the fundamental flaw.