分析了如此多的合约与攻击案例后,我发现随机数是经常出现的一个话题。在CTF题目中经常能见到随机数的预测。
以太坊作为数字货币的初始平台之一,已经在市面上进行了极广的普及。对于以太坊来说,其经常应用在ERC20、轮盘、彩票、游戏等应用中,并利用Solidity完成对合约的编写。作为区块链的应用,以太坊同样是去中心化的、透明的。所以许多赌博游戏、随机数预测等相关应用需要精心设计,否则就会产生危害。
本文详细的将以太坊中的随机数安全问题进行归类,并通过样例对各个类别的安全问题进行演示操作,方便读者进行进一步的分析解读。
我们在这里将随机数分类为四个大的方向。
随机数使用区块中的公共变量作为随机数种子
随机数使用过去的区块的区块哈希
随机数结合哈希与私人设置的值作为种子
随机数结合区块链机制而导致的安全问题
我将在下文中对这四类问题进行分析总结,并对合约进行演示讲解。
根据有漏洞的合约以及常见的CTF题目,我们总结了几种被用于生产随机数的区块变量,如下:
contract test{
event block(uint);
function run() public{
block(now);
}
}
block.coinbase 代表挖当前区块的矿工地址
block.difficulty 表示这个区块的挖矿难度
block.gaslimit 表示交易中所限制的最大的gas值
block.number表示当前区块的高度
block.timestamp表示当前区块何时被挖出来的
这些区块变量可以被矿工进行计算,所以我们不能轻易的使用这些变量作为生成随机数的种子。并且,这些变量可以通过区块得到,当攻击者得到这些公共信息后,可以肆无忌惮的进行计算以达到预测随机数的效果。
下面我们看此类型的几个样例:
首先为一个轮盘类型的应用代码。
/**
*Submitted for verification at Etherscan.io on 2016-06-28
*/
contract Lottery {
event GetBet(uint betAmount, uint blockNumber, bool won);
struct Bet {
uint betAmount;
uint blockNumber;
bool won;
}
address private organizer;
Bet[] private bets;
// Create a new lottery with numOfBets supported bets.
function Lottery() {
organizer = msg.sender;
}
// Fallback function returns ether
function() {
throw;
}
// Make a bet
function makeBet() {
// Won if block number is even
// (note: this is a terrible source of randomness, please don't use this with real money)
bool won = (block.number % 2) == 0;
// Record the bet with an event
bets.push(Bet(msg.value, block.number, won));
// Payout if the user won, otherwise take their money
if(won) {
if(!msg.sender.send(msg.value)) {
// Return ether to sender
throw;
}
}
}
// Get all bets that have been made
function getBets() {
if(msg.sender != organizer) { throw; }
for (uint i = 0; i < bets.length; i++) {
GetBet(bets[i].betAmount, bets[i].blockNumber, bets[i].won);
}
}
// Suicide :(
function destroy() {
if(msg.sender != organizer) { throw; }
suicide(organizer);
}
}
该合约的关键点在makeBet()
函数中。
// Make a bet
function makeBet() {
// Won if block number is even
// (note: this is a terrible source of randomness, please don't use this with real money)
bool won = (block.number % 2) == 0;
// Record the bet with an event
bets.push(Bet(msg.value, block.number, won));
// Payout if the user won, otherwise take their money
if(won) {
if(!msg.sender.send(msg.value)) {
// Return ether to sender
throw;
}
}
}
在该函数中,用户会在调用该函数的同时获得一个won的bool变量,该变量通过对2进行取余操作来获取是否为true或者false。当won为基数的时候,合约向参与者进行赚钱。
然而这里的block.number
可以进行预测,我们可以写攻击合约,当block.number
满足条件时调用函数,当不满足的时候放弃执行该函数,这样就可以做到百分百命中。
第二个例子与block.timestamp
有关。
/**
*Submitted for verification at Etherscan.io on 2017-08-20
*/
pragma solidity ^0.4.15;
/// @title Ethereum Lottery Game.
contract EtherLotto {
// Amount of ether needed for participating in the lottery.
uint constant TICKET_AMOUNT = 10;
// Fixed amount fee for each lottery game.
uint constant FEE_AMOUNT = 1;
// Address where fee is sent.
address public bank;
// Public jackpot that each participant can win (minus fee).
uint public pot;
// Lottery constructor sets bank account from the smart-contract owner.
function EtherLotto() {
bank = msg.sender;
}
// Public function for playing lottery. Each time this function
// is invoked, the sender has an oportunity for winning pot.
function play() payable {
// Participants must spend some fixed ether before playing lottery.
assert(msg.value == TICKET_AMOUNT);
// Increase pot for each participant.
pot += msg.value;
// Compute some *almost random* value for selecting winner from current transaction.
var random = uint(sha3(block.timestamp)) % 2;
// Distribution: 50% of participants will be winners.
if (random == 0) {
// Send fee to bank account.
bank.transfer(FEE_AMOUNT);
// Send jackpot to winner.
msg.sender.transfer(pot - FEE_AMOUNT);
// Restart jackpot.
pot = 0;
}
}
}
简单的分析一下该合约。
该合约同样为一种游戏合约,合约中设定了固定的转账金额——TICKET_AMOUNT
。该合约需要满足参与者转账设定好的金额,并当msg.value满足条件后,触发参与合约,该合约设定了随机数random
并且该随机数为uint(sha3(block.timestamp)) % 2
。当该随机数的结果为0时获奖,获奖一方获得pot - FEE_AMOUNT
的金额,而庄家收取一定手续费。
看似简单的赌博游戏其中蕴含着一些漏洞可以操纵。block.timestamp
是可以进行预测的,而参与者可以通过预测该值而达到作恶的可能。
第三个合约例子为:
/**
*Submitted for verification at Etherscan.io on 2017-09-01
*/
contract Ethraffle_v4b {
struct Contestant {
address addr;
uint raffleId;
}
event RaffleResult(
uint raffleId,
uint winningNumber,
address winningAddress,
address seed1,
address seed2,
uint seed3,
bytes32 randHash
);
event TicketPurchase(
uint raffleId,
address contestant,
uint number
);
event TicketRefund(
uint raffleId,
address contestant,
uint number
);
// Constants
uint public constant prize = 2.5 ether;
uint public constant fee = 0.03 ether;
uint public constant totalTickets = 50;
uint public constant pricePerTicket = (prize + fee) / totalTickets; // Make sure this divides evenly
address feeAddress;
// Other internal variables
bool public paused = false;
uint public raffleId = 1;
uint public blockNumber = block.number;
uint nextTicket = 0;
mapping (uint => Contestant) contestants;
uint[] gaps;
// Initialization
function Ethraffle_v4b() public {
feeAddress = msg.sender;
}
// Call buyTickets() when receiving Ether outside a function
function () payable public {
buyTickets();
}
function buyTickets() payable public {
if (paused) {
msg.sender.transfer(msg.value);
return;
}
uint moneySent = msg.value;
while (moneySent >= pricePerTicket && nextTicket < totalTickets) {
uint currTicket = 0;
if (gaps.length > 0) {
currTicket = gaps[gaps.length-1];
gaps.length--;
} else {
currTicket = nextTicket++;
}
contestants[currTicket] = Contestant(msg.sender, raffleId);
TicketPurchase(raffleId, msg.sender, currTicket);
moneySent -= pricePerTicket;
}
// Choose winner if we sold all the tickets
if (nextTicket == totalTickets) {
chooseWinner();
}
// Send back leftover money
if (moneySent > 0) {
msg.sender.transfer(moneySent);
}
}
function chooseWinner() private {
address seed1 = contestants[uint(block.coinbase) % totalTickets].addr;
address seed2 = contestants[uint(msg.sender) % totalTickets].addr;
uint seed3 = block.difficulty;
bytes32 randHash = keccak256(seed1, seed2, seed3);
uint winningNumber = uint(randHash) % totalTickets;
address winningAddress = contestants[winningNumber].addr;
RaffleResult(raffleId, winningNumber, winningAddress, seed1, seed2, seed3, randHash);
// Start next raffle
raffleId++;
nextTicket = 0;
blockNumber = block.number;
// gaps.length = 0 isn't necessary here,
// because buyTickets() eventually clears
// the gaps array in the loop itself.
// Distribute prize and fee
winningAddress.transfer(prize);
feeAddress.transfer(fee);
}
// Get your money back before the raffle occurs
function getRefund() public {
uint refund = 0;
for (uint i = 0; i < totalTickets; i++) {
if (msg.sender == contestants[i].addr && raffleId == contestants[i].raffleId) {
refund += pricePerTicket;
contestants[i] = Contestant(address(0), 0);
gaps.push(i);
TicketRefund(raffleId, msg.sender, i);
}
}
if (refund > 0) {
msg.sender.transfer(refund);
}
}
// Refund everyone's money, start a new raffle, then pause it
function endRaffle() public {
if (msg.sender == feeAddress) {
paused = true;
for (uint i = 0; i < totalTickets; i++) {
if (raffleId == contestants[i].raffleId) {
TicketRefund(raffleId, contestants[i].addr, i);
contestants[i].addr.transfer(pricePerTicket);
}
}
RaffleResult(raffleId, totalTickets, address(0), address(0), address(0), 0, 0);
raffleId++;
nextTicket = 0;
blockNumber = block.number;
gaps.length = 0;
}
}
function togglePause() public {
if (msg.sender == feeAddress) {
paused = !paused;
}
}
function kill() public {
if (msg.sender == feeAddress) {
selfdestruct(feeAddress);
}
}
}
参与者参与到该合约中,合约将会将contestants
数组中添加参与者地址信息,而剩下的就是需要调用chooseWinner
函数来对获胜者进行挑选。
function chooseWinner() private {
address seed1 = contestants[uint(block.coinbase) % totalTickets].addr;
address seed2 = contestants[uint(msg.sender) % totalTickets].addr;
uint seed3 = block.difficulty;
bytes32 randHash = keccak256(seed1, seed2, seed3);
uint winningNumber = uint(randHash) % totalTickets;
address winningAddress = contestants[winningNumber].addr;
RaffleResult(raffleId, winningNumber, winningAddress, seed1, seed2, seed3, randHash);
// Start next raffle
raffleId++;
nextTicket = 0;
blockNumber = block.number;
// gaps.length = 0 isn't necessary here,
// because buyTickets() eventually clears
// the gaps array in the loop itself.
// Distribute prize and fee
winningAddress.transfer(prize);
feeAddress.transfer(fee);
}
该函数中定义了三个随机数种子,第一个为block.coinbase
——contestants[uint(block.coinbase) % totalTickets].addr;
第二个为msg.sender
——contestants[uint(msg.sender) % totalTickets].addr
第三个为——block.difficulty
。
而此刻我们也能过看出来,这三个随机数种子均是可以通过本地来获取到的,也就是说参与者同样可以对这三个变量进行提取预测,以达到作恶的目的。
由于totalTickets
是合约固定的,所以see1 2 3均可以由我们提前计算,此时我们就很容易的计算出randHash
,然后计算出winningAddress
。而获胜方的地址是根据位置所决定的,所以我们可以提前了解到获胜者是谁并可以提前将该位置占领。提高中奖概率。
每一个以太坊中的区块均有用于验证的哈希值,而该值可以通过block.blockhash()
来进行获取。这个函数需要一个指定块的函数来传入,并可以对该块进行哈希计算。
contract test{
event log(uint256);
function go() public{
log(block.number);
}
}
block.blockhash(block.number) 计算当前区块的哈希值
block.blockhash(block.number - 1)计算上一个区块的哈希值
block.blockhash()
下面我们具体来看几个实例。
首先是block.blockhash(block.number)
。
block.number
状态变量允许获取当前块的高度。 当矿工选择执行合同代码的事务时,具有此事务的未来块的block.number
是已知的,因此合约可以访问其值。 但是,在EVM中执行事务的那一刻,由于显而易见的原因,尚未知道正在创建的块的blockhash,并且EVM将始终为零。
有些合约误解了表达式block.blockhash(block.number)的含义。 在这些合约中,当前块的blockhash在运行时被认为是已知的并且被用作随机数的来源。
为了方便我们对合约进行解读,我们将其中关键函数拿出来:
function deal(address player, uint8 cardNumber) internal returns (uint8) {
uint b = block.number;
uint timestamp = block.timestamp;
return uint8(uint256(keccak256(block.blockhash(b), player, cardNumber, timestamp)) % 52);
}
为了便于我们观察,我们将函数稍微修改一下,
event log(uint8);
function deal(address player, uint8 cardNumber) returns (uint8) {
uint b = block.number;
uint timestamp = block.timestamp;
log(uint8(uint256(keccak256(block.blockhash(b), player, cardNumber, timestamp)) % 52));
return uint8(uint256(keccak256(block.blockhash(b), player, cardNumber, timestamp)) % 52);
}
这样我们就拿到了每次执行的结果。
我们执行两次:
而通过log我们能够给知道每次的结果,也就是说这个随机数其实是可以预测的,我们用户就可以根据预测的值进行作恶。
function random(uint64 upper) public returns (uint64 randomNumber) {
_seed = uint64(sha3(sha3(block.blockhash(block.number), _seed), now));
return _seed % upper;
}
同样,该函数也存在类似的情况,我们知道now是所有用户都可以获得的,而该合约使用的所有随机数种子均是可获得的。且该_seed
变量可以存在于区块中,并通过web3的内部函数获取。具体的方法我们在下文中进行讲解。