address漏洞
上周帮一个朋友看他的智能合约,部署在BSC上,一个简单的质押挖矿。代码逻辑没问题,审计报告也有。但我在读他前端调用的时候发现了一个细节——合约里有个withdraw函数,参数是address payable _to。前端传参时,用户地址是从window.ethereum.selectedAddress拿的,没做任何校验。这意味着什么?任何人只要在控制台改一下这个值,就能把合约里其他人的质押奖励全部转到自己地址。
这不是个例。address漏洞的可怕之处在于,它往往藏在最不起眼的地方。你以为参数类型是address就安全了?EVM只检查它是不是20字节,不检查这个地址到底是谁。更麻烦的是,很多开发者把address当成一个普通字符串在处理,忘了它背后是一个有余额、能调用合约、能接收回调的实体。
function batchTransfer(address[] memory _recipients, uint256 _amount) public {
for (uint i = 0; i < _recipients.length; i++) {
require(token.transfer(_recipients[i], _amount));
}
}
这段代码在2018年让BEC代币归零。攻击者传了两个地址——一个是自己的,另一个也是自己的,但_amount设成了2^255。第一次转账溢出,第二次转账溢出,两个溢出相加刚好抵消,账面上代币总量没变,但攻击者凭空拿到了天量代币。问题出在address数组的长度上吗?不是。是_amount和address的组合运算没有做边界检查。address在这里成了放大器——你传多少个地址,就放大多少倍风险。
还有一个更隐蔽的。Solidity里address和address payable的转换。早期版本允许address直接.transfer(),后来编译器强制要求payable。但有人为了省gas,用assembly里的call,手动传value。结果呢?目标地址是个合约,它的fallback函数里写了revert()。你的call返回false,但你没检查返回值。钱扣了,对方没收到,卡在合约里。address在这里变成了一个黑洞——你知道它在那儿,但不知道它会不会吞掉你的ETH。
我见过最离谱的一个案例,是某NFT项目的白名单校验。合约里存了一个mapping(address => bool),前端用ethers.utils.getAddress()把用户输入的地址转成checksum格式,然后去查mapping。问题来了:getAddress()会做大小写校验,如果用户输入的是全小写,它会自动算出正确的checksum。但如果用户输入的是全大写呢?getAddress()直接抛异常。这本来不算漏洞,但项目方在合约里也用了keccak256(abi.encodePacked(_addr))去算白名单哈希,而前端传的是getAddress()处理后的checksum地址。两者对不上。结果就是:所有正常用户都mint不了,只有那些手动构造了全小写地址、绕过前端校验的人才能成功。address在这里变成了一个格式陷阱——你以为你在校验,其实你在制造不一致。
说到底,address漏洞的根源不是技术,是思维惯性。我们太习惯把地址当成一个“标识符”,忘了它在EVM里是一个“对象”。对象就有状态、有行为、有副作用。你传一个address进去,可能触发一个receive(),可能消耗所有gas,可能重入,可能改变整个合约的余额分布。每次我看到address出现在函数参数里,第一反应不是“这是个地址”,而是“这是个入口”。入口后面是什么,取决于你有多不信任它。
朋友那个合约最后改了,加了个require(_to == msg.sender)。简单,粗暴,但有效。address漏洞不需要多复杂的攻击链,有时候就是少写了一个require。你写合约的时候,多问一句:这个地址,真的应该是这个地址吗?