重入漏洞(Reentrancy vulnerability)
重入漏洞是调用外部合约时最严重的威胁之一,被调用的智能合约可能会夺取对操作流的控制,这意味着被调用的合约可以在当前智能合约中实施调用函数预料之外的更改。这种操作通常通过重定向控制流来实现,从而有效地将被调用方转换为调用方。这个循环可以重复,也就意味着恶意合约可以一次又一次地进入系统,这就是重入漏洞。
ETH transfers
当ETH被转移到一个合约地址时,将会触发receive或者fallback函数。攻击者可以在fallback方法中写入任何任意逻辑,这样每当合约接收到一个传输时,该逻辑就会执行。
1、单一函数可重入(Single-Function Reentrancy)
一个函数在执行过程中,攻击者可以利用智能合约的可重入性反复调用某个函数,导致合约的逻辑或状态被破坏。以下是臭名昭著的DAO攻击:
contract Vulnerable {
mapping (address => uint) private balances;
function withdraw() public {
uint amount = balances[msg.sender];
(bool success, ) = msg.sender.call.value(amount)("");
require(success);
balances[msg.sender] = 0;
}
}
在这个例子中,这个外部调用的目的是给msg.sender传输ETH。由于用户余额映射在外部调用后才更新,攻击者的fallback函数会再次调用withdraw函数,转移更多的资金,直到耗尽智能合约资金或者执行达到最大堆栈大小才会停止。在最后一步,攻击者只需要检查目标智能合约的整体ETH余额,以确保攻击者不会提取超过合约拥有的ETH,因为提取过度会导致整个交易回滚。此外,攻击者需要考虑执行深度以及gas成本,以免在重复调用中耗尽gas。
上述代码可以通过先更新状态再调用来进行修复,如下所示:
contract Vulnerable1 {
mapping (address => uint) private balances;
function withdraw() public {
uint amount = balances[msg.sender];
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call.value(amount)("");
require(success);
}
}
然而,这个解决方案仍存在一个潜在的问题,如果另一个函数调用withdraw,它可能会受到同样的攻击。因此,任何函数在调用不受信任的合约时应该被视为不受信。
2、跨函数可重入(Cross-Function Reentrancy)
如果一个易受攻击的函数与另一个攻击者所期待的函数共享状态时,就有可能发生跨函数重入攻击。
contract Vulnerable2 {
mapping (address => uint) private balances;
//允许一个账户将代币转移到另一个账户
function transfer(address to, uint amount) public {
if (balances[msg.sender] >= amount) {
balances[to] += amount;
balances[msg.sender] -= amount;
}
}
function withdraw() public {
uint amount = balances[msg.sender];
(bool success, ) = msg.sender.call.value(amount)("");
require(success);
balances[msg.sender] = 0;
}
}
在上述这个例子中,攻击者最初可以调用withdraw函数,一旦被调用进行价值转移,攻击者就可以重新进入合约,但这次调用的是transfer函数。当攻击者通过transfer函数回到合约时,withdraw的调用还没有结束,msg.sender的余额映射没有被设置为0。结果而言,攻击者可以将资金转移一个他们控制的账户地址,然后使用该地址重复此过程。这种类型的漏洞在2016年引发了THE DAO事件。
3、跨合约漏洞(Cross-contract Reentrancy)
前面描述的漏洞并不严格限于单个合约中的共享状态和函数。例如,一个余额映射被设置为public,或者通过view函数直接或间接公开,并且另一个合约也依赖这个状态,则该漏洞仍可以被成功执行。这在高度模块化的智能合约系统中变得尤为重要,其中包含复杂的业务逻辑规则。在这些情况下,跨合约重入可能不那么明显,因此更加危险。
4、只读重入(Read-only Reentrancy)
只读重入是跨合约重入的一个具体实例。这种类型漏洞出现在当一个智能合约的行为依赖于另一个合约的状态。寻找可重入漏洞的攻击者通常关注于状态改变的函数,然而,在跨合约重入的背景下,views可能产生过时的状态信息。这种情况可能会导致第三方设施的利用。
下面的示例提供一个允许用户存款和取款的银行合约:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Bank is ReentrancyGuard {
mapping (address => uint) public balances;
//允许用户通过向合约发送ETH来增加其账户中的余额,msg.value表示随同此交易发送的ETH数量。
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() public nonReentrant {
uint amount = balances[msg.sender];
//允许用户提取其账户中的所有ETH余额
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success);
balances[msg.sender] = 0;
}
}
这个bank合约通过使用OpenZeppelin's ReentrancyGuard来保护自己不受重入问题的影响。然后,一个第三方的合约为了它自己的业务逻辑来使用这个bank合约公开可用的balances映射,管理用户在银行的投资管理股票:
//从bank合约中查询特定账户得余额
contract BankConsumer {
Bank private bank; //私有的Bank类型变量,用于存储Bank合约的实例
constructor(address _bank) {
bank = Bank(_bank); //在构造函数中初始化bank变量
}
function getBalance(address account) public view returns (uint256) {
return bank.balances(account); //返回指定账户的余额
}
}
bank合约的withdraw函数收到了保护来防止重入攻击的发生,但这种保护只适用于合约自身,而不适用于其他系统。withdraw函数的外部调用的执行流可以被攻击者接管,攻击者然后制造一个智能合约与其他项目交互,呈现出一种误导性平衡。
BankConsumer合约中有一个公开的函数,这个函数用于分配股份和检查,防止用户清算超过他们拥有的股份。同时,bank合约暴露的过时的余额可以通过设计一个恶意的receive函数进行利用:
contract Attacker {
event Checkpoint(uint256 balance);
Bank private bank;
BankConsumer private consumer;
constructor(address _bank, address _consumer) payable {
bank = Bank(_bank);
consumer = BankConsumer(_consumer);
}
function attack() public {
emit Checkpoint(consumer.getBalance(address(this)));
bank.deposit{value: 1 ether}();
bank.withdraw();
emit Checkpoint(consumer.getBalance(address(this)));
}
receive() external payable {
emit Checkpoint(consumer.getBalance(address(this)));
// more malicious code here
}
}
Attacker合约记录CheckPoint事件,说明BankConsumer合约的可见余额是过时的。当这个合约被部署到Remix并执行attack函数,Attacker合约的事件将会被触发:
[
{
"from": "0xE3Ca443c9fd7AF40A2B5a95d43207E763e56005F",
"topic": "0xde5ae8a37da230f7df39b8ea385fa1ab48e7caa55f1c25eaaef1ed8690f36998",
"event": "Checkpoint",
"args": {
"0": "0",
"balance": "0"
}
},
{
"from": "0xE3Ca443c9fd7AF40A2B5a95d43207E763e56005F",
"topic": "0xde5ae8a37da230f7df39b8ea385fa1ab48e7caa55f1c25eaaef1ed8690f36998",
"event": "Checkpoint",
"args": {
"0": "1000000000000000000",
"balance": "1000000000000000000"
}
},
{
"from": "0xE3Ca443c9fd7AF40A2B5a95d43207E763e56005F",
"topic": "0xde5ae8a37da230f7df39b8ea385fa1ab48e7caa55f1c25eaaef1ed8690f36998",
"event": "Checkpoint",
"args": {
"0": "0",
"balance": "0"
}
}
]
从第二个CheckPoint事件可以看出,通过receive函数,尽管资金已经转移给了attacker,但bank合约仍然为Attacker合约显示了1 ETH的余额。这些过时的信息可以被操纵来误导建立在银行合同上的第三方基础设施,比如BankConsumer。