一、前言

上一篇文章中我们讲述了以太坊中智能合约的详细概念,DApp应用等。除此之外,我们还讲述了EVM虚拟机硬性限制安全方面的相关,包括了一些变量类型、Gas限制以及常见的调用堆深度限制问题。而上述的问题虽然常见,但是利用的空间并不是太大,属于开发人员对函数底层了解的不够充分导致的。而在本文中,我们需要向大家提出一个用途非常广泛的函数,而此函数由于在代码中出现的次数十分频繁,所以其安全性极为重要。而本文通过模型分析、攻击案例的分析已经一些开发点出发,向开发人员与安全分析人员提供一些攻击参考,也帮助大家在开发相应应用的过程中对其有所注意。

二、Call函数简介

简单来说,call()是一个底层的接口,用来向一个合约发送消息,在平常的应用实例中,我们会经常看到这个函数的使用。例如test.call("abc", 123)。test为函数中调用此call方法的用户,abc为调用的方法名称,而123为传入的参数值。如果你想实现自己的消息传递,可以使用这个函数。call()函数支持传入任意类型的任意参数,并将参数打包成32字节,相互拼接后向合约发送这段数据。

调用模型如下:

<address>.call(...) returns (bool)

而根据上文我们大致对其有所了解,call函数是用于在一个函数中调用另外函数而设计的。我们知道,在信息安全领域中,无论是web端还是逆向范畴中,倘若存在命令执行的入口,那么此处就需要我们重点的对待。比如web中通过传入小码而放入xxx.php文件,由此可以令apache服务器对其直接进行执行操作从而获得我们想要的结果。对于区块链系统也是如此,此处的call()函数就为我们提供了一个执行命令的接口,我们通过这个接口使系统执行自己的函数从而转账。下面我们具体来看call()函数的一些特性。

在call函数调用的过程中,Solidity中的内置变量 msg 会随着调用的发起而改变,msg 保存了调用方的信息包括:调用发起的地址,交易金额,被调用函数字符序列等。

使用call函数进行跨合约的函数调用后,内置变量 msg 的值会修改为调用者,执行环境为被调用者的运行环境(合约的storage)。

例如我调用如下函数:

pragma solidity ^0.4.0;

contract A {
    address public temp1;
    uint256 public temp2;

    function Testcall(address addr) public {
        temp1 = msg.sender;
        temp2 = 100;
        addr.call(bytes4(keccak256("test()")));
    }
}

contract B {
    address public temp1;
    uint256 public temp2;

    function test() public  {
        temp1 = msg.sender;
        temp2 = 200;
    }
}

而调用后我们得到,在A合约中,msg.sender = address(调用者) ;在A合约中调用B合约的函数,函数中, msg.sender = address(A合约地址) 。

同时,在A合约中调用B合约的函数,调用的运行环境是被调用者的运行环境,即是B合约的运行环境。

三、攻击模型

我们想要执行以太坊中某个系统函数,需要从一个合约调用另外一个合约的方法。这里我们可以以web浏览器进行类比,被调用的合约看作webserver,而调用的msg则是http数据,EVM底层通过ABI规范来解码参数,获取方法选择器,然后执行对应的合约代码。

而在Solidity语言中,我们可以通过call方法来实现对某个合约或者本地合约的某个方法进行调用。

例如根据文档所示我们有如下调用方法:

<address>.call(调用方法, 参数1, 参数2, …)  
<address>.call(bytes)

我们可以通过使用call函数传入调用方法与参数实现功能,除此之外,我们还可以自行构造传入bytes值来执行相应函数。

Solidity编程中,一般跨合约调用执行方都会使用msg.sender全局变量来获取调用方的以太坊地址,从而进行一些逻辑判断等。通常情况下合约通过 call 来执行来相互调用执行,由于 call 在相互调用过程中内置变量 msg 会随着调用方的改变而改变,这就成为了一个安全隐患,在特定的应用场景下将引发安全问题。

1 call函数参数问题

在讨论攻击模型前,有同学会问:函数里面不是有参数吗?那么攻击者又不能改变参数的个数,那你怎么能够做到传入的参数数量正好是原来函数的数量呢?难道你必须要拿到函数的最高权限吗?

下面我们就根据Solidity函数的约束来讲述一下我们如何解决这个问题。

pragma solidity ^0.4.4;

contract A {
    uint256 public aa = 0;

    function test(uint256 a) public {
        aa = a;
    }

    function callFunc() public {
        this.call(bytes4(keccak256("test(uint256)")), 10, 11, 12);
    }
}

我们看上面的函数,方法callFunc()中使用了call()函数用来调用上述的test()方法。而我们发现下面的callFunc()函数传入了10、11、12三个参数,而我们上述test()函数中只有一个传入的参数,例子中 test()函数仅接收一个 uint256 的参数,但在callFunc() 中传入了三个参数,由于 call 自动忽略多余参数,所以成功调用了 test()函数。

根据此Solidity的特性,我们也就对参数的个数不用过多考虑,仅仅需要调用攻击即可。

2 权限绕过模型

我们知道在Solidity编程中我们都会使用msg.sender全局变量来获取调用方的以太坊地址。并且使用msg.sender来作为扣款方。

function transfer(address  to, uint256 value) returns (bool success) {

    balances[msg.sender]-=  value;
    balances[to] +=  value;

}

下面我们看一个身份认证函数:

function isAuth(address src) internal view returns (bool) {
    if (src == address(this) || src == owner) {
        return true;
    }
    else {
        return false;
    }
}

此身份认证函数用以验证调用函数一方是否是合约拥有者或者合约本身。倘若是则返回真,否则返回假。

function callFunc(bytes data) public {
    this.call(data);
}

function withdraw(address addr) public {
    if(!isAuth(msg.sender))  throw;
    addr.transfer(this.balance);
}

上面的函数中,我们写了withdraw()函数,而此函数中包括了身份验证模块。倘若验证失败,则直接退出。所以作为攻击者,我们应该想办法绕过这个函数。这个时候我们就要借助上面的callFunc()函数了。

由于data中的值是我们自己构造的,所以我们可以传入我们的攻击代码。我们看如下,倘若我们传入:data = bytes4(keccak256("withdraw(address)")), target,那么意味着我们将执行this.call(bytes4(keccak256("withdraw(address)")),攻击者地址);。由于我们知道函数的执行环境为被调用者的运行环境,也就是系统本身的环境,所以我们利用系统内部来执行了函数并调用了其他函数。而这样我们的调用者自然而然就是合法的,也就是说我们绕过了系统函数的安全验证。从而将钱转账到我们自己的账户。

下面我们看一道利用bytes值传入参数的例子:

fuction CPcall(address A, uint256 value, bytes Data) public
{       
....
    if(!A.Call(Data)){
    revert();
    }
....
}

下面我们看一下内部的转账函数:

fuction transfer(address to, uint256 value) public tansferAllowed(msg.sender) returns {
...
    Transfer(msg.sender, to, value);
...
}

CPcall()函数中,我们利用了call函数,比并且其参数Data是我们传入的bytes值。而此值我们可以进行控制,所以我们完全可以传入调用函数transfer(),并且在传入参数中将to设置为攻击者自己的地址。这样不仅绕过了tansferAllowed(msg.sender),还攻击了系统,将以太币传给自己。

3 代币方法器注入攻击

在上面我们利用bytes类型的变量传入了攻击利用代码,而倘若我们没有办法进行关键参数的传入工作,而只能传入函数名应该怎么办?

例如:

function CPcall(address _to, uint _value, bytes data, string fallback){
        ...
         assert(_to.call(bytes4(keccak256(fallback)),msg.sender, _value, _data)) ;
        ...
}

上述函数中传入了四个变量,而加入我们能操控的变量只有fallback,而valuedata我们无法自行传入。我们应该如何调用下一步的操作?

这里我们写出转账函数的内容:

function transfer(address a, uint value) public returns (bool success)
{
    trans(msg.sender, a, value);//向a转账value数量的钱
}

倘若如此,我们就可以利用call来进行内部调用转账函数来达到偷币的效果。

我们可以仅仅将fallback的值修改为transfer,并且使函数CPcall()中的断言函数中调用转账函数。不过同学会问,上一个函数中的参数有三个,而下面的只有两个,那不会不匹配吗?我们这里知道,其实这个函数同样会执行成功的。这是因为EVM在获取参数的时候没有参数个数校验的过程,因此取到前两个参数之后,就把最后一个参数_data给截断掉了,在编译和运行阶段都不会报错。

四、攻击案例

ATN代币增发

2018.5.11,ATN 技术人员收到异常监控报告,显示 ATN Token 供应量出现异常,通过分析发现 Token 合约由于存在漏洞受到攻击。由于 ATN 代币的合约中的疏漏,该事件中 call 注入不但绕过了权限认证,同时还可以更新合约拥有者。

本次事件的原因是由于在 ATN 项目中使用到了 ERC223ds-auth库,两个库在单独使用的情况下没有问题,同时使用时就会出现安全问题。

在ERC223 标准中定义了自定义回调函数:

pragma solidity ^0.4.13;

contract ERC223 {
    function transfer(address to, uint amount, bytes data) public returns (bool ok);

    function transferFrom(address from, address to, uint256 amount, bytes data) public returns (bool ok);

    function transfer(address to, uint amount, bytes data, string custom_fallback) public returns (bool ok);

    function transferFrom(address from, address to, uint256 amount, bytes data, string custom_fallback) public returns (bool ok);

    event ERC223Transfer(address indexed from, address indexed to, uint amount, bytes data);

    event ReceivingContractTokenFallbackFailed(address indexed from, address indexed to, uint amount);
}

下面为函数的具体详情:源地址在ERC233

/*
     * ERC 223
     * Added support for the ERC 223 "tokenFallback" method in a "transfer" function with a payload.
     */
    function transferFrom(address _from, address _to, uint256 _amount, bytes _data, string _custom_fallback)
        public
        returns (bool success)
    {
        // Alerts the token controller of the transfer
        if (isContract(controller)) {
            if (!TokenController(controller).onTransfer(_from, _to, _amount))
               throw;
        }

        require(super.transferFrom(_from, _to, _amount));

        if (isContract(_to)) {
            ERC223ReceivingContract receiver = ERC223ReceivingContract(_to);
            receiver.call.value(0)(bytes4(keccak256(_custom_fallback)), _from, _amount, _data);
        }

        ERC223Transfer(_from, _to, _amount, _data);

        return true;
    }

我们重点看

if (isContract(_to)) {
        ERC223ReceivingContract receiver = ERC223ReceivingContract(_to);
        receiver.call.value(0)(bytes4(keccak256(_custom_fallback)), _from, _amount, _data);
    }

ds-auth
) 权限认证和更新合约拥有者函数如下:

function setOwner(address owner_) public auth {
    owner = owner_;
    emit LogSetOwner(owner);
}

...

modifier auth {
    require(isAuthorized(msg.sender, msg.sig));
    _;
}

function isAuthorized(address src, bytes4 sig) internal view returns (bool) {
    if (src == address(this)) {
        return true;
    } else if (src == owner) {
        return true;
    } else if (authority == DSAuthority(0)) {
        return false;
    } else {
        return authority.canCall(src, this, sig);
    }
}

而我们根据两个核心代码可以进行分析:

首先攻击者通过调用transferFrom() 函数,并将自己的钱包地址作为_from 参数,ATN 合约的地址作为 _to 参数。然后传入setOwner作为回调函数。在执行的过程中,由于上文我们可知,当参数数量大于调用函数的参数数量时,后面的参数自动被忽略。之后call 调用已经将 msg.sender转换为了合约本身的地址,也就绕过了 isAuthorized()的权限认证,将拥有者改为了自己。之后攻击转账后再次调用setOwner()将权限还原,不留痕迹。

五、总结

根据上文,我们可以知道call()函数存在许许多多的问题。我们总结下来大致有以下一些问题,因为call函数特殊的性质导致其能够调用其他函数。而根据我们信息安全的思维来看,能执行命令那么久会存在安全隐患。所以我们在未来开发的时候要严格对此函数进行过滤以及包装。我们总结下来,在之后的虚拟机设计中可以遵循以下原理:

  • 1 简单性:尽可能少、尽可能底层的操作码,尽可能少的数据类型,尽可能少的虚拟机层次结构。

  • 2 完全确定性:VM规范的任何部分不能存在歧义,结构需要是完全正确的。此外,应该有相应的计算步骤来记录Gas的消耗。

  • 3 空间节省:EVM组件需要尽可能的紧凑。

  • 4 简单的安全性:为了使VM不会被攻击,应该很容易得出智能合约运行所需要消耗的“燃料”成本。

六、参考资料

本稿为原创稿件,转载请标明出处。谢谢。

点击收藏 | 0 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖