Hack Analysis: Omni Protocol, July 2022
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.
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.
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.
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:
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.
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.
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:
- Flashloan WETH from Balancer.
- Flashloan ERC20 vTokens from a Doodles NFTX vault.
- Swap some WETH for more vTokens on SushiSwap.
- Redeem vTokens for 4 Doodles NFTs on the NFTX vault.
- Create a malicious contract, DebtTaker, that will get liquidated during the transaction.
- Transfer all Doodles NFTs to DebtTaker.
- Call DebtTaker so that it supplies 3 NFTs as collateral to the Omni Pool and borrows WETH against it.
- DebtTaker withdraws 2 NFTs, providing the Liquidator contract as the destination address.
- After receiving the second NFT, Liquidator liquidates the last NFT collateral on the Omni Pool, because DebtTaker is currently in debt.
- After receiving the liquidated NFT, Liquidator transfers the 3 NFTs back to DebtTaker.
- After receiving the 3 NFTs, DebtTaker deposits all NFTs as collateral into the Omni Pool and borrows as much WETH as possible from it.
- 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.
- DebtTaker withdraws all collateral, even though the market thinks there’s no collateral.
- Pay back both flashloans.
Let’s code one step at a time, and eventually look at the entire PoC. We will be using Foundry.
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.
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.
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
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.
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
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
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.
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.
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.
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.
Now we can better understand the various states of
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.
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.
We are in the final
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.
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
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.
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.
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.