研究报告【Finding The Greedy, Prodigal, and Suicidal Contracts at Scale】)指出,目前在以太坊中,有89%的智能合约代码都或多或少存在安全漏洞/隐患,这显然是一个非常惊人的调查结果,对社区而言也是一个巨大的风险因素。而随着智能合约的增多乃至未来可能的大规模发展,相信对各种合约代码的审计也将会变成一个专门的、专业的领域,并且是不能够、也不应该被忽视的。
本文译自Merunas Grincalaitis(一位以太坊开发者)于2017年9月18日发表在Medium上的文章,点击跳转原文链接。本文是作者结合自己所写的一份智能合约代码来讲述智能合约审计要点的技术文章,并包含了对Solidity语言可能遇到的几种危险攻击的介绍。对于以太坊智能合约开发者而言有一定的参考和学习价值。
你有没有考虑过如何审计一个智能合约来找出安全漏洞?
你可以自己学习,或者你可以使用这份便利的一步步的指南来准确地知道在什么时候该做什么,并对合约进行审计。
我已经研究过很多智能合约的审计,并且我已经找到了从任何合约中提取所有重要信息的最常规步骤。
在本文中,你将会学到以下内容:
- 生成对一个智能合约的完整审计报告所需的所有步骤。
- 作为以太坊智能合约审计人员需要了解的最重要的攻击类型。
- 应该在合约中寻找什么,和一些你不会在其他任何地方找到的有用的提示。
让我们直接开始审计合约吧:
如何审计一个智能合约
为了教会你如何进行审计,我会审计我自己写的一份合约。这样,你可以看到可以由你自行完成的真实世界的审计。
现在你也许会问:智能合约的审计到底是指什么?
智能合约审计就是仔细研究代码的过程,在这里就是指在把Solidity合约部署到以太坊主网络中并使用之前发现错误、漏洞和风险;因为一旦发布,这些代码将无法再被修改。这个定义仅仅是为了讨论目的。
请注意,审计不是验证代码安全的法律文件。没有人能100%确保代码不会在未来发生错误或产生漏洞。这仅仅是保证你的代码已被专家校订过,基本上是安全的。
讨论可能的改进,主要是为了找出那些可能会危害到用户的以太币的风险和漏洞。
好了,现在我们来看看一份智能合约审计报告的结构:
- 免责声明: 在这里你会说审计不是一个具有法律约束力的文件,它不保证任何东西。这只是一个讨论性质的文件。
- 审计概览和优良特性: 快速查看将被审计的智能合约并找到良好的实践。
- 对合约的攻击: 在本节中,你将讨论对合约的攻击以及会产生的结果。这只是为了验证它实际上是安全的。
- 合约中发现的严重漏洞: 可能严重损害合约完整性的关键问题。那些会允许攻击者窃取以太币的严重问题。
- 合约中发现的中等漏洞: 那些可能损害合约但危害有限的漏洞。比如一个允许人们修改随机变量的错误。
- 低严重性的漏洞: 这些问题并不会真正损害合约,并且可能已经存在于合约的已部署版本中。
- 逐行评注: 在这部分中,你将分析那些具有潜在改进可能的最重要的语句行。
- 审计总结: 你对合约的看法和关于审计的最终结论。
将这份结构说明保存在一个安全的地方,这是你安全地审计智能合约时需要做的全部内容。它将确实地帮助你找到那些难以发现的漏洞。
我建议你从第7点“逐行评注”开始,因为当逐行分析合约时,你会发现最重要的问题,你会看到缺少了什么,以及哪些地方应该修改或改进。
在后文中,我会给你展示一个免责声明,你可以把它作为审计的第一步。你可以从第1点开始看下去,直到结束。
接下来,我将向你展示使用这样的结构完成的审计结果,这是我针对我自己写的一个合约来做的。你还将在第3点中看到对于智能合约可能受到的最重要的攻击的介绍。
将这份结构说明保存在一个安全的地方,这是你安全地审计智能合约时需要做的全部内容。它将确实地帮助你找到那些难以发现的漏洞。
我建议你从第7点“逐行评注”开始,因为当逐行分析合约时,你会发现最重要的问题,你会看到缺少了什么,以及哪些地方应该修改或改进。
在后文中,我会给你展示一个免责声明,你可以把它作为审计的第一步。你可以从第1点开始看下去,直到结束。
接下来,我将向你展示使用这样的结构完成的审计结果,这是我针对我自己写的一个合约来做的。你还将在第3点中看到对于智能合约可能受到的最重要的攻击的介绍。
赌场合约审计
你可以在我的Github上看到审计的代码:https://github.com/merlox/casino-ethereum/blob/master/contracts/Casino.sol
对应的合约代码:
pragma solidity ^0.4.11;
import "github.com/oraclize/ethereum-api/oraclizeAPI.sol";
/// @title Contract to bet Ether for a number and win randomly when the number of bets is met.
/// @author Merunas Grincalaitis
contract Casino is usingOraclize {
address owner;
// The minimum bet a user has to make to participate in the game
uint public minimumBet = 100 finney; // Equal to 0.1 ether
// The total amount of Ether bet for this current game
uint public totalBet;
// The total number of bets the users have made
uint public numberOfBets;
// The maximum amount of bets can be made for each game
uint public maxAmountOfBets = 10;
// The max amount of bets that cannot be exceeded to avoid excessive gas consumption
// when distributing the prizes and restarting the game
uint public constant LIMIT_AMOUNT_BETS = 100;
// The number that won the last game
uint public numberWinner;
// Array of players
address[] public players;
// Each number has an array of players. Associate each number with a bunch of players
mapping(uint => address[]) numberBetPlayers;
// The number that each player has bet for
mapping(address => uint) playerBetsNumber;
// Modifier to only allow the execution of functions when the bets are completed
modifier onEndGame(){
if(numberOfBets >= maxAmountOfBets) _;
}
/// @notice Constructor that's used to configure the minimum bet per game and the max amount of bets
/// @param _minimumBet The minimum bet that each user has to make in order to participate in the game
/// @param _maxAmountOfBets The max amount of bets that are required for each game
function Casino(uint _minimumBet, uint _maxAmountOfBets){
owner = msg.sender;
if(_minimumBet > 0) minimumBet = _minimumBet;
if(_maxAmountOfBets > 0 && _maxAmountOfBets <= LIMIT_AMOUNT_BETS)
maxAmountOfBets = _maxAmountOfBets;
// Set the proof of oraclize in order to make secure random number generations
oraclize_setProof(proofType_Ledger);
}
/// @notice Check if a player exists in the current game
/// @param player The address of the player to check
/// @return bool Returns true is it exists or false if it doesn't
function checkPlayerExists(address player) returns(bool){
if(playerBetsNumber[player] > 0)
return true;
else
return false;
}
/// @notice To bet for a number by sending Ether
/// @param numberToBet The number that the player wants to bet for. Must be between 1 and 10 both inclusive
function bet(uint numberToBet) payable{
// Check that the max amount of bets hasn't been met yet
assert(numberOfBets < maxAmountOfBets);
// Check that the player doesn't exists
assert(checkPlayerExists(msg.sender) == false);
// Check that the number to bet is within the range
assert(numberToBet >= 1 && numberToBet <= 10);
// Check that the amount paid is bigger or equal the minimum bet
assert(msg.value >= minimumBet);
// Set the number bet for that player
playerBetsNumber[msg.sender] = numberToBet;
// The player msg.sender has bet for that number
numberBetPlayers[numberToBet].push(msg.sender);
numberOfBets += 1;
totalBet += msg.value;
if(numberOfBets >= maxAmountOfBets) generateNumberWinner();
}
/// @notice Generates a random number between 1 and 10 both inclusive.
/// Must be payable because oraclize needs gas to generate a random number.
/// Can only be executed when the game ends.
function generateNumberWinner() payable onEndGame {
uint numberRandomBytes = 7;
uint delay = 0;
uint callbackGas = 200000;
bytes32 queryId = oraclize_newRandomDSQuery(delay, numberRandomBytes, callbackGas);
}
/// @notice Callback function that gets called by oraclize when the random number is generated
/// @param _queryId The query id that was generated to proofVerify
/// @param _result String that contains the number generated
/// @param _proof A string with a proof code to verify the authenticity of the number generation
function __callback(
bytes32 _queryId,
string _result,
bytes _proof
) oraclize_randomDS_proofVerify(_queryId, _result, _proof) onEndGame {
// Checks that the sender of this callback was in fact oraclize
assert(msg.sender == oraclize_cbAddress());
numberWinner = (uint(sha3(_result))%10+1);
distributePrizes();
}
/// @notice Sends the corresponding Ether to each winner then deletes all the
/// players for the next game and resets the `totalBet` and `numberOfBets`
function distributePrizes() onEndGame {
uint winnerEtherAmount = totalBet / numberBetPlayers[numberWinner].length; // How much each winner gets
// Loop through all the winners to send the corresponding prize for each one
for(uint i = 0; i < numberBetPlayers[numberWinner].length; i++){
numberBetPlayers[numberWinner][i].transfer(winnerEtherAmount);
}
// Delete all the players for each number
for(uint j = 1; j <= 10; j++){
numberBetPlayers[j].length = 0;
}
totalBet = 0;
numberOfBets = 0;
}
}
以下就是我的合约Casino.sol的审计报告:
序言
在这份智能合约审计报告中将包含以下内容:
- 免责声明
- 审计概览和优良特性
- 对合约的攻击
- 合约中发现的严重漏洞
- 合约中发现的中等漏洞
- 低严重性的漏洞
- 逐行评注
- 审计总结
1.免责声明
审计不会对代码的实用性、代码的安全性、商业模式的适用性、商业模式的监管制度或任何其他有关合约适用性的说明以及合约在无错状态的行为作出声明或担保。审计文档仅用于讨论目的。
2.概述
该项目只有一个包含142行Solidity代码的文件 Casino.sol
。所有的函数和状态变量的注释都按照标准说明格式(即Ethereum Nature Specification Format,缩写为natspec,它是以太坊社区官方的代码注释格式说明,原文参考github:【https://github.com/ethereum/wiki/wiki/Ethereum-Natural-Specification-Format】,译者注)进行编写,这可以帮助我们快速地理解程序是如何工作。
该项目使用了一个中心化的服务实现了Oraclize API,来在区块链上生成真正的随机数字。
译者注:
Oraclize是一种为智能合约和区块链应用提供数据的独立服务,官网:【http://www.oraclize.it】。因为类似于比特币脚本或者以太坊智能合约这样的区块链应用无法直接获取链外的数据,所以就需要一种可以提供链外数据并可以与区块链进行数据交互的服务。Oraclize可以提供类似于资产/财务应用程序中的价格信息、可用于点对点保险的天气信息或者对赌合约所需要的随机数信息。
这里是指在这个项目的源代码中引入了一个实现了Oraclize API的开源的Solidity代码库。
在区块链上生成随机数字是一个相当困难的课题,因为以太坊的核心价值之一就是可预测性,其目标是确保没有未定义的值。
译者注:
这里之所以说在区块链上生成随机数很困难,是因为,无论采用何种算法,都需要使用时间戳作为生成随机数的“种子”(因为时间戳是计算机领域内唯一可以理论上保证“不会重复”的数值);而在智能合约中取得时间戳只能依赖某个节点(矿工)来做到。这就是说,合约中取得的时间戳是由运行其代码的节点(矿工)的计算机本地时间决定的;所以这个节点(矿工)的可信度就成了最大的问题。理论上,这个本地时间是可以由恶意程序伪造的,所以这种方法被认为是“不安全的”。通行的做法是采用一个链外(off-chain)的第三方服务,比如这里使用的Oraclize,来获取随机数。因为Oraclize是一种公共基础服务,不会针对特定的合约“作假”,所以这可以认为是“相对安全的”。
因为使用Oraclize可以在链外生成随机数字,所以使用它来产生可信的数字被认为是一种很好的做法。 它实现了修饰符和一个回调函数,用于验证信息是否来自可信实体。
此智能合约的目的是参与随机抽奖,人们在1到9之间下注。当有10个人下注时,奖金会自动分配给赢家。每个用户都有一个最低下注金额。
每个玩家在每局游戏中只能下一次注,并且只有在参与者数量达到要求时才会产生赢家号码。
优秀特性
这个合约提供了一系列很好的功能性代码:
- 使用Oraclize生成安全的随机数并在回调中进行验证。
- 修改器检查游戏结束条件,阻止关键功能,直到奖励得以分配。
- 做了较多的检查来验证bet函数的使用是合适的。
- 只有在下注数达到最大条件时才安全地生成赢家号码。
3.对合约进行的攻击
为了检查合约的安全性,我们测试了多种攻击,以确保合约是安全的并遵循了最佳实践。
重入攻击(Reentrancy attack)
此攻击通过递归地调用ERC20代币中的
call.value()
方法来提取合约中的以太币,如果用户在发送以太币之后才更新发送者的balance
(即账户余额,译者注)的话,攻击就会生效。
当你调用一个函数将以太币发送给合约时,你可以使用fallback
函数再次执行该函数,直到以太币被从合约中提取出来。
由于该合约使用了 transfer()
而不是 call.value()
,因此不存在重入攻击的风险;因为transfer
函数只允许使用2300
gas,这只够用来产生事件日志数据并在失败时抛出异常。这样就无法递归调用发送者函数,从而避免了重入攻击。
因为transfer
函数只会在每局游戏结束,向赢家分发奖励时才会被调用一次,所以重入式攻击在这里不会导致任何问题。
请注意,调用此函数的条件是投注次数大于或等于10次,但这个投注次数只有在 distributePrizes()
函数结束时才会被重置为0,这是有风险的;因为理论上是可以在投注次数被清零之前调用该函数并执行所有逻辑的。
所以我的建议是在函数开始时就更新条件、将投注次数设置为0,以确保 distributePrizes()
在被超出预期地多次调用时不会产生实际效果。
数值溢出(Over and under flows)
当一个
uint256
类型的变量值超出上限2256(即2的256次方,译者注)时会发生溢出。其结果是变量值变为0,而不是更大。
例如,如果你想把一个unit类型的变量赋予大于2**256的值,它会简单地变为0,这是危险的。
另一方面,当你从0值中减去一个大于0的数字时,则会发生下溢出(underflow)。例如,如果你用0减去1,结果将是2**256,而不是-1。
在处理以太币的时候,这非常危险;然而在这个合约中并不存在减法操作,所以也不会有下溢出的风险。
唯一可能发生溢出的情况是当你调用 bet()
向某个数字下注时, totalBet
变量的值会相应增加:
totalBet += msg.value;
有人可能会发送大量的以太币而导致累加结果超过2**256,这会使totalBet
变为0。这当然是不大可能发生的,但风险是有的。
所以我推荐使用类似于[OpenZeppelin’s SafeMath.sol]
这样的库。它可以使你的计算处理更安全,免去发生溢出(overflow或者underflow)的风险。
可以将其导入来使用,对uint256类型激活它,然后使用 .mul()
、 .add()
、 .sub()
和 .div()
这些函数。例如:
import './SafeMath.sol';
contract Casino {
using SafeMath for uint256;
function example(uint256 _value) {
uint number = msg.value.add(_value);
}
}
重放攻击(Replay attack)
重放攻击是指在像以太坊这样的区块链上发起一笔交易,而后在像以太坊经典这样的另一个链上重复这笔交易的攻击。(就是说在主链上创建一个交易之后,在分岔链上重复同样的交易。译者注。)
以太币会像普通的交易那样,从一个链转移到另一个链。
基于由Vitalik Buterin提出的EIP 155【https://github.com/ethereum/EIPs/blob/master/EIPS/eip-155.md】,从Geth的1.5.3版本和Parity的1.4.4版本开始,已经增加了对这个攻击的防护。
译者注:
EIP,即Ethereum Improvement Proposal(以太坊改进建议),官方地址【https://github.com/ethereum/EIPs】是由以太坊社区所共同维护的以太坊平台标准规范文档,涵盖了基础协议规格说明、客户端API以及合约标准规范等等内容。
所以使用合约的用户们需要自己升级客户端程序来保证针对这个攻击的安全性。
重排攻击(Reordering attack)
这种攻击是指矿工或其他方试图通过将自己的信息插入列表(list)或映射(mapping)中来与智能合约参与者进行“竞争”,从而使攻击者有机会将自己的信息存储到合约中。
当一个用户使用 bet()
函数下注以后,因为实际的数据是存储在链上的,所以任何人都可以简单地通过调用公有状态变量 playerBetsNumber
这个mapping看到所下注的数字。
这个mapping是用来表示每个人所选择的数字的,所以,结合交易数据,你就可以很容易地看到他们各自下注了多少以太币。这可能会发生在 distributePrizes()
函数中,因为它是在随机数生成处理的回调中被调用的。
因为这个函数起作用的条件在其结束之前才会被重置,所以这就有了重排攻击(reordering attack)的风险。
因此,我的建议就像我之前谈的那样:在 distributePrizes()
函数开始时就重置下注人数来避免其产生非预期的行为。
短地址攻击(Short address attack)
这种攻击是由Golem团队发现的针对ERC20代币的攻击:
- 一个用户创建一个空钱包,这并不难,它只是一串字符,例如:【0xiofa8d97756as7df5sd8f75g8675ds8gsdg0】
- 然后他使用把地址中的最后一个0去掉的地址来购买代币:也就是用【0xiofa8d97756as7df5sd8f75g8675ds8gsdg】作为收款地址来购买1000代币。
- 如果代币合约中有足够的余额,且购买代币的函数没有检查发送者地址的长度,以太坊虚拟机会在交易数据中补0,直到数据包长度满足要求
- 以太坊虚拟机会为每个1000代币的购买返回256000代币。这是一个虚拟机的bug,并且仍未被修复。所以如果你是一个代币合约的开发者,请确保对地址长度进行了检查。
但我们这个合约因为并不是ERC20代币合约,所以这种攻击并不能适用。
你可以参考这篇文章【http://vessenes.com/the-erc20-short-address-attack-explained/】来获得更多关于这种攻击的信息。
4.合约中发现的严重漏洞
审计中并未发现严重漏洞。
5.合约中发现的中等漏洞
checkPlayerExists()
应该是一个常态(constant)函数,然而实际上它并不是。因此这增加了调用这个函数的gas消耗,当有大量对此函数的调用发生时会产生很大的问题。
应该把它改为常态函数来避免昂贵的消耗gas的执行。
译者注:
Solidity语言中的常态(constant)函数,指的是在运行时不会改变合约状态的函数,也就是不会改变合约级别的状态变量(state variable)的值的函数。因为状态变量的更改是会保存到链上的,所以对状态变量的更改都要消耗gas(来支付给矿工),这是非常昂贵的。在本例中,因为checkPlayerExists()
函数中访问了状态变量playerBetsNumber
来判断是否已经有人下过注了,虽然这是个合约级别的变量,但这个函数并没有改变它的值,所以这个函数应该声明为constant
以节省其对gas的消耗。
6.低严重性的漏洞
你在__callback()
函数和 pay()
函数的开始位置使用了 assert()
而不是 require()
。
assert()
和 require()
大体上是相同的,但assert函数一般用来在更改合约状态之后做校验,而require通常在函数的开头用做输入参数的检查。
你定义了一个合约级别的变量players,但没有任何地方使用它。如果你不打算使用它,就把它删除。
7.逐行评注
- 第1行:你在版本杂注(pragma version)中使用了脱字符号(^)来指定使用高于
0.4.11
版本的编译器。
这不是一个好实践。因为大版本的变化可能会使你的代码不稳定,所以我推荐使用一个固定的版本,比如0.4.11
。
第14行:你定义了一个
uint
类型的变量totalBet
,这个变量名是不合适的,因为它保存的是所有下注的合计值。我推荐使用totalBets
作为变量名,而不是totalBet
。第24行:你用大写字母定义了一个常量(constant variable),这是一个好实践,可以使人知道这是个固定的、不可变的变量。
第30行:就像我之前提到的,你定义了一个未使用的数组
player
。如果你不打算使用它,就把它删除。
第60行:函数checkPlayerExists()
应该被声明为 constant
。因为它并没有更改合约状态,把它声明为 constant
可以节省下每次运行它所要消耗的gas。
即使函数默认是public类型,但显式地给函数指定类型仍然是一个好实践,它可以避免任何困惑。这里可以在这个函数声明的末尾确切地加上public声明。
第61行:你没有检查输入参数
player
被正常传入且格式正确。请确保在函数开头使用require(player != address(0))
; 语句来检查传入地址是否为0。为了以防万一,最好也要检查地址的长度是否符合要求来应对短地址攻击。第69行:同样建议给
bet()
函数加上可见度(visibilty)
关键字public
来避免任何困惑,以明确应该如何使用此函数。第72行:使用 require() 来检查函数输入参数,而不是 assert() 。
同样的,在函数开头,一般更经常使用 require() 。请把所有在函数开头使用的 assert() 改为 require() 。第90行:你使用了一个对
msg.value
的简单合计,在value值很大时这会导致溢出。所以我建议你每次对数值进行运算时都要检查是否会溢出。第98行:
generateNumberWinner()
应该是internal
函数,因为你肯定不希望任何人都可以从合约以外执行它。
译者注:
在Solidity语言中,internal
关键字的效果,与面向对象语言比如C++、Java中的protected类型基本一致,此关键字限定的函数或者状态变量,仅在当前合约及当前合约的子合约(contacts deriving from this contract)中可以访问。private
关键字则与其他语言中的此关键字相同,由其限定的函数或者状态变量仅在当前合约中可以访问。
第103行:你把
oraclize_newRandomDSQuery()
函数的结果保存在了一个bytes32类型的变量中。调用callback函数并不需要这么做,而且你也没有在其他地方再用到这个变量,所以我建议不要用变量保存这个函数的返回值。第110行:
__callback()
函数应该声明为external
,因为你只希望它从外部被调用。
译者注:
在Solidity中,函数关键字public
和external
在gas的消耗上是有区别的。因为 public 的函数既可以在合约外调用,又可以在合约内调用,所以虚拟机会在运行时为其分配内存,拷贝其所用到的所有变量。而external
的函数只允许从合约外部进行调用,其调用会直接从calldata(即函数调用的二进制字节码数据)中获取参数,虚拟机不会为其分配内存并拷贝变量值,所以其gas消耗比 public 的函数要低很多。
第117行:这里的
assert()
应该使用require()
,就像我先前解释的那样。第119行:你使用了
sha3()
函数,但这并不是一个好的实践。实际的算法使用的是keccak256
,并不是sha3
。所以我建议这里更明确地改为使用keccak256()
。第125行:
distributePrizes()
函数应该被声明为internal
。
译者注:
此函数与第98行的generateNumberWinner()
函数一样,声明为internal
或者private
都是可以的。区别仅在于你希不希望子合约中可以使用它们。
- 第129行:尽管你在这里用了一个变长数组的大小来控制循环次数,但其实也没有多糟糕,因为获胜者的数量被限制为小于100。
8.审计总结
总体上讲,这个合约的代码有很好的注释,清晰地解释了每个函数的目的。
下注和分发奖励的机制非常简单,不会带来什么大问题。
我最终的建议是需要更加注意函数的可见性声明,因为这对于明确函数应该供谁来执行的问题非常重要。然后就是需要在编码中考虑 assert
、 require
和 keccak
的使用上的最佳实践。
这是一个安全的合约,可以在其运行期间保证资金安全。
结论
以上就是我使用我在开篇介绍过的结构所进行的审计。希望你确实学到了一些东西并且可以对其他智能合约进行安全审计了。
请继续学习合约安全知识、编码最佳实践以及其他实用知识,并努力提高。
原文:如何做智能合约审计?
作者:笔名辉哥