[Web3 Hack Writeup Series - 1] A special reentrancy of Earning.Farm at 20230809
First published on Hashnode on Aug 14, 2023
Last updated
First published on Hashnode on Aug 14, 2023
Last updated
This is the first article in my Web3 Hack Writeup series, where I'll be sharing writeups about DeFi attack incidents from the fantastic web3 security project on GitHub - SunWeb3Sec/DeFiHackLabs: Reproduce DeFi hacked incidents using Foundry. My goal is to document my journey of learning web3 security and hopefully, provide valuable insights for those interested in this field. I truly hope that our contributions will help make the web3 space more secure, furthering the realization of the decentralized utopia.
Without further ado, let's dive in.
First, let's take a look at the information related to this attack:
As you can see, this was a reentrancy attack with a value of 154 WETH (~286K USD). However, hold on... Wasn't the "withdraw" function protected by the "nonReentrant" modifier? Why did the reentrancy still occur?
This is precisely what makes this reentrancy attack unique. Let's carefully analyze the workflow of the attack transaction and the corresponding code. I believe you'll understand everything afterward.
I'll be using Phalcon.xyz to analyze the attack flow. (Phalcon.xyz is a powerful transaction explorer designed for the DeFi community by BlockSec. Phalcon.xyz has powerful transaction debugging capabilities, it provides comprehensive data on invocation flow, source code, balance changes, and transaction fund flows.)
I admit that this image can be overwhelming at first glance. For the sake of easier comprehension, you can also refer to the version of the diagram with a simple function call without parameters.
Lines 1-6: The attacker initiated a flashloan from Uniswap V3, borrowing 0 USDC and 10,000 WETH.
Starting from Line 7, the attacker invoked the standard interface within his own contract - "uniswapV3FlashCallback" (this is the standard process for executing a flashloan, where anyone borrowing a flashloan completes their desired operations with the borrowed funds and fees within this function before returning the funds + fees to Uniswap).
In Line 12, the attacker approved the ENF_ETHLEV contract (the Vault contract of Earning.Farm) to access his funds. Simultaneously, in Line 16, the current ether amount within ENF_ETHLEV is read.
In Line 62, the attacker deposited an amount of ether equal to the totalAssets of ENF_ETHLEV, resulting in the attacker obtaining half of the LP shares from the entire pool.
In Line 595, the attacker called the "withdraw" function of ENF_ETHLEV, and this is where the reentrancy starts:
In Line 065, the attacker received the ether returned by ENF_ETHLEV and, in Line 068, initiated a reentry into ENF_ETHLEV, requesting a "transfer" of the attacker's LP shares to another account of the attacker.
An important detail to note is that the attacker did not transfer all of the LP shares to the other account; he only transferred LP shares minus 1000.
At this point, we temporarily pause the analysis of the workflow and turn our attention to the source code of ENF_ETHLEV to understand the reasons behind the occurrence of reentrancy.
The contract withdraws the ether in Line 135 before updating the account's LP share balance in Lines 140 and 142. As we've deduced from our earlier analysis, during the withdrawal operation in Line 135, the attacker's "receive()" function has already reentered the ENF_ETHLEV contract, transferring LP shares minus 1000 to another attack account. When the logic reaches Line 140, it realizes that the attacker's account now has only 1000 shares left, so it only burns this small portion.
Although the withdraw() function uses a "nonReentrant" modifier, the attacker didn't re-enter the withdraw function; instead, they re-entered the "transfer" function of the same contract. This renders the "nonReentrant" protection ineffective.
So, what should developers do to avoid this type of reentrancy? One suggestion is to follow the "check-effect-interaction" pattern. In the case of this attack, the code should first execute the checks and burn operations in Lines 140 and 142 before calling the withdrawal functionality in Line 135. This way, there won't be a situation where a user has already withdrawn but can still transfer LP share units through reentrancy and then withdraw again.
Returning to the flow, we notice an unknown call from an unknown contract in Line 73, which is the other attack contract deployed by the attacker. In Line 598, this attack contract receives the LP shares it got earlier (approximately half of the pool). At this point, the pool only has the original 320 ether remaining, so the attacker can withdraw 159 ether.
At this point, the attack is essentially concluded. Afterward, the attacker still needs to repay the flashloan.
Next, we'll replicate the attack process within the Foundry framework. If you're not familiar with Foundry, you can refer to its documentation.
All the code can be found on my GitHub.
Interface: Prepare the necessary interface for ENF_ETHLEV.
Contract Interaction: Set the addresses for WETH, Uniswap V3, ENF_ETHLEV, and Controller contracts.
Fork Blockchain: Request an RPC URL from a node service provider like Alchemy, and set the fork block number to the moment when the attack occurred.
Attack logic is usually written in functions that start with "test." This function will be executed during testing.
Initialize the Exploiter contract and execute the flashloan.
In this function, we'll implement the interaction with ENF_ETHLEV after borrowing 10,000 ETH via flashloan. Essentially, this translates our workflow analysis from part 2 into code.
Withdraw the WETH obtained from the flashloan back to ETH in this account.
Read the existing assets in the ENF_ETHLEV contract.
Deposit an equivalent amount of ETH into the ENF_ETHLEV contract.
Call ENF_ETHLEV's convertToAssets function to read the current LP shares.
Call ENF_ETHLEV's withdraw function (where the reentrancy occurs) to withdraw all ETH.
The Exploiter (another attack contract) executes a withdrawal using the LP shares just received to obtain the corresponding amount of ETH.
Repay the flashloan.
This is the crucial point where the attacker implements the reentrancy. When the Controller attempts to transfer funds to the attacker's contract, the attacker's "receive" function reentrant the contract's "transfer" function, transferring away the LP tokens.
This is the attacker's other contract, which, upon receiving the LP tokens, proceeds to withdraw the corresponding share of ether from ENF_ETHLEV.
Yeah! We successfully replicated the attack!
This section discusses various approaches to achieving the reentrancy attack.
Initially, the attacker borrowed 10,000 ether from Uniswap V3. However, as we later discovered in the analysis, the attacker only used 320 ether. Therefore, we can reduce the amount of borrowed ether to lower the cost. Borrowing 321 ether would yield a profit of 159 ethers:
As observed, this earns 5 ethers more than borrowing 10,000 ethers due to reduced borrowing costs. (Although the hacker might not care about these 5 ethers, right? :))
We noticed that the attacker transferred "share - 1000" to another contract during reentrancy. What's the purpose behind this choice?
Looking at the ENF_ETHLEV contract code, if the attacker were to transfer all shares to another contract, leaving their balance at 0, it shouldn't trigger an error. Then, using another contract to withdraw all the shares, theoretically, the attacker could obtain more ETH than with "share - 1000". Let's implement this.
it turns out that this approach indeed generates slightly more profit than the original.
At this point, we need to introduce a bit of mathematics.
(1) Suppose the original contract's ETH balance is x. The ETH deposited by the attacker is y, and at this point, the LP share owned by the attacker is y/(x+y)
.
(2) The attacker withdraws y ethers and transfers all LP shares to another contract.
(3) The other contract now holds LP shares equal to y/(x+y)
. At this moment, the original contract has x ethers. Therefore, the other contract can withdraw x*y/(x+y)
ethers, which constitutes the attacker's profit.
Now our problem is transformed into:
The Expression: We want to maximize f(y) = x * y / (x + y)
.
The Constraint: We have a fixed value for x.
Maximization: We want to find the value of y that maximizes f(y).
Next, we just need to do some calculus. If you're not interested, feel free to skip directly to the conclusion section :)
Take the derivative of the function with respect to y:
f'(y) = (x*(x + y) - x*y) / (x + y)^2
Set the derivative equal to zero and solve for y:
(x*(x + y) - x*y) / (x + y)^2 = 0
Simplify the equation:
x*(x + y) - xy = 0
x^2 + xy - x*y = 0
x^2 = 0
Solve for y:
Since x^2
is always greater than or equal to zero, there is no real solution for y that would make the derivative equal to zero. This means that the function does not have any critical points where the derivative is zero.
Analyze the sign of the derivative:
Since the derivative is never zero, you need to determine its sign. Plug in a value of y greater than zero (e.g., y = 1) into the derivative:
f'(1) = (x*(x + 1) - x*1) / (x + 1)^2
= (x^2 + x - x) / (x + 1)^2
= x^2 / (x + 1)^2
Since x^2
is always positive and (x + 1)^2
is always positive (as both are squares), f'(1)
is positive. This means that the function is increasing for y > 0.
Determine the behavior as y approaches infinity:
As y approaches infinity, the value of f(y)
approaches x because the x*y
term dominates the denominator (x + y)
. This is because y grows much larger compared to x, and y/(x + y)
becomes negligible.
Maximize the function:
Since the function is increasing for y > 0
and approaches x as y approaches infinity, you can conclude that the maximum value of the function f(y) is x.
In conclusion, to maximize the function x*y / (x + y) with a known value of x, set y to infinity. This will result in the maximum value of x.
Let's go back to the ENF_ETHLEV contract code and find that it has a maximum limit for the amount of ETH deposited by users, set at maxDeposit, which is 500 ether.
So, let's attempt to deposit 500 ether and perform the attack once again.
In this case, our profit would be 188 ether, which is 34 ether higher than the initial 154 ether. This optimization is indeed valuable.
The use of the nonReentrancy modifier does not necessarily prevent reentrancy issues, as reentrancy can occur across different functions within the same contract. It is crucial to adhere to the check-effect-interaction principle whenever possible!
The initial attack flow may not always be optimal. Optimizing both cost and profit could potentially lead to better attack outcomes. This requires a more detailed analysis of the contract, along with the application of certain mathematical tools.
I am thankful for the contribution of @1nf0s3cpt and numerous other web3 developers to the DeFiHackLabs project. I have gained substantial benefits from it, and it stands as the best web3 security learning resource up to this point. Special thanks to @Phalcon_xyz for providing the powerful debugging transaction tool, which significantly facilitates the analysis of attack cases.