A PoC of the Hundred Finance Heist
Hundred Finance and Agave, lending protocols on the Gnosis Chain, were hit by reentrancy attacks on March 15 this year. These attacks drained the protocols of all collateral. In this article, we’ll be exploring the attack that took place on Hundred Finance, digging deep to find the vulnerability that was exploited, and creating a proof of concept (PoC) to demonstrate how the attack worked.
This article was written by Hephyrius.eth, an Immunefi Whitehat Scholar.
The Gnosis Root Cause
The root cause of both attacks is the same: post transfer hooks in non-standard ERC667 tokens, which enabled the reentrancy.
The official bridge that the Gnosis Chain uses creates tokens that have an
onTokenTransfer() hook. This hook is called after a token is transferred to an address. If a token is sent to an address that is a contract, then the token will look to see if that contract contains the
onTokenTransfer() fallback. If the fallback is found, the token will call the contracts
onTokenTransfer() function. This leads to a situation where control of the transaction is given to the contract being called, which allows the contract to do whatever it likes.
This hook was intended to be used by the bridge contract to prevent users from sending tokens to the bridge by accident. In the context of the attacks, the attacker used this callback to reenter the protocols and drain the collateral.
Hundred Finance Overview
Hundred Finance is a cross-chain lending protocol that has multiple deployments across a half dozen chains. At the time of the attack, the protocol deployment on Gnosis had over $6m in available liquidity.
As with all lending protocols, Hundred Finance allows users to borrow tokens against tokens they have deposited. Borrowing is done in an over-collateralized manner. For instance, a user may deposit $1m of ETH to borrow $750k of USDT. Lending protocols allow users to deposit a handful of tokens that the protocol has whitelisted beforehand. Each of these lending markets has its own parameters, which dictate how much of a token can be supplied, how much can be borrowed against supplied tokens, the oracle that will dictate the underlying price, and whether there is a global deposit cap in depositing tokens.
Hundred Finance is a fork of Compound. Markets in a compound fork are not isolated. This means a user can borrow any token against any other token. In practice, this means that the security of the protocol is dependent on the weakest token that has been enabled. Usually, lending protocols work using a health system, where a user can only borrow if their account health is above a value of 1, which is considered healthy. If they fall below this value, then a liquidator may pay off their loan, in exchange for a discount on the collateral that the user provided. Whenever a user supplies collateral, their account health increases, and whenever they borrow tokens, their account health decreases.
The Compound protocol was never intended to support non-standard tokens, such as those with hooks or callbacks. On Ethereum, tokens that are added to Compound are heavily vetted beforehand to ensure that they are standard ERC20 tokens.
In Hundred’s Gnosis deployment, whenever a user borrows collateral, the borrowed tokens are sent before the user’s debt level is updated. When combined with the
onTokenTransfer callback that bridged tokens have on Gnosis, an attacker is able to reenter the protocol and borrow against collateral multiple times, which allows them to borrow a lot more than they are entitled to.
In the attack seen on March 15, 2022, the attacker did precisely this. The attacker deposited around $2m in USDC collateral and then borrowed well over $6m of tokens from different money markets, essentially allowing them to drain $6m from the protocol of all available liquidity. Whenever the attacker borrowed a token, the token would trigger the
onTokenTransfer() callback in the attacker’s contract, giving the attacker the ability to borrow against another market in Hundred Finance before their debt was updated. The original attack transaction can be inspected here.
Hundred Finance Forking
Now that we have a basic understanding of what occurred in the attack, we can create our own PoC that exploits the reentrancy that happens before debt values are updated. To get started we need two things: an archive node for the Gnosis Chain and a pre-exploit block number. For this PoC, I will be using an archive node from poket network and will demonstrate the PoC on block 21120000, which is a few hours before both attacks. A repository containing a complete working proof of concept can be found here.
Our attack PoC will be a simplified version of what happened. Instead of attacking every lending market, we will be attacking two. The attack has several stages. We’ll walk through each stage before piecing everything together:
- Flashloan funds
- Deposit funds as collateral
- Borrow against collateral in market 1
- Reenter protocol and borrow against collateral in market 2
- Swap Token 2 for Token 1
- Repay flashloan
The first step is to obtain a substantial amount of capital that we will be using as collateral to borrow against. The more collateral we have, the easier and simpler our attack becomes. In this example, we will be borrowing all of the USDC from the SushiSwap USDC-WXDAI pair.
Let’s break down the code in snippet 3. We have three functions at the moment:
borrow , and
startAttack function is self-explanatory; it’s the function the attacker calls to trigger the exploit.
borrow function is where the flashloan is triggered. This function begins by finding the XDAI-USDC pair deployed by the SushiSwap factory. It then calls the swap function in this pair and asks for the entire USDC balance to be transferred to the attacker. As we’ve passed in non-zero data in our swap call, in this case, a string “0x”, the SushiSwap pair knows that this is a flashloan and that it should call the
uniswapV2Call of the receiving contract. Calling the
uniswapV2Call triggers the
attackLogic() function. At this point, our contract now has roughly $2m in USDC that we can use. A key thing to remember is that, right now, if we don’t repay the USDC with a 0.3% fee, our transaction will revert.
Now that we have $2m, the next thing we need to do is deposit this money as collateral in the Hundred Finance protocol. Snippet 4 breaks down this process. Once our contract has received the funds needed for the attack and control has passed to the
attackLogic() function, the
depositUsdc() function is called. In this function, we do three things. We figure out the balance of USDC that we have, we approve the Hundred Finance USDC market as a spender of our entire USDC balance, and then we trigger the mint function within this market. By triggering the mint function, we deposit our entire borrowed USDC balance, and in return get HUSDC tokens. These tokens act as an IOU that lets us withdraw our USDC collateral.
Now that the Hundred Finance protocol believes that we have $2m in collateral deposited, we can start our malicious acts. The first thing we need to do is borrow as much of our USDC back from the market as we possibly can. This can be seen in snippet 5, where we borrow USDC equivalent to 90% of the USDC we provided as collateral. In other words, we borrow $1.8m USDC from the market.
Remember back to earlier when we discussed the
onTokenTransfer callback (snippet 1) that is called when a bridged token is transferred on the Gnosis Chain. This is important for the attack. When we borrowed the $1.8m USDC from Hundred, the transfer of that USDC triggered the
onTokenTransfer() function within our attack contract, which gives us control over the flow of execution. At this moment, we the attacker have $2m in collateral, $1.8m in borrowed tokens, and the health of our account on the lending platform is at its limit. But right now, the lending platform doesn’t actually know that we have $1.8m in debt, as the debt that we have hasn’t been registered (snippet 2). In snippet 6, we pull off our reentry and get away with the goods.
Let’s break down what’s happening here. The callback from token transfers is fairly naive. It would trigger at any point from receiving any token that has the callback. We need some logic to determine if the tokens we are receiving are from the flashloan or from our borrowing activities. Therefore in our attack, we add some logic to check if the transferrer is the SushiSwap pair.
Once we’re sure that the pair is actually the borrowed market, we can get to work and reenter the hundred protocol by triggering our
borrowXdai() function. This function borrows an additional $1.2m dollars worth of xDai from the Hundred Finance xDai market, meaning that we’ve now turned our $2 million collateral into $3 million borrowed assets, while the protocol is unaware that we’ve done this.
Now that we’re $1 million dollars richer, we need to repay our flashloan to make sure our profit sticks and the attack doesn’t revert. The first thing we need to do is convert our xDai into USDC. We can’t use the USDC-XDAI pair on SushiSwap to unload our profits, as we have a reentry lock on that pair. If we try to swap again via it, we will trigger the lock and revert our transaction. In this instance, we do this by using the curve 3 pool on Gnosis, which has ample liquidity and low slippage. Snippet 7 shows us swapping our xDai for wrapped xDai and then swapping this for USDC on Curve.
Our attack contract now has around $3 million in USDC–more than enough to repay our flashloan. We simply repay the loan by sending enough USDC back to the pair contract. This will be the amount we borrowed with an additional 0.3% fee. This is shown at the end of the
attackLogic() function in snippet 4. After sending the USDC, our flashloan has been repaid, and we now have $1m in profit that we can enjoy on our private island. Tying it all together in snippet 8, we have a viable attack that allows us to borrow a lot more than we provide as collateral, allowing us to drain liquidity from Hundred Finance. All this in 123 lines of code, no less.
The Hundred Finance exploit shows us just how important it is to assess and reassess the security assumptions developers and protocol operators make when porting code from one chain to another. It also acts as an important reminder of how crucial the Check-Effect-Update pattern is in the crypto sphere, as well as the importance of reentrancy guards.
The PoC here is a much simpler attack than what actually took place in reality and would not have drained the protocol entirely. We propose as an exercise to the reader to expand this POC so that all liquidity across all lending markets is drained. This exercise will hone your ability to interact with many different smart contract legos, as well as test your ability to retrieve information about chain states.