[论文01]Smart Contract Vulnerability Analysis and Security Audit
这是一篇2020年发布在IEEE ntework上的期刊文章
该期刊中科院分区 sci 1区,影响因子8.8
introduction
本文对以太坊智能合约的各种漏洞进行了调查,并提出了相应的防御机制来应对这些漏洞。
特别地,我们关注类Fomo3d游戏合同中的随机数漏洞(radom number vulnerability),以及所应用的攻击和防御方法。
最后,对现有的以太智能合同安全审计方法进行了总结,并从不同角度对几种主流审计工具进行了比较。
以太坊智能合约漏洞
由于以太坊智能合约一经部署就无法更改,所以对于智能合约的代码需要更加严格的检查与审计。
1. Unchecked Send /Other Function Call
对于一些例如send 或者transfer之类的函数需要对其进行严格的检查和审计,在函数逻辑之后对函数结果进行审计,可以避免程序异常分支或者合约结果的损失。
我们来看一些合约1,RachMan;这是一个评估账户存于银行的财富数目的合约,如果一个用户想获得richman称号则需要转账给现有的合约地址,转账数目必须大于当前的财富值。将以太币转入后转入者将可获得rich man 头衔,财富值也随之更新。
然而该合约的缺点在于,该合约在赋予转入者头衔前未检查是否转入成功,也即攻击者可以通过转账一笔远高于银行账户余额的数目,导致一笔失败的转账交易来获得rich man 称号。
REENTRANCY VULNERABLITIES(重入)
重入漏洞往往是因为合约开发者的疏忽所带来的巨大损失。
重入漏洞的思想类似于用户去柜台取1000块钱,柜员发现该用户账户中正好有1000元钱,于是柜员将钱取给用户,在柜员还没来得及将系统中用户账户余额减少1000元时,该用户又来取钱,柜员查看余额发现符合要求,于是又拿了1000元钱给该用户。
解决方法也很简单,那就是先更新系统再取钱给用户。
来看如下这个合约:
知识背景
如果一个合约接受以太币那么需要一个带payable修饰符的回退函数,而攻击者可能会在回退函数中写入恶意代码,包含对被调用合约的re-entry,如上面的合约,该攻击者提取了10次数值为amount的以太币。解决方法很简单,先减钱,再转钱。
PERMISSION CONTROL VULNERABLITIES
由于所有合约一经部署在链上公开,所以一旦对于一些函数调用的权限不受控制可能会造成非常严重的后果。例如,如果函数权限未成功设置导致所有人都可以修改余额,或者自毁函数权限公开,攻击者可以触发自毁函数后提取合约账户中的以太币到攻击者账户。
造成该漏洞原因有三点:
- 合约开发者错误使用msg.origin 和 msg.sender,msg.origin返回合约调用的发起者,是一个用户地址;msg.sender返回的是函数调用者,是一个用户地址或者是一个用户地址。 无法准确区分这两者可能会导致程序的错误分支。
- 无法确定对于一个函数的调用者是合约还是用户,这两者在功能和计算能力上存在显著差异。例如在Fomo3d游戏中,黑客可以通过精确预测合约中的时间戳获得巨大利润。
- 拼写错误很难被编译器检查,可能会导致非预期的回调函数或者合约字段内容以及权限被修改。
FUNCTION VISIBILITY VULNERBILITIES
对于一个函数的默认权限是公开的,如果忘记声明一个私有函数的可见性会导致所有人都可以访问或者调用该函数,继而产生非预期后果。
TIMESTAMP DEPENDENCY
一些函数逻辑基于目前时间值,矿工通过控制当前时间来获得预期结果。
RANDOM NUMBER VULNERBILITIES
在合约中攻击者通过精确预测随机数,在后面femo3d游戏合约中分析该漏洞的实例。
AIRDROP VULNERABILITY IN FOMO3D
GEME INTRODUCTION
key代表了玩家在游戏中拥有的财产,为关键要素。每个玩家参加游戏都需要购买key,key的价格随着游戏进行轻微波动。
在这个游戏中有一个倒计时表。每一个轮次中,玩家可以随时购买一个或多个钥匙。每当一个玩家购买一个kay,时间增加30s。玩家购买以太币的资金一部分用来维护这个key,一部分流入奖金池。
倒计时结束后,最后一个购买key的玩家可以获得奖金池一半的资金,剩下的资金按照一定的规则分配给持有key的玩家以及作为下一轮的起始资金。
除了奖池和分红,Fomo3d还使用aordrop机制刺激玩家购买钥匙。 购买密钥金额的百分之一将分配给airdrop奖金池。
在每轮游戏开始时,玩家获得airdrop的几率为0,每超过0.1Eth的订单将增加0.1%的空投几率。 一旦玩家获得airdop,所有玩家空投的概率将归零。 玩家空投的金额取决于在购买上花费的ETH金额。 对于0.1-1以太币,有25%的机会赢得空投。 对于1-10ETH的购买,有50%的机会赢得airdrop。 对于购买10个以上的ETH,有75%的机会赢得airdrop side pot。
此游戏中引入的漏洞出现在游戏的空投机制中。
VULNERABILITY ANALYSIS
1. 随机数弱点
这个函数通过产生随机数来决定赢家。随机数种子由block.timestamp, block.diffculty, block.coinbase, block.gaslimit, msg.sender产生;但这些信息在链上公开,所以攻击者可以使用算法计算赢得的奖金是否超过本金,如果超过则购买,从而产生收益。
function airdrop() private view returns (bool){
uint256 seed = uint256(keccak256(abi.encodePacked((block.timestamp).add
(block.difficulty).add ((uint256(keccak256(abi.encodePacked(block.coinbase))))/(now)).add
(block.number)
)));
if ((seed - ((seed/1000)*1000))< airDropTracker_)
return(true);
else
return(false);
}
2. 识别认证漏洞
在Fomo3d合约中,为了避免用户利用合约去调用合约,该合约引入了一个修饰器isHuman() 来判断合约调用者是否是用户。该修饰器的原理为:如果是使用者调用函数,代码长度是0;如果是合约调用函数,即使是一个空合约,代码长度也不会是0.
(codesize是以太坊提供给每个地址的属性,指的是该地址所拥有的运行代码的字节数)
协定构造函数执行完成之前,不会部署代码。 在约定构造函数中,代码大小为0。 因此攻击者可以协定构造函数中进行购买,从而绕过isHuman() 修改器。
(1)如图a, 首先攻击者书写在PwnFomo3d 合约中书写和Fomo3d airdrop()相同的代码,同时生成随机数,相应的计算该随机数是否能赢得此次的airdrop。
如果取得预期结果,立即购买钥匙,否则函数重复执行该循环判断。
但是这种攻击并不是很有效率,因为攻击者每次尝试攻击都需要部署合约,这会消耗很多汽油费。
(2)如图b,攻击者首先部署了一个父合约(注:此处的父合约是指调用的原合约,而不是我们继承中说到的父合约),每次攻击都从父合约中调用这个攻击函数,从而预测在这个时间购买是否能获得airdrop,结果乐观即购买。(也就是说真正的合约内容是写在子合约里面的,父合约负责调用,免去了父合约的重复部署)
图a与图b的区别在于决定是否购买的合约是一个固定部署好的合约,这将降低成本。该方案的缺陷在于需要估计新部署PwnFomo3d合约的地址,该地址也是airdrop生成算法中的变量。
虽然此方案能减少攻击成本,但是本质来说并没有真正的提高效率。
(3)图c是针对图b效率所提出的方案,实现思想为提前部署多个子合约。由Fomo3d合约可知,每个玩家每次得到airdrop的概率会增加至少1/1000,因此攻击者可以部署1000个子合约,同时利用父合约保存其地址。每次攻击中,父合约循环遍历子合约地址,以挑选出特定地址的攻击合约。
通过该方法来决定是否购买key,成功率将会达到100%。
deFense AgAInst RAndom number VulnerAbIlItIes
以上分析说明该合约的缺陷产生原因在于,合约之间调用会导致随机数变得可预测,所以攻击者可以攻击该合约利用另一个合约调用。因此,避免攻击的目的在于限制调用者为用户,而不是合约。
实现分为两个方面
实现
-
避免合约调用
签名验证:合约地址和用户地址的一个重要区别在于:用户地址拥有私钥,而合约地址不产生私钥,通过检查地址的私钥可区分用户地址和合约地址。 利用询答机制向地址请求私钥签名,以太坊提供的ecrecover()将是一种便利的方式(代码见上文修改器 isHuman1),该函数返回对应签名的地址。isHuman1的msg是指签名信息的SHA-3值,r,s,v1是指由msg的签名算法生成的数据。缺点是会消耗额外的汽油。
origin == msg.sender:检查msg.sender是否等于tx.origin。如果攻击者利用合约攻击airdrop,那么上述两个值将会不等同,也即其中可能存在恶意攻击。
-
无法生成真正的随机数
改变已知的种子的生成随机数,可以利用块的哈希值(作者避重就轻,未提及哈希所带来的计算开销)
具体做法为在种子生成过程可以引入外部oracles如Oraclize(一种提供可信数据源的预言机)。以太坊允许用户调用区块来获得特定打包后区块的哈希值,所以在打包前该哈希值未知,如果当前种子取决于当前区块哈希值,结果则不可预测。
随机数生成可以分成两步:用户请求调用合约的区块号,在交易打包后合约返回区块号的哈希值。利用该哈希值可以保证种子的生成是基于一个随机过程的。
本文可能用到附加知识:
摘自各种博客,侵删
modifier
相当于一个前置条件,如果函数有多个相同的前置条件可以代码复用,结构清晰。
contract Core {
address public owner;
string public description;
uint256 public initialSupply;
uint256 public a;
constructor(string _str, uint256 _initialSupply) public {
// 在合约初始化的时候,保存创建者为合约拥有者 owner
owner = msg.sender;
description = _str;
initialSupply = _initialSupply;
}
// 修饰器是个好东西,如果限定条件在多处复用,可以使用修饰器
modifier ownerOnly (){
// 下面require要求调用者必须是初始化时保存的owner
require(msg.sender ==owner);
_;
}
function` `func1() public ownerOnly {
// 使用了修饰器 ownerOnly,当该函数的调用者不是初始化时的owner,则报错
a = 1024;
}
// 这里owner变量是初始化合约者的地址,可以拓展为“权限者“,所以也就可以变更“权限者”
function` `changeOwner(address _addr) public ownerOnly {
// 合约创建时owner存储了初始化合约者的地址,可以拓展为“权限者“,所以也就可以变更“权限者”
// 传入一个新地址,当然不能谁都能赋权,故仍使用ownerOnly限定只有当下的”权限者“可以执行该函数。
// 函数执行后,则新地址为合约的“权限者”
owner = _addr;
}
}
fallback函数
在合约调用没有匹配到函数签名,或者调用没有带任何数据时被自动调用。
声明方式:
没有名字,不能有参数,没有返回值
pragma solidity ^0.4.0;
contract SimpleFallback{
function(){
//fallback function
}
}
简单例子:
由于Solidity编辑器remix中,提供了编译期检查,所以我们不能直接通过Solidity调用一个不存在的函数。但我们可以使用Solidity的提供的底层函数address.call来模拟这一行为
pragma solidity ^0.4.0;
contract ExecuteFallback{
//回退事件,会把调用的数据打印出来
event FallbackCalled(bytes data);
//fallback函数,注意是没有名字的,没有参数,没有返回值的
function(){
FallbackCalled(msg.data);
}
//调用已存在函数的事件,会把调用的原始数据,请求参数打印出来
event ExistFuncCalled(bytes data, uint256 para);
//一个存在的函数
function existFunc(uint256 para){
ExistFuncCalled(msg.data, para);
}
// 模拟从外部对一个存在的函数发起一个调用,将直接调用函数
function callExistFunc(){
bytes4 funcIdentifier = bytes4(keccak256("existFunc(uint256)"));
this.call(funcIdentifier, uint256(1));
}
//模拟从外部对一个不存在的函数发起一个调用,由于匹配不到函数,将调用回退函数
function callNonExistFunc(){
bytes4 funcIdentifier = bytes4(keccak256("functionNotExist()"));
this.call(funcIdentifier);
}
}
(1)我们调用callExistFunc(),这个方法后,返回日志,可以看到返回的值跟传入的参数一致,是正常的调用
(2)调用callNonExistFunc(),这个方法,返回的日志是如下图,可以发现当没有找到对应函数可调用时,会默认调用fallback函数
ether发送send()
address.send(ether to send)向某个合约直接转帐时,address指向的合约必须有fallback函数,且为payable的,才可以接收到发送来的ether,因为:address.send(ether to send)这个行为没有发送任何数据,所以接收合约总是会调用fallback函数
合约实例测试:
- 两个合约,两个合约均有发送ether的函数,一个合约有fallback函数且为payable,另一个合约没有fallback函数
- 部署时,两个合约同时存入100
- 两个合约相互发送ether,看结果如何
pragma solidity ^0.4.0;
contract SendWithFallback{
function SendWithFallback() payable { //构造函数,部署时用来存入
}
//fallback函数及其事件
event fallbackTrigged(bytes data);
function() payable{
fallbackTrigged(msg.data);
}
//查询当前的余额
function getBalance() constant returns(uint){
return this.balance;
}
event SendEvent(address to, uint value, bool result);
//使用send()发送ether
function sendEther(address _addto){
bool result = _addto.send(3);
SendEvent(_addto, 1, result);
}
}
contract SendWithoutFallback{
function SendWithoutFallback() payable { //构造函数,部署时用来存入
}
//查询当前的余额
function getBalance() constant returns(uint){
return this.balance;
}
event SendEvent(address to, uint value, bool result);
//使用send()发送ether
function sendEther(address _addto){
bool result = _addto.send(3);
SendEvent(_addto, 1, result);
}
}
测试步骤:
(1)部署,两个合约都存入100
(2)先用不带Fallback的合约向带Fallback的合约发送ether
发送会成功,触发事件
再看两合约的余额
(3)再用带Fallback的合约向不带Fallback的合约发送ether
返回结果result为false,表明发送ether是失败的,
再看看两合约的余额,并没有变化
(4)因此,要接收ether的合约,要有fallback函数,且为payable属性的
fallback的限制:
send()函数总是会调用fallback,这个行为非常危险,著名的DAO被黑也与这有关。如果我们在分红时,对一系列帐户进行send()操作,其中某个做恶意帐户中的fallback函数实现了一个无限循环,将因为gas耗尽,导致所有send()失败。为解决这个问题,send()函数当前即便gas充足,也只会附带限定的2300gas,故而fallback函数内除了可以进行日志操作外,你几乎不能做任何操作。
下述行为消耗的gas都将超过fallback函数限定的gas值:
注意:上述仅对使用send()方式的有2300gas的限制,对使用call()方式没有这样的限制。
- 向区块链中写数据
- 创建一个合约
- 调用一个external的函数
- 发送ether
payable函数
fallback函数,回退函数,是合约里的特殊无名函数,有且仅有一个1。它在合约调用没有匹配到函数签名,或者调用没有带任何数据时被自动调用。
(2)回退函数
回退函数是合约里的特殊函数,没有名字,不能有参数,没有返回值。下面来看一个简单的回退函数例子。
pragma solidity ^0.4.0;
contract SimpleFallback{
function(){
//fallback function
}
}
调用函数找不到时
当调用的函数找不到时,就会调用默认的fallback函数。由于Solidity中,Solidity提供了编译期检查,所以我们不能直接通过Solidity调用一个不存在的函数。但我们可以使用Solidity的提供的底层函数address.call
来模拟这一行为,关于call()
函数详见:http://me.tryblockchain.org/Solidity-call-callcode-delegatecall.html 。我们来看个例子:
pragma solidity ^0.4.0;
contract ExecuteFallback{
//回退事件,会把调用的数据打印出来
event FallbackCalled(bytes data);
//fallback函数,注意是没有名字的,没有参数,没有返回值的
function(){
FallbackCalled(msg.data);
}
//调用已存在函数的事件,会把调用的原始数据,请求参数打印出来
event ExistFuncCalled(bytes data, uint256 para);
//一个存在的函数
function existFunc(uint256 para){
ExistFuncCalled(msg.data, para);
}
// 模拟从外部对一个存在的函数发起一个调用,将直接调用函数
function callExistFunc(){
bytes4 funcIdentifier = bytes4(keccak256("existFunc(uint256)"));
this.call(funcIdentifier, uint256(1));
}
//模拟从外部对一个不存在的函数发起一个调用,由于匹配不到函数,将调用回退函数
function callNonExistFunc(){
bytes4 funcIdentifier = bytes4(keccak256("functionNotExist()"));
this.call(funcIdentifier);
}
}
在上面的代码中,我们定义了一个fallback函数,和一个对应的显示请求原始数据的事件FallbackCalled
。
当我们调用callExistFunc()
时,由于函数实际存在,会直接触发existFunc()
的调用,我们能看到ExistFuncCalled
事件被触发,运行时将打印出ExistFuncCalled[ "0x42a788830000000000000000000000000000000000000000000000000000000000000001","1"]
。其中第一个数据是调用该函数时,传过来的原始数据,前四个字节42a78883
,是existFunc()
的方法签名,指明是对该函数进行调用,紧跟其后的是函数的第一个参数0000000000000000000000000000000000000000000000000000000000000001
,表示的是uin256
值1
(32字节的无符号整数值十六进制表示),数据格式说明详见:http://me.tryblockchain.org/Solidity-abi-abstraction.html。
当我们调用的函数找不到时才会触发对fallback函数的自动调用。当调用callNonExistFunc()
,由于它调用的functionNotExist()
函数在合约中实际并不存在。故而,实际会触发对fallback函数的调用,运行后会触发FallbackCalled事件,说明fallback被调用了。事件输出的数据是,FallbackCalled[ "0x69774a91"]
,0x69774a91
是调用的原始数据,是调用的functionNotExist()
函数的四字节的函数签名。
send()函数发送ether
当我们使用address.send(ether to send)
向某个合约直接转帐时,由于这个行为没有发送任何数据,所以接收合约总是会调用fallback函数,我们来看看下面的例子:
pragma solidity ^0.4.0;
contract SendFallback{
//fallback函数及其事件
event fallbackTrigged(bytes data);
function() payable{fallbackTrigged(msg.data);}
//存入一些ether用于后面的测试
function deposit() payable{
}
//查询当前的余额
function getBalance() constant returns(uint){
return this.balance;
}
event SendEvent(address to, uint value, bool result);
//使用send()发送ether,观察会触发fallback函数
function sendEther(){
bool result = this.send(1);
SendEvent(this, 1, result);
}
}
在上述的代码中,我们先要使用deposit()
合约存入一些ether,否则由于余额不足,调用send()
函数将报错。存入ether后,我们调用sendEther()
,使用send()
向合约发送数据,将会触发下述事件:
SendEvent[
"0xc35f7ac1351648b0b8a699c5f07dd6a78f626714",
"1",
"true"
]
fallbackTrigged[
"0x"
]
可以看到,我们成功使用send()
发送了1wei到合约,触发了fallback函数,附带的数据是0x
(bytes类型的默认空值),空数据。
这里需要特别注意的是:
- 如果我们要在合约中通过
send()
函数接收,就必须定义fallback函数,否则会抛异常。 - fallback函数必须增加
payable
关键字,否则send()
执行结果将会始终为false
。
fallback中的限制
send()
函数总是会调用fallback,这个行为非常危险,著名的DAO被黑也与这有关。如果我们在分红时,对一系列帐户进行send()
操作,其中某个做恶意帐户中的fallback函数实现了一个无限循环,将因为gas耗尽,导致所有send()
失败。为解决这个问题,send()
函数当前即便gas充足,也只会附带限定的2300gas,故而fallback函数内除了可以进行日志操作外,你几乎不能做任何操作。如果你还想做一些复杂的操作,解决方案看这里:http://me.tryblockchain.org/blockchain-solidity-fallback-bestpractice.html。
下述行为消耗的gas都将超过fallback函数限定的gas值:
- 向区块链中写数据
- 创建一个合约
- 调用一个external的函数
- 发送ether
所以一般,我们只能在fallback函数中进行一些日志操作:
pragma solidity ^0.4.0;
contract FallbackFailOnGasLimit{
uint someStorage;
event fallbackTrigged(bytes);
function() payable{
fallbackTrigged(msg.data);
//将因为写入操作失败,注释掉下面这行,将会执行成功
someStorage = 1;
}
function callFallback() returns (bool){
return this.send(0);
}
}
在上述代码中,fallback函数有写入操作,会消耗掉超过限定的gas,故会导致失败,注释掉someStorage = 1;
后,执行callFallback()
将会成功。
注意:上述仅对使用send()
方式的有2300gas的限制,对使用call()
方式没有这样的限制。
constructor
mapping
映射/字典(mappings)
映射
或字典类型,一种键值对的映射关系存储结构。定义方式为mapping(_KeyType => _KeyValue)
。键的类型允许除映射
外的所有类型,如数组,合约,枚举,结构体。值的类型无限制。
映射
可以被视作为一个哈希表,其中所有可能的键已被虚拟化的创建,被映射到一个默认值(二进制表示的零)。但在映射表中,我们并不存储键的数据,仅仅存储它的keccak256
哈希值,用来查找值时使用。
因此,映射
并没有长度,键集合(或列表),值集合(或列表)这样的概念。
映射
类型,仅能用来定义状态变量
,或者是在内部函数中作为storage
类型的引用。引用是指你可以声明一个,如var storage mappVal
的用于存储状态变量的引用的对象,但你没办法使用非状态变量来初始化这个引用。
可以通过将映射
标记为public
,来让Solidity创建一个访问器。要想访问这样的映射
,需要提供一个键值做为参数。如果映射
的值类型也是映射
,使用访问器访问时,要提供这个映射
值所对应的键,不断重复这个过程。下面来看一个例子:
contract MappingExample{
mapping(address => uint) public balances;
function update(uint amount) returns (address addr){
balances[msg.sender] = amount;
return msg.sender;
}
}
由于调试时,你不一定方便知道自己的发起地址,所以把这个函数,略微调整了一下,以在调用时,返回调用者的地址。编译上述合同后,可以先调用update()
,执行成功后,查看调用信息,能看到你更新的地址,这样再查一下这个地址的在映射里存的值。
如果你想通过合约进行上述调用。
pragma solidity ^0.4.0;
//file indeed for compile
//may store in somewhere and import
contract MappingExample{
mapping(address => uint) public balances;
function update(uint amount) returns (address addr){
balances[msg.sender] = amount;
return msg.sender;
}
}
contract MappingUser{
address conAddr;
address userAddr;
function f() returns (uint amount){
//address not resolved!
//tringing
conAddr = hex"0xf2bd5de8b57ebfc45dcee97524a7a08fccc80aef";
userAddr = hex"0xca35b7d915458ef540ade6068dfe2f44e8fa733c";
return MappingExample(conAddr).balances(userAddr);
}
}
映射
并未提供迭代输出的方法,可以自行实现一个数据结构。
contract MappingExample{
mapping(address => uint) public balances;
function update(uint amount) returns (address addr){
balances[msg.sender] = amount;
return msg.sender;
}
}
contract MappingUser{
address conAddr;
address userAddr;
function f() returns (uint amount){
//address not resolved!
//tringing
conAddr = hex"0xf2bd5de8b57ebfc45dcee97524a7a08fccc80aef";
userAddr = hex"0xca35b7d915458ef540ade6068dfe2f44e8fa733c";
return MappingExample(conAddr).balances(userAddr);
}
}
`映射`并未提供迭代输出的方法,可以自行实现一个数据结构。