文章目录
2018年6月20日,ATN代币团队发布《ATN抵御黑客攻击的报告》,报告指出黑客利用call注入攻击漏洞修改合约拥有者,然后给自己发行代币,从而造成 ATN 代币增发,造成数千万美金的损失。
call()
函数,对于写合约的人来说并不陌生,对合约感兴趣的呢更是必备了解。
研究一个函数的攻击点,要从它的特性下手。
📕call() 函数的特性
call()是调用第三方合约函数的底层接口,有两种方式可以调用它:
方式一:call(方法选择器, arg1, arg2, …)
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)
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函数的特性,可以在特定的场景中触发巨大安全漏洞。
- 一是权限绕过
- 二是代币窃取
🥁经典攻击模型——权限绕过
这是一个非常经典的攻击模型(现实中将会复杂数倍,但结构相同);
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)
🥁经典攻击模型——代币窃取
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
的方式,指定调用的合约及合约方法,并做好函数参数的检查。
constructor() {
b = new B();
}
🚀扩展阅读
ERC223
ERC223标准中解决了很多ERC20标准中一些潜在的问题,同时该标准也代入了Call注入问题。
//例如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注入攻击的预防判断;