11. Elevator
通关条件
将目标合约的 top 字段修改为 true
目标合约
1 | pragma solidity ^0.5.0; |
解题过程
可以看到按照目标合约的逻辑,如果 building.isLastFloor(_floor)
为 true,函数将无法修改 top 的值。注意到 building.isLastFloor/1
执行了两次,那么我们只需要让这个函数两次执行返回不一样的结果就好。攻击合约如下:
1 | contract HackBuilding is Building { |
合约部署后,调用 goTo(任意楼层)即可。
12. Privacy
通关条件
解锁目标合约
目标合约
1 | pragma solidity ^0.5.0; |
解题过程
和第八题一样的解法。
合约存储布局如下:
1 | // 1 byte, slot 0 |
所以我们要获得 data 就可以:
1 | await web3.eth.getStorageAt(instance, 5); |
需要注意的是,要解锁合约的 _key
是 bytes16 类型的,而 data 是 bytes32 类型的,bytes32 转换到 bytes 16 会截断超出的 bytes,也就是我们只取前 16 个 bytes 就好。
13. Gatekeeper One
通关条件
通过目标合约所有 modifier 的检查,设置目标合约的 entrant 字段
目标合约
1 | pragma solidity ^0.5.0; |
解题过程
要通过 gateOne 很容易,使用中间合约调用 enter 即可
要通过 gateThree 也挺简单:
- part one 说明 4 字节的
_gateKey
和 2 字节的_gateKey
是相同的 - part two 说明 4 字节的
_gateKey
和 8 字节的_gateKey
是不同的 - part three 说明 4 字节的
_gateKey
和 2 字节的tx.origin
是相同的
又有:
uint16(address) 的转换会保留 address 最后两个字节,由上述 1,3 可得,uint32(uint64(_gateKey))
等于 uint32(tx.origin) & 0x0000FFFF
。再结合上述 2,只要 _gateKey
的最后 4 个字节为 uint32(tx.origin) & 0x0000FFFF
,其前面的 4 个字节可以为全 0 外的任意值。我们这里直接取 player 地址的最后八个字节,然后将其第 5,6 个字节替换为 0 得到 _gateKey
。
此题最麻烦的就是 gateTwo,要求执行到 gateTwo 的时候,剩余的 gas 对 8191 取模为 0。我们先部署一个攻击合约:
1 | contract HackGateOne { |
用 _gasAmount = 90000
尝试 hack 一下。交易失败后可以通过 etherscan 查看 Geth trace,找出 GAS opcode(gasleft() 的 opcode)的位置,得到:
可见执行到 gasleft() 的时候还剩余 89791 gas,gasleft() 本身又消耗 2 gas,所以本次合约执行 gasleft() == 89789
。我们的目标是让 gasleft() == 81910
,所以 _gasAmount
调整为 90000 - (89189 - 81910) = 82121
,再次提交后成功过关。
*在 remix 中可以通过 Debug 看到 gas 消耗情况,但是或许由于目标合约的编译器版本和编译器设置和本地环境有所区别,所以得到的结果并不准确。
14. Gatekeeper Two
通关条件
通过目标合约所有 modifier 的检查,设置目标合约的 entrant 字段
目标合约
1 | pragma solidity ^0.5.0; |
解题过程
gateOne: 同上一题
gateTwo:要求调用者的 extcodesize 为 0,看起来和 gateOne 冲突了,因为 gateOne 要求通过中间合约调用目标合约,但是中间合约的 codesize 是必然大于 0 的。查阅资料后得知,如果在中间合约的 constructor 中调用目标合约,此时中间合约的 extcodesize 为 0。
gateThree:异或运算具有自反性,即 A XOR B XOR B = A XOR 0 = A,所以 bytes8 _gateKey = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ (uint64(0) - 1))
由上述可得攻击合约:
1 | contract HackGateTwo { |
15. Naught Coin
通关条件
目标合约是一个 ERC20 代币合约,玩家的合约被锁仓十年。绕过锁仓限制提取资金即可通关
目标合约
1 | pragma solidity ^0.5.0; |
解题过程
ERC20 标准中有 approve 和 transferFrom 函数,可以允许第三方动用代币持有人的资金,所以先写一个攻击合约:
1 | contract HackCoin { |
然后使用 contract.approve('attacker contract address', (await contract.balanceOf(player)).toString())
将玩家的所有代币委托给攻击合约。
最后调用攻击合约 transfer(player, target, value)
即可。
16. Preservation
通关条件
获得目标合约的 ownership
目标合约
1 | pragma solidity ^0.5.0; |
解题过程
回顾一下第 6 题:
delegatecall 调用的是其他合约的代码,但是修改的是当前合约的存储空间
考虑以下因素:
- Preservation 调用 setTime 的时候,修改的是 Preservation 的存储空间,而非 LibraryContract 的。
- setTime 执行的时候,会对 uint 类型的 storedTime 赋值,这实际意味着对存储空间中的 slot 0 进行赋值。
- Preservation 存储空间中 slot 0 存储的是 timeZone1Library,也就是调用 setTime 将会导致 timeZone1Library 被修改。
综合以上 3 点,我们可以将 timeZone1Library 替换为攻击合约。攻击合约沿用上面的思路,构建一个新的 LibraryContract,该合约在调用 setTime 的时候,会修改存储空间中 slot 2 的值(Preservation 的 slot 2 存储的是 owner)。攻击合约如下:
1 | contract HackPreservation { |
部署攻击合约,得到 attackerContractAddress,执行以下操作:
1 | contract.setFirstTime('attackerContractAddress'); |
至此攻击完成。
17. Recovery
通关条件
找出目标合约生成的代币合约,并取出里面的 ETH
目标合约
1 | pragma solidity ^0.5.0; |
解题过程
直接去 Etherscan 找 instance 的交易记录,可以很容易找到代币合约,然后调用其 destroy 函数即可。操作如下:
1 | let func = web3.eth.abi.encodeFunctionSignature('destroy(address)') |
不过按照通关之后给的 tips 的意思,应该是想让玩家计算出代币合约地址。
合约地址的计算方式为:
1 | address = keccak(RLP([creator, nonce]))[12:] // 取右边的 20 个 bytes |
更多信息可以参考这里。
18. MagicNumber
通关条件
部署一个只有 10 个 opcode 的合约,该合约在调用后返回 42
调用者合约
1 | pragma solidity ^0.5.0; |
解题过程
不会,找到了这篇题解。我们需要手工构造一个合约。
创建合约的交易的 bytecode 主要由初始化代码和运行时代码两部分组成。初始化代码用于创建合约,并存储运行时代码;运行时代码则是合约的实际逻辑。
首先考虑运行时代码。我们需要将 42(0x2A)存放到内存中,再返回给调用者。第一个步骤需要使用 MSTORE(0x52,用于将一个 (u)int256 写入内存,0x52),第二个步骤需要使用 RETURN(0xF3,返回合约调用的结果)。
第一步需要执行:
1 | PUSH1 0x2A(PUSH1,0x60,用于将 1 byte 的值推入插槽栈中。这里我们需要存储 42) |
第二步需要执行:
1 | PUSH1 0x20(返回值的长度,我们设置为 32 bytes) |
两个步骤结合起来得到我们的运行时代码 0x602A60805260206080F3
,刚好 10 个 opcode,同时也是 10 bytes。
现在考虑初始化代码。初始化代码需要拷贝运行时代码并返回给 EVM。第一个步骤需要使用 CODECOPY(0x39,用于拷贝运行时代码),第二个步骤也是 RETURN。
第一步需要执行:
1 | PUSH1 0x0A(运行时代码的大小,10 bytes) |
第二步需要执行:
1 | PUSH1 0x0A(运行时代码的长度,10 bytes) |
结合以上两步我们可以得到初始化代码一共 12 bytes,运行时代码会接在初始化代码之后,所以上面的 0x?? 实际上是 0x0C(运行时代码在 bytecode 中的起始索引为 12)。由此得到我们的初始化代码 0x600A600C600039600A6000F3
将初始化代码和运行时代码组合起来就得到了我们的 bytecode:0x600A600C600039600A6000F3602A60805260206080F3
。
接下来部署我们的合约并设置为 Solver:
1 | let bytecode = "0x600A600C600039600A6000F3602A60805260206080F3"; |
最后提交通关~
补充:有关 EVM 和 opcode 相关的内容可以查看这里。
19. Alien Codex
这道题获取 instance 的时候有 BUG,需要手工做些处理。详情在这儿。
通关条件
获得目标合约的 ownership
目标合约
1 | pragma solidity ^0.5.0; |
解题过程
该合约的 owner 字段定义在 Ownable 中,存储在 slot 0,我们的目标就是替换 slot 0 中的数据。
注意以下几点:
- 目标合约定义了一个叫 codex 的 bytes32[] 类型的数组,我们可以向该数组中添加/修改数据,还可以修改数组的长度
- 在 Solidity 中,插槽数组大小为 2**256,codex 中的元素为 bytes32 类型,所以一个元素会占据一个 slot
- 在 Solidity 中,(2**256 - 1) + 1 = 0
- 各个字段在 Storage 中的布局:
1 | address _owner: slot 0, 20 bytes |
综合这些信息不难想到,我们可以通过让 codex 溢出来访问到 slot 0。Storage 的情况可以使用 remix 的 Debug 功能来观察、验证。
具体的攻击步骤如下:
1 | contract.make_contact() |
20. Denial
通关条件
阻止目标合约的 owner 从合约中转账
目标合约
1 | pragma solidity ^0.5.0; |
解题思路
本题有个很明显的重入漏洞,看似我们可以通过重入 withdraw 方法将合约中的资金全部转走,如此一来,owner 自然无法成功转账。但是!每次转账金额都是余额的 1%,转是转不完的,重入是不行的。所以我们需要另想办法(按照题意也是要求在合约中还有资金的时候 DOS)。
主要思路是,让合约在执行时消耗完所有 gas,交易失败。所以我们可以构建这样的攻击合约:
1 | contract DenialAttacker { |
将该合约设置为 WithdrawPartner 后提交即可通过。
21. Shop
通关条件
实现一个函数,使得两次调用返回值不同
目标合约
1 | pragma solidity ^0.5.0; |
解题思路
和第 11 题很像,但是此题的 price 函数被设置为 view,所以我们无法利用攻击合约中的字段来处理返回值。注意到 Shop 中有个 isSold 字段,我们可以利用它来调整返回值,攻击合约如下:
1 | contract ShopAttacker is Buyer { |
但是在尝试提交后发现交易失败。经过查询得知,随着 EIP-1884 的实施,SLOAD 的 gas 消耗增加了,导致此题目前无解,详见这里。