上一篇文章中我们讲述了以太坊中智能合约的详细概念,DApp应用等。除此之外,我们还讲述了EVM虚拟机硬性限制安全方面的相关,包括了一些变量类型、Gas限制以及常见的调用堆深度限制问题。而上述的问题虽然常见,但是利用的空间并不是太大,属于开发人员对函数底层了解的不够充分导致的。而在本文中,我们需要向大家提出一个用途非常广泛的函数,而此函数由于在代码中出现的次数十分频繁,所以其安全性极为重要。而本文通过模型分析、攻击案例的分析已经一些开发点出发,向开发人员与安全分析人员提供一些攻击参考,也帮助大家在开发相应应用的过程中对其有所注意。
简单来说,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 会随着调用方的改变而改变,这就成为了一个安全隐患,在特定的应用场景下将引发安全问题。
在讨论攻击模型前,有同学会问:函数里面不是有参数吗?那么攻击者又不能改变参数的个数,那你怎么能够做到传入的参数数量正好是原来函数的数量呢?难道你必须要拿到函数的最高权限吗?
下面我们就根据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的特性,我们也就对参数的个数不用过多考虑,仅仅需要调用攻击即可。
我们知道在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)
,还攻击了系统,将以太币传给自己。
在上面我们利用bytes类型的变量传入了攻击利用代码,而倘若我们没有办法进行关键参数的传入工作,而只能传入函数名应该怎么办?
例如:
function CPcall(address _to, uint _value, bytes data, string fallback){
...
assert(_to.call(bytes4(keccak256(fallback)),msg.sender, _value, _data)) ;
...
}
上述函数中传入了四个变量,而加入我们能操控的变量只有fallback
,而value
与data
我们无法自行传入。我们应该如何调用下一步的操作?
这里我们写出转账函数的内容:
function transfer(address a, uint value) public returns (bool success)
{
trans(msg.sender, a, value);//向a转账value数量的钱
}
倘若如此,我们就可以利用call来进行内部调用转账函数来达到偷币的效果。
我们可以仅仅将fallback
的值修改为transfer
,并且使函数CPcall()
中的断言函数中调用转账函数。不过同学会问,上一个函数中的参数有三个,而下面的只有两个,那不会不匹配吗?我们这里知道,其实这个函数同样会执行成功的。这是因为EVM在获取参数的时候没有参数个数校验的过程,因此取到前两个参数之后,就把最后一个参数_data
给截断掉了,在编译和运行阶段都不会报错。
2018.5.11,ATN 技术人员收到异常监控报告,显示 ATN Token 供应量出现异常,通过分析发现 Token 合约由于存在漏洞受到攻击。由于 ATN 代币的合约中的疏漏,该事件中 call 注入不但绕过了权限认证,同时还可以更新合约拥有者。
本次事件的原因是由于在 ATN 项目中使用到了 ERC223
和 ds-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不会被攻击,应该很容易得出智能合约运行所需要消耗的“燃料”成本。
2 http://me.tryblockchain.org/Solidity-call-callcode-delegatecall.html#fn1
4 https://github.com/ATNIO/atn-contracts/blob/master/src/ATN.sol
本稿为原创稿件,转载请标明出处。谢谢。