Among various security issues in smart contracts, the reentrancy vulnerability has always been the most harmful and occurs frequently. The vulnerability is caused by the contract not properly managing the order of changes to state variables when processing function calls. The most famous reentrancy exploit was the 2016 The DAO attack, which led to a fork of Ethereum. At present, due to the improvement of developers’ security awareness, most projects use reentrancy locks to prevent reentrancy vulnerabilities and reduce security risks. However, in the DeFi field, although the contract uses reentrancy locks in some key functions, it is still possible to be attacked by read-only reentrancy vulnerabilities.
Introduction to Read-Only Reentrancy Vulnerability
The read-only reentrancy vulnerability is a special case of the reentrancy vulnerability. The location where the vulnerability occurs is the view function in the smart contract. Since this type of function does not modify the state variables of the contract, in most cases this type of function It will not be decorated with reentrancy locks. When the victim contract calls the view function of the vulnerable contract, since the state variables used in the view function have not been updated at this time, the data obtained by the victim contract through the view function is also not updated. If the victim contract relies on the view function to return value, there may be abnormal situations, such as abnormal calculation of collateral prices and incorrect calculation of rewards.
The following is an analysis of the read-only reentrancy vulnerabilities in two famous DeFi protocols, Curve and Balancer. The Curve protocol focuses on stablecoin trading between cryptocurrencies such as USDC, DAI, USDT, etc. The Balancer protocol is designed to allow users to create their asset portfolios and can be used as liquidity pools for other users to trade..The read-only reentrancy vulnerabilities in these two protocols were discovered by ChainSecurity, and we greatly admire their contributions to WEB3 security.
Curve vulnerability analysis
Curve is a decentralized exchange. Users can provide liquidity assets to the liquidity pool to obtain a corresponding share of LP tokens, which represent the share in the liquidity pool. When the user withdraws liquidity, the contract will burn LP tokens and send the corresponding liquidity assets to the user. At the same time, the contract provides a function
get_virtual_price to calculate the virtual value of LP tokens. The calculation formula is to divide D by the total supply of LP tokens. D can be simply understood as the total value of liquidity tokens. The function is implemented as follows:
Users can remove liquidity by calling the
remove_liquidity function of the contract after adding liquidity:
The function first gets the total supply of LP tokens, calculates how much liquidity assets the user can withdraw, subtracts the corresponding amount from the asset balance of the liquidity pool, and then sends it to the user. If it is ETH, then calls the low-level
call function to send, if it is an ERC20 token, use its transfer function to send, and finally burn the LP token.
Here, when the liquid asset is ETH, it will use
call to send ETH, and enter the logic of the
fallback function of msg.sender. It seems that there are points that can be exploited, but the key functions in the contract such as
remove_liquidity and so on have used the reentrancy lock, can not be reentrant. Going back to the
remove_liquidity function, since the state update is not completed synchronously, when entering the
fallback function of msg.sender, the LP tokens have not been burned yet, but the balance in the liquidity pool has been deducted, so the balance will becomes smaller while TotalSupply has not changed. Looking at the functions that use these two state variables in the contract, it can be found that the
get_virtual_price function is affected, and this function is a view type function that does not use the reentrancy lock. If this function is called during reentrancy , the final result will be smaller than the normal value, that is, the price of LP tokens will be reduced.
If the external contract relies on the result returned by the
get_virtual_price function in Curve for logic processing, it may be affected by this read-only reentrancy vulnerability.
Attack Case Analysis
On February 9, 2023, the DeFi protocol dForcenet suffered a hack, and the root cause of the attack was the use of the return value of the
get_virtual_price function of the Curve liquidity pool by its oracle.
The attacking transaction is: https://arbiscan.io/tx/0x5db5c2400ab56db697b3cc9aa02a05deab658e1438ce2f8692ca009cc45171dd
Attack process analysis:
First, a large amount of WETH was borrowed through a flash loan, and then liquidity was added to the Curve ETH/wstETH pool using the
add_liquidity function, obtaining wstETHCRV:
Then, part of the wstETHCRV was transferred to another attacking contract, and wstETHCRV-gauge and USX were borrowed by depositing wstETHCRV-gauge in the wstETHCRV-gauge contract:
remove_liquidity function of the Curve ETH/wstETH pool was called to remove liquidity. When removing liquidity, the logic entered the
fallback function of the attacking contract. Due to dForcenet's oracle using the
get_virtual_price function of the Curve ETH/wstETH pool, under the current reentrant state, since the balance of ETH in the pool has decreased but the total supply of wstETHCRV has not changed, the obtained virtual price will be smaller. Therefore, the attacker liquidated another attacking contract and a user's loan in the
Finally, the attacker exchanged wstETHCRV-gauge for WETH through a series of operations, returned the flash loan, and made a profit of 1236 ETH and 710,000 USX tokens.
The implementation of the oracle in dForcenet:
It can be seen that the
getPrice function calls the
get_virtual_price function of the Curve liquidity pool and performs multiplication processing.
Balancer Vulnerability Analysis
Users can create liquidity pools for a range of assets via the Balancer protocol and earn fees by facilitating trades from other users. Users can add liquidity to the created liquidity pool through the
joinPool function of the Balancer: Vault contract, or withdraw liquidity through the
exitPool function. Both functions are implemented by calling the built-in function
_joinOrExit, but the type of operation passed in is different. The
_joinOrExit function is implemented as follows:
The function first checks the input parameters, and then gets the balances of tokens in the corresponding liquidity pool. Next, the
_callPoolBalanceChange function will be called to calculate the balance, transfer assets, and pay fees:
The function will call the
onExitPool function of the liquidity pool based on the operation type, and then perform asset transfers. When removing liquidity, the function will call
_processExitPoolTransfers to transfer assets to the user. The implementation is as follows:
The function calls
_sendAsset to transfer assets. If the token is WETH, it will be converted to ETH and then sent using
call to send ETH, if the recipient is a contract and has implemented the
fallback function, the logic will continue to the
After executing the
_callPoolBalanceChange function, the function will modify the balance according to the pool type by calling the corresponding function:
The logic of the function is over here. It can be seen that there is a problem where the transfer is performed first, and then the balance is updated. If there is ETH in the assets, it will trigger the
fallback function of the receiver. At this point, the transfer has already been completed, but the internal balance of the contract has not been updated yet. Therefore, we are looking for potential vulnerabilities in the contract and finding that all key functions use reentrancy lock to prevent such attacks.
Although no vulnerabilities are found in the write function, we have discovered that the
getPoolTokens function, which is of the view type, uses the internal balance of the contract and is not protected by a reentrancy lock because it does not modify any state variables.
As a result, if an external contract calls the
getPoolTokens function of the Balancer: Vault contract and uses its return value for further processing, it may be vulnerable to a read-only reentrancy attack.
Attack Case Analysis
The project Sentiment on Arbitrum suffered an attack, resulting in a loss of $1 million.The root cause of this attack was that Sentiment’s oracle used the data returned by the
getPoolTokens function in the Balancer: Vault contract, which has a known read-only reentrancy vulnerability.
The attacking transaction is: https://arbiscan.io/tx/0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d
Attack process analysis:
The attacker borrowed 606 WBTC, 10,050 WETH, and 18,000,000 USDC through a flash loan, then created an account through Sentiment and deposited 50 WETH into it. Using this account, they deposited the 50 WETH into Balancer: Vault:
After that, the attacker provided liquidity to the pool by calling the
joinPool function of the Balancer: Vault contract through the attack contract, depositing 606 WBTC, 10,000 WETH, and 18,000,000 USDC. Then, he removed liquidity by calling the
exitPool function and triggered the
fallback function of the attack contract when sending ETH:
fallback function, the attacker was calling the borrow function to borrow funds. This function was using
RiskEngine.isAccountHealthy to check the account health, and the
getPrice function of the oracle was obtained by
WeightedBalancerLPOracle.getPrice. This function was also using the data returned by the
getPoolTokens function of the Balancer: Vault contract. At this point, since the logic was still in the
fallback function of the attack contract, the quantities of WBTC, WETH, and USDC in the liquidity pool had not been updated. Therefore, the price of the oracle was 16 times larger than before, allowing the attacker to borrow more assets using the 50 WETH:
The attacker then repeatedly called the
exec functions to borrow other assets and, after repaying the flash loan, made a profit of 0.5 WBTC, 30 WETH, 538,399 USDC, and 360,000 USDT:
Check out the
getPrice function of the WeightedBalancerLPOracle contract:
It can be seen that after the function get the token balance of the corresponding liquidity pool through the
getPoolTokens function of the Balancer.vault contract, the token balance will be multiplied in the subsequent logic. The unupdated token balance will cause here the results are magnified.
This article briefly introduces the concept of read-only reentrancy vulnerabilities, and analyzes the read-only reentrancy vulnerabilities in two top DeFi, as well as the corresponding real attack cases. When developing a DeFi project, the possible integration of other projects should be considered, and various security measures should be taken to reduce the harm caused by reentrancy vulnerabilities. If necessary, view-type functions can also be protected by reentrancy locks to enhance security.
At Eocene Research, we provide the insights of intentions and security behind everything you know or don’t know of blockchain, and empower every individual and organization to answer complex questions we hadn’t even dreamed of back then.