2018年6月20日,ATN代币团队发布《ATN抵御黑客攻击的报告》,报告指出黑客利用call注入攻击漏洞修改合约拥有者,然后给自己发行代币,从而造成 ATN 代币增发,造成数千万美金的损失。
call()
函数,对于写合约的人来说并不陌生,对合约感兴趣的呢更是必备了解。
📕call( ) 函数的特性
研究一个函数的攻击点,要从它的特性下手。
call()是调用第三方合约函数的底层接口,有两种方式可以调用它:
方式一:call(方法选择器, arg1, arg2, …)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| pragma solidity ^0.8.0;
contract A{
address public owner; constructor(){ owner = msg.sender; } modifier onlyOwner(){ require(msg.sender == owner,"only owner can call this func"); _; }
function useCall() public onlyOwner{
(bool ret,) = B.call(abi.encodeWithSignature("setNum(uint256)", 10,"12")); }
}
contract B { uint256 public num;
function setNum(uint256 _num) public { if(msg.sender != A.owner){ revert(); } num = _num; }
}
|
方式二:call(bytes)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| pragma solidity ^0.8.0;
contract A{
address public owner; constructor(){ owner = msg.sender; } modifier onlyOwner(){ require(msg.sender == owner,"only owner can call this func"); _; }
function useCall() public onlyOwner{
(bool ret,) = B.call(bytes(keccak256("setNum(uint256)")),10,"12"); }
}
contract B { uint256 public num;
function setNum(uint256 _num) public { if(msg.sender != A.owner){ revert(); } num = _num; }
}
|
以上两个例子都是在合约A中使用call
调用合约B的func()函数。
需要注意的是,func()函数只接受一个参数_num,但在调用中我们填入了数字10和字符串”12”这两个参数,但call()将会识别过滤掉多余的参数,将数字10作为调用参数。
这便是该call的第一个特性!
⚽特性一
call()可以接受任何长度、任何类型的参数,其传入的参数会被填充至 32 字节最后拼接为一个字符串序列,由 EVM 解析执行;
并且call()函数能够自动过滤掉多余的参数。
再回到上面的两个例子,在setNum()函数中我加了一个判断 if(msg.sender != A.owner)
来确定调用者是否为合约B的拥有者,实际上调用者一定会是合约B的拥有者,这便是call()的第二个重要特性。
⭐特性二(重要)
在call()调用的过程中,Solidity中的内置变量 msg
会随着调用的发起而改变,msg
保存了调用方的信息包括:
- 调用发起的地址(msg.sender)
- 交易金额(msg.value)
- 被调用函数标识符(msg.sig)
使用call()进行跨合约的函数调用后,内置变量 msg
的值会修改为调用者,执行环境为被调用者的运行环境。
利用call( ) 特性
利用call函数的特性,可以在特定的场景中触发巨大安全漏洞。
🥁经典攻击模型——权限绕过
这是一个非常经典的攻击模型(现实中将会复杂数倍,但结构相同);
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| contract CallBug{
function callFunc(bytes data) public{ this.call(data); } //内部转账函数 function authorityTranser(uint256 _amount) internal{ //该方法要求调用者是本合约 require(this == msg.sender); //一系列转账操作... } }
|
攻击思路
- 在某些情况下,合约将会预留一个接口函数
callFunc
方便后续操作,我们可以利用 特意构造好的字节数据 传入callFunc
接口调用authorityTranser()
转账函数;
- 利用特性二:
msg.sender
此时会等于 this.address
,使我们成功绕过require(this == msg.sender);
- 发生一系列转账操作;
example
调用callFunc传入参数:bytes(keccak256("authorityTranser(uint256)"), 1)
🥁经典攻击模型——代币窃取
1 2 3 4 5 6 7 8 9 10 11 12
| contract CallBug{ function transfer(address _to, uint256 _value) public { require(_value <= balances[msg.sender]); balances[msg.sender] -= _value; balances[_to] += _value; }
function callFunc(bytes data) public { this.call(data); //this.call(bytes4(keccak256("transfer(address,uint256)")), target, value); //利用代码示意 } }
|
攻击思路同上
example
调用callFunc传入参数:bytes(keccak256("authorityTranser(uint256)"), 1)
预防安全漏洞
call调用的自由度极大,并且call会发生msg值的改变,需要谨慎的使用这些底层的函数;同时在使用时,需要对调用的合约地址、可调用的函数做严格的限制。
call调用会改变msg的值,会修改msg.sender为调用者合约的地址,所以在合约中不能轻易将合约本身的地址作为可信地址。
如果合约逻辑无法避免跨合约的函数调用,可以采用 new
合约,并指定 function_selector
的方式,指定调用的合约及合约方法,并做好函数参数的检查。
1 2 3
| constructor() { b = new B(); }
|
🚀扩展阅读
ERC223标准中解决了很多ERC20标准中一些潜在的问题,同时该标准也代入了Call注入问题。
1 2 3 4 5 6 7 8 9
| //例如ERC223的转账函数 function transfer(address to,uint value,bytes data,string custom_fallback) public returns (bool success){ _transfer(msg.sender,to,value,data);
if(isCoontract(to)){ ContractReceiver rx = ContractReceiver(to); require(address(rx).call.value(0)(bytes4(keccak256(custom_fallback)),msg.sender,value,data),"not success!"); } }
|
其中 require(address(rx).call.value(0)(bytes4(keccak256(custom_fallback)),msg.sender,value,data),"not success!");
就是对call注入攻击的预防判断;