区块链骇客第三讲-Call注入攻击

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);

//一系列转账操作...
}
}

攻击思路

  1. 在某些情况下,合约将会预留一个接口函数 callFunc 方便后续操作,我们可以利用 特意构造好的字节数据 传入callFunc接口调用authorityTranser() 转账函数;
  2. 利用特性二: msg.sender 此时会等于 this.address,使我们成功绕过require(this == msg.sender);
  3. 发生一系列转账操作;

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)

预防安全漏洞

  1. call调用的自由度极大,并且call会发生msg值的改变,需要谨慎的使用这些底层的函数;同时在使用时,需要对调用的合约地址、可调用的函数做严格的限制。

  2. call调用会改变msg的值,会修改msg.sender为调用者合约的地址,所以在合约中不能轻易将合约本身的地址作为可信地址。

  3. 如果合约逻辑无法避免跨合约的函数调用,可以采用 new 合约,并指定 function_selector 的方式,指定调用的合约及合约方法,并做好函数参数的检查。

1
2
3
constructor() {
b = new B();
}

🚀扩展阅读

ERC223

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注入攻击的预防判断;


区块链骇客第三讲-Call注入攻击
http://nangbowan.github.io/2022/11/03/区块链骇客第三讲-Call注入攻击/
作者
Science_Jun
发布于
2022年11月3日
许可协议