Hack Analysis: Omni Protocol, July 2022

Immunefi
Immunefi
Published in
10 min readDec 22, 2022

--

Introduction

Omni, an NFT money market platform, was the victim of a $1.4M hack on July 10, 2022, which exploited a reentrancy vulnerability on its Ethereum smart contracts.

The hacker used a Doodles NFTX vault to deposit Doodles NFTs as collateral on an Omni pool. By leveraging a reentrancy vulnerability on two different functions and using two attacker contracts, the hacker was able to borrow against the collateral and make the market forget about it.

In this article, we’ll be analyzing the vulnerable code in the Omni contract, and then we’ll create our own simplified version of the attack, testing it against a local fork. You can read the full PoC here.

This article was written by gmhacker.eth, an Immunefi smart contract triager.

Background

Omni was an NFT money market on the Ethereum blockchain. It allowed users to borrow and lend against their non-fungible tokens. Using an Omni protocol’s pool, one could deposit supported ERC721 tokens as collateral and borrow a given amount of an ERC20 asset. This would bring about market efficiency to NFTs, which by their nature are very illiquid assets. For instance, a depositor could supply various Doodles ERC721 tokens and borrow WETH against them. If the health factor was reached, liquidators could buy off the ERC721 collateral at a discount. As of today, it seems the Omni smart contracts are no longer being used.

It’s also worth describing what NFTX is, since we will be using it. NFTX is a platform for creating liquid markets for illiquid NFTs, where users can mint ERC20 tokens (vTokens) representing a claim on a given NFT they deposit into a vault. These ERC20s can later on be used on AMMs and other protocols.

Root Cause

Having a rough understanding of what the Omni protocol is, we can dive into the actual smart contract code to explore the root cause vulnerability that was leveraged in the July 2022 hack.

The on-chain transaction can be seen here.

The underlying vulnerability, reentrancy, was exploited across two different functions of the same smart contract. Notably, these functions were lacking reentrancy locks and did not follow the checks-effects-interactions pattern. In particular, the vulnerable code is using the ERC721’s safeTransferFrom method. As samczsun has pointed out in one of his blog posts, the function naming here might be misleading.

Snippet 1: safeTransferFrom function from solmate’s ERC721

The safeTransferFrom function checks if the transfer destination address is able to handle ERC721 tokens. More precisely, the address either needs to be an externally owned account (EOA) or a smart contract implementing the onERC721Received interface. If the receiver is a smart contract, the last part adds an external call to the destination contract address, which hands over execution to the receiver and introduces the potential for a reentrancy vulnerability.

And that’s exactly the case in the Omni protocol’s Pool contract:

Snippet 2: From Omni Pool code, SupplyLogic.sol

The executeWithdrawERC721 function is run when a user wants to remove their NFT collateral from the market. Though it’s not included in the above snippet for simplicity, one of the last things the function does is calling userConfig.setUsingAsCollateral(reserve.id, false), which informs the market that the address in question no longer has collateral deposited into the contract. But the NToken.burn gets called before that, as we can see in the snippet. That burn function will call safeTransferFrom on the tokens provided as collateral, giving the execution context to the destination address. This will give us an opportunity to reenter the market, knowing that after reentering the market the configuration that tells the market we have collateral will be set as false.

There’s another place in the Pool smart contract where one can reenter the market through the NToken.burn function. The executeERC721LiquidationCall function is run when one liquidates collateral tokens of a borrow that fell bellow the health factor liquidation threshold, allowing the liquidator to buy the NFT collateral at a discount. Once again, the checks-effects-interactions pattern is not being used.

Snippet 3: From Omni Pool code, LiquidationLogic.sol

Going through the executeERC721LiquidationCall code, we can see that there’s a special case where userConfig.setUsingAsCollateral(collateralReserve.id, false) will be called after calling the burning function.

Snippet 4: From Omni Pool code, LiquidationLogic.sol

It’s possible to use the same strategy as before to reenter the market, knowing that in the end the collateral configuration will be set as false. The actual hack that took place in July 2022 leveraged both of these vulnerabilities to create a double-reentrancy attack and steal funds from the market.

Proof of Concept

Now that we understand the vulnerability that compromised the Omni protocol, we can formulate our own proof of concept (PoC). The hacker flashloaned 20 Doodles tokens to maximize their profit, but we’ll stick to 4 NFTs to simplify our code. The expansion of the PoC for a larger-scale attack should be straightforward.

We’ll start by selecting an RPC provider with archive access. For this demonstration, the free public RPC aggregator provided by Ankr should be sufficient. We will be using block number 15114361 as our fork block–1 block prior to the actual exploit transaction.

Our PoC needs to run through a number of steps on a single transaction to be successful. Here is a high-level overview of what we will be implementing in our attack PoC:

  1. Flashloan WETH from Balancer.
  2. Flashloan ERC20 vTokens from a Doodles NFTX vault.
  3. Swap some WETH for more vTokens on SushiSwap.
  4. Redeem vTokens for 4 Doodles NFTs on the NFTX vault.
  5. Create a malicious contract, DebtTaker, that will get liquidated during the transaction.
  6. Transfer all Doodles NFTs to DebtTaker.
  7. Call DebtTaker so that it supplies 3 NFTs as collateral to the Omni Pool and borrows WETH against it.
  8. DebtTaker withdraws 2 NFTs, providing the Liquidator contract as the destination address.
  9. After receiving the second NFT, Liquidator liquidates the last NFT collateral on the Omni Pool, because DebtTaker is currently in debt.
  10. After receiving the liquidated NFT, Liquidator transfers the 3 NFTs back to DebtTaker.
  11. After receiving the 3 NFTs, DebtTaker deposits all NFTs as collateral into the Omni Pool and borrows as much WETH as possible from it.
  12. The liquidation call finishes, leaving the market thinking DebtTaker doesn’t have collateral inside anymore. Because of that, the withdrawing function will not check the user’s debt.
  13. DebtTaker withdraws all collateral, even though the market thinks there’s no collateral.
  14. Pay back both flashloans.

Let’s code one step at a time, and eventually look at the entire PoC. We will be using Foundry.

The Attack

Snippet 5: the start of our attack contracts.

Let’s begin by creating two contracts. One contract will be DebtTaker, responsible for borrowing on the Omni market. The second will be Liquidator, responsible for flashloaning and for liquidating DebtTaker. The DebtTaker contract will only be created in the exploit transaction, not on the Liquidator deployment.

Snippet 6: Flashloan from Balancer

Our attack entrypoint will be Liquidator.startExploit. Our first action is to flashloan 1000 Ether of WETH from the Balancer protocol. For that, we need to call the function flashLoan on the BalancerVault contract. The Balancer protocol expects us to implement the callback function receiveFlashLoan, which will be executed after the funds are transferred to our contract. We already add to the callback the 1000 ether payback required from Balancer flashloan.

Snippet 7: Flashloan from Doodles NFTX vault.

Inside receiveFlashLoan, we do another flashloan, this time on NFTX. We call the function flashLoan on an NFTX vault owning Doodles NFTs. The vault will transfer 4 Ether (units) vTokens representing shares of NFTs deposited into it. The NFTX protocol expects us to implement the callback function onFlashLoan, which will be executed after vTokens are sent to the Liquidator contract. It’s also expecting us to return keccak256(“ERC3156FlashBorrower.onFlashLoan”), as part of the interface it requires us to implement.

Finally, we already add logic for the flashloan payback. We call NFTXVault.mint, which will deposit our NFT tokens and mint vTokens to our balance. We don’t need to transfer them to the protocol, since the vault code itself will do the transfer for us after we give them back the execution context (as part of the NFTXVault.flashLoan logic).

Snippet 8: Swap some WETH for vTokens on SushiSwap.

Our 4 Ether vTokens are not enough to get 4 Doodles NFTs, so we swap some of our flashloaned WETH for 0.3 Ether vTokens. We use the SushiSwap router to call the swapTokensForExactTokens function. Now, we are in a position to get 4 Doodles NFTs in exchange for the necessary vTokens.

We call the function NFTXVault.redeem to get 4 specific Doodles NFTs. These are tokens that belonged to the vault at the time of the attack. This function will transfer those NFTs to our Liquidator contract, and will transfer the necessary vTokens into the vault.

Snippet 9: Taking debt upon DebtTaker.

We reach the point where we will be using the DebtTaker contract. First of all, we’ll transfer all the Liquidator’s Doodles NFTs to it. You will notice that we have created an enum variable called receiveState. This will be used on the onERC721Received callback that gets triggered by the ERC721’s safeTransferFrom method.

Since there will be lots of times this callback is executed, we need to be certain when to actually run reentering logic. After receiveState is set to FIRST_WITHDRAW, function prepareLiquidationScene on DebtTaker is called. We’ll later see why such a state exists and when state transitions will happen. This first DebtTaker function will be used to set the liquidation stage, and the reentrancy mechanisms will be triggered inside the execution scope of this method. Function DebtTaker.withdrawAll will be a final trigger to withdraw every asset back to the Liquidator contract.

Snippet 10: Supply collateral to the Pool, and borrow

The DebtTaker contract will use 3 of the 4 NFTs in its possession to supply collateral to the Omni pool. For that, we need to call IOmni.supplyERC721, which will transfer the selected tokens to the pool’s balance and mark them as being used as collateral.

The function IOmni.getUserAccountData, as the name implies, provides various helpful parameters regarding DebtTaker’s account in the market. Among those, we have availableBorrowsBase, the maximum amount allowed to be borrowed against the provided collateral. So we use this value to borrow as much as possible from the market. We borrow from the Omni pool by executing the function borrow. Inside this method, the Omni pool will transfer the asset, WETH, to the DebtTaker contract, making sure to register that amount as user debt.

Snippet 11: Withdraw some collateral to Liquidator

After we have supplied collateral and borrowed funds from the pool, we are in a position to execute the methods that will allow us to reenter the protocol. Accordingly, we call the function withdrawERC721 to withdraw 2 of our 3 collateral NFTs. DebtTaker can input the destination address, so it withdraws the NFTs to the Liquidator contract.

As a reminder, this function will run executeWithdrawERC721, which executes NToken.burn before debt is checked and before the collateral usage configuration is checked. The burn function will call safeTransferFrom on each of our Doodles tokens. What we want is to reenter the protocol in the second transfer. We will catch the market in a weird intermediate stage:

  • DebtTaker only has 1 NFT as collateral, but it has a maximum loan for 3 NFTs as collateral, which means it’s in a position to be liquidated.
  • Since the pool hasn’t yet checked the debt on the market, and that’s dependent on the collateral usage configuration, we’re in a position to liquidate and set that configuration as false. This will prevent the withdrawing function from checking the debt on the market, leaving Liquidator with the collateral and DebtTaker with the loan.
  • We will maximize our profit by reentering the liquidation method in a similar fashion to once again borrow money and afterwards erasing the collateral usage configuration.
Snippet 12: First stages of onERC721Received

Now we can better understand the various states of ReceiveState. The FIRST_WITHDRAW state is just used for the first NFT withdrawn to the Liquidator contract. We don’t want to reenter the Omni protocol here, so we change to the LIQUIDATE state to run the first reentry upon the withdrawal of the second NFT.

When onERC721Received is triggered on the withdrawal of the second NFT, we are in the first reentrancy point. The DebtTaker contract has an unhealthy debt against its now single NFT collateral in the market, so the Liquidator can call IOmni.liquidationERC721. This function will transfer the necessary WETH to cover for the liquidation, and it will transfer the last collateral token to the Liquidator contract.

As we’ve mentioned already, safeTransferFrom inside the liquidation function provides once again an opportunity to reenter the market. Specifically, we know that in the end of this function the collateral usage configuration will be set as false, which means we can supply collateral again and borrow assets in between, and the market will afterwards forget that we do have collateral inside it, together with a big debt.

Snippet 13: Use liquidation callback to reenter

We are in the final ReceiveState state, BORROW_ALOT. We are inside the callback triggered by IOmni.liquidationERC721. At this stage, the Liquidator contract has 3 of the 4 borrowed Doodles NFTs. The contract will transfer all those tokens back to the DebtTaker contract, and then trigger it to once again borrow from the market.

Snippet 14: Borrow as much as possible, because it will all be forgiven.

The DebtTaker.borrowALot function will execute the same flow of supplying collateral and borrowing against it. The previous time, the debt that was borrowed was somewhat paid forward through the liquidation process.This time, the entire loan will be forgotten–hence why this is the place to borrow as much funds as possible.

In the actual hack, the attacker used 20 Doodles tokens to be able to borrow as much money as possible. Here, we just have 4 Doodles tokens, so we deposit all of them as collateral and borrow against it. After that, the liquidationERC721 function will finish, setting the collateral usage configuration as false. The withdrawERC721 execution will also finish, and it will not check for possible debt on the market, since the aforementioned configuration is false. This means we terminate the execution of DebtTaker.prepareLiquidationScene.

The market is in an unstable state right now. The DebtTaker contract can call IOmni.withdrawERC721 to take its collateral back, and because the collateral usage configuration is set to false, we will be able to just withdraw all the collateral, even though we have a large debt on the protocol.

Snippet 15: Withdraw all collateral.

The DebtTaker contract withdraws all the collateral to the Liquidator contract, and transfers all the WETH profit to it as well. This completes the entire exploit logic.

After that, as we’ve already shown, the NFTs will be deposited to NFTX for vTokens that will be used for the second flashloan repayment, and 1000 ether WETH are transferred to Balancer for the first flashloan repayment. If we run this PoC against the forked block number, we will get a profit of about 20 Ether. Enhancing the exploit magnitude should be fairly straightforward. Together with the interfaces and logs, our PoC amounts to 324 lines of code.

Conclusion

The Omni exploit stresses the importance of proper secure pattern implementation. In particular, it’s a reminder of how crucial the checks-effects-interactions pattern is in the DeFi sphere, as well as the importance of reentrancy guards. Yes, reentrancy vulnerabilities are definitely still happening (see this historical collection of reentrancy attacks).

Furthermore, this hack highlights how one should be mindful about token transfer hooks. Such callbacks are intended to solve one particular problem, but these underlying mechanisms might be making external calls that the project is unaware of. For this reason, one should audit and understand any code being used from an external dependency.

As previously pointed out, this PoC is a simpler attack than what actually took place in reality. We propose, as an exercise to the reader, to expand this PoC so that all liquidity in the market is drained. This exercise will hone your ability to interact with many different smart contract legos, as well as test your ability to retrieve on-chain state information.

Snippet 16: full attack contract.

--

--

Immunefi
Immunefi

Immunefi is the premier bug bounty platform for smart contracts, where hackers review code, disclose vulnerabilities, get paid, and make crypto safer.