接到了链上的项目,正好学习一下
0x00 环境搭建
我用的是这个靶场,需要下载MetaMask,下载完毕之后可以切换到Sepolia测试币,去这里开源挖币,就有测试币可以搭建Instance了(不过需要认证score,也很简单,只需要认证好github就能过)
0x01 题目
00 Hello Ethernaut
这个题目完全是新手教程,让我们与contract的info这个method进行交互,我们打开控制台按照他的意思一直contract.xxx(“xxx”)就行了
01 Fallback
漏洞类型:不算漏洞
take-away msg:fallback/receive 函数触发时机
Basic
这一关的名字是Fallback,fallback是一类函数,这类函数可以在合约接收到以太币时执行一些操作(Exploration里有更精确的定义)。本题中,当接收的币大于0并且币的发送方在发送这条消息之前已经对该合约有了贡献(发送过币),那就把这个合约的所有权移交给币的发送方。
也就是说,我们只需要1
2await contract.contribute({value:1})
await contract.sendTransaction({value:1})
就已经将控制权掌握在我们手中了,此时就可以使用被onlyOwner这个modifier修饰的withdraw函数了1
2
3await contract.withdraw()
await contract.owner() // 查看合约所有权
await getBalance(contract.address) // 查看合约剩余余额
Exploration
我很好奇按照上面的说法,contribute两次应该也可以吧,不用非得contribute一次再sendtransaction一次才能提权。后来我尝试了一下果然不行,因为contribute在合约里有明确定义,合约收到Ether之后会调用contribute里的逻辑去处理这个Ether。但sendTransaction属于向合约发起交易,并且没有附加数据(msg.data),因此会隐式调用receive函数(也就是Fallback函数)进行逻辑处理。
因此本题fallback函数的调用条件是:当合约接收到代币但没有处理代币的函数,并且没有附加数据时,会调用fallback函数来处理。但我问了GPT老师,他的回答是这样的:
触发 fallback 函数的情况:
①调用不存在的函数:如果调用的函数在合约中不存在,且合约定义了 fallback 函数,则会触发 fallback 函数。
②发送以太币但没有数据,且没有定义 receive 函数:如果合约中没有定义 receive 函数,发送纯以太币且没有附加数据时,会触发 fallback 函数。
③发送以太币且有附加数据:如果发送以太币且附加了数据(即 msg.data 不为空),即使合约定义了 receive 函数,也会触发 fallback 函数。
exp如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38await contract.owner()
'0x3c34A342b2aF5e885FcaA3800dB5B205fEfa3ffB'
await getBalance(contract.address)
'0'
await contract.contribute({value:1})
...
await contract.owner()
'0x3c34A342b2aF5e885FcaA3800dB5B205fEfa3ffB'
await getBalance(contract.address)
'0.000000000000000001'
await contract.contribute({value:1})
...
await contract.owner()
'0x3c34A342b2aF5e885FcaA3800dB5B205fEfa3ffB'
await getBalance(contract.address)
'0.000000000000000002'
await contract.sendTransaction({value:1})
...
await contract.owner()
'0x9a80a78bebE92EF9cDfC4CA116fC4925237fDbd0'
await contract.withdraw()
...
await contract.owner()
'0x9a80a78bebE92EF9cDfC4CA116fC4925237fDbd0'
await getBalance(contract.address)
'0'
ps: 打这关的时候提交有的时候会显示不通过,但多submit几次就好了
02 Fallout
漏洞类型:权限设置错误/关键函数拼写错误
这题貌似是想告诉我们不要把构造函数公有化?因为只要调用这个Fal1out函数就能掌握所有权了。
1 | await contract.Fal1out() |
03 Coin Flip
漏洞类型:预测伪随机数
take-away msg:部分全局变量;伪随机数预测
猜硬币,这种随机数肯定是伪随机数,可以预测的。
看合约源码得知blockValue是前一个区块的hash值,并将其转换为无符号整数。
如果lasthash值(上个区块的hash值)等于blockValue,就说明这个blockValue被用过了,调用revert函数返回gas。我们只能等新区块生成之后再预测(比特币10min生成一个区块,以太坊只需要15s)
如果lasthash等于blockValue,就用blockValue除以FACTOR得到一个结果coinFlip,我们要猜这个数字是1还是0。
源码分析完了,伪随机数是blockValue,由于我们已经知道了FACTOR,所以只要知道了blockValue就可以直到后面的全部内容,我们来分析一下为什么可以预测blockValue
当交易在链上被发送时,这些交易会被矿工打包到即将挖出的下一个区块中,区块包含多个交易,这些交易会顺序执行。在同一笔交易中,如果一个合约调用另一个合约,这些调用会在同一个区块中顺序执行。区块链的每个区块都有一个固定的时间窗(例如,比特币的每个区块时间为10分钟,以太坊的每个区块时间为15秒)。在这个时间窗内,所有被打包到这个区块中的交易会共享相同的区块哈希和区块编号。
因此,只要我们写一个攻击合约,在攻击合约中获取block.number,再调用被攻击合约的flip函数,那么flip中的block.number就跟攻击合约中的number是相同的了。因此也就达成了攻击的目的。
1 | pragma solidity ^0.8.0; |
04 Telephone
漏洞类型:不算漏洞
take-away msg:直接调用者 & 间接调用者
本题主要考察 tx.origin 与 msg.sender 的区别,tx.orgin 指的是交易的发起方,msg.sender 指的是合约的直接调用者。比如,当用户X通过合约A调用合约B时:对于合约A,tx.origin 与 msg.sender 都是用户X;对于合约B,tx.origin 是用户X,msg.sender 是合约A
因此我们只需要部署一个合约A,通过该合约调用题目函数即可。
1 | pragma solidity ^0.8.0; |
05 Token
漏洞类型:整数溢出
take-away msg:整数溢出
整数溢出。balances 和 value 都是 uint256 型的,所以减完了还是正数,由于初始余额有20,我们直接减21即可1
2
3
4contract.address
'0x440f5dD58996e284Bf339399cE48580cf2ADC84b'
await contract.transfer("0x440f5dD58996e284Bf339399cE48580cf2ADC84b",21)
06 Delegation
漏洞类型:不算漏洞
take-away msg:代理合约;delegatecall
本题考察对合约调用方式的理解。合约调用共有三种方式
- call: 调用后内置变量 msg 的值会修改为调用者,执行环境为被调用者的运行环境
- delegatecall: 调用后内置变量 msg 的值不会修改为调用者,但执行环境为调用者的运行环境(相当于复制被调用者的代码到调用者合约)
- callcode: 调用后内置变量 msg 的值会修改为调用者,但执行环境为调用者的运行环境
貌似 “callcode” 已被弃用,取而代之的是 “delegatecall”
也就是说,当我们以 call 的形式通过合约 Delegation 调用合约 Delegate 的 pwn 函数时,Delegate 中记录的 msg.sender 是 Delegation 的地址;而当我们以 Delegate call 的形式通过合约 Delegation 调用合约 Delegate 的 pwn 函数时,Delegate 中记录的 msg.sender 是我们自己的地址。
类比 Telephone 那道题,普通 call 的 msg.sender 就是 msg.sender,而 DelegateCall 的 msg.sender 是 tx.origin。
在本题中,合约 Deletation 部署了合约 Delegate,并且有 pwn 函数可以让我们提权,我们需要通过触发 fallback,通过传入的 data 调用 pwn(),即await contract.sendTransaction({data:"0xdd365b8b"});
,至于为什么是 0xdd365b8b,这好像是 pwn() 函数的签名再哈希以后的前四字节,可以通过下面的 solidity 代码生成
1 | pragma solidity ^0.4.0; |
也可以通过await web3.utils.keccak256("pwn()")
生成
07 Force
漏洞类型:不算漏洞
take-away msg:通过自毁函数强制转入eth
本题给了空合约,旨在让我们学会如何在合约拒绝转入时强制给它转入 ETH
可以通过 selfdestruct() 函数来完成目标,这个合约析构函数有以下性质:
指令执行后,合约将拒绝服务,地址对应的字节码将被标注为删除
合约地址中所有的 ETH 将被发送到指定的新地址
进行 ETH 转移时,即使目标地址为一个合约地址,也不会触发该地址的 fallback 函数,因此不需要该合约有任何的 payable 函数
如果 selfdestruct 函数被非预期执行,整个合约会拒绝服务
也就是说我们可以写一个合约,让它析构的时候给要攻击的合约转入 1wei,在 remix 上部署以下合约即可,记得 value 设置成 1wei
1 | pragma solidity ^0.8.0; |
08 Vault
漏洞类型:隐私信息上链
take-away msg:不要把隐私信息上链,因为全公开
vault 是金库的意思,合约源码把密码设置成 private 了,但这个 private 是幽默 private,因为区块链中所有存储在 storage 里的东西都是公开的,也就是说我们可以通过 getStorageAt 来查看合约的情况
1 | await web3.eth.getStorageAt(instance); |
09 King
漏洞类型:未检查返回值
((await contract.prize()).toString());
可以查看这个合约的余额。
transfer 转账时如果遇到错误会 revert, 根据这个特性可以使得某个恶意合约成为 king, 该合约的 receive 方法始终 revert,而call和send函数则不是,而是返回一个false,因此这就是为什么需要检查call和send的返回值的原因。
直接写个合约,在里面fallback抛出异常即可。不过设置 revert 有个比较麻烦的点是不能给这个合约转钱了,所以只能把我的钱包地址设置白名单了。不过或许可以用之前那个 Force 的方式试试🤔之后再说吧
1 | // SPDX-License-Identifier: MIT |
10 Re-entrancy
漏洞类型:重入攻击
take-away msg:重入的举例与防护
重入攻击久仰大名,重入指的是攻击者在被攻击合约更新余额前重复提款的行为,我们可以在 fallback 或者 receive 函数里再次调用 withdraw,这样他就会再次向被攻击合约提出提款请求。
我们可以用await getBalance(contract.address)
来查看合约的余额,这里“合约余额”与“合约定义的用户余额”不太一样,要注意一下。
还有就是,我最后攻击的时候用户余额已经溢出了,仍然不能提款,应该是每次提款的数目已经大于“合约余额”了,比如合约余额只剩0.004的时候我要提0.01,那就是不行的,只能以0.001为单位去提取。
exp 如下,不过要在前面加上 SafeMath库,然后把题目源码也粘在前面
1 | // SPDX-License-Identifier: MIT |
11 Elevator
漏洞类型:不算漏洞
take-away msg:view/pure?但我没用到这两个修饰符
这道题在本地实现好 isLastFloor,让其根据调用次数的不同返回不同值就行,第一次返回 false,第二次返回 true.
题目提示合约不适合保存 promises,我有点没太理解 promises 的意思…或许之后可以看看 view 和 pure 来帮助理解这一点吧。今天刷了 8 道题,得休息一下..
题目说:
你可以在接口使用 view 函数修改器来防止状态被篡改. pure 修改器也可以防止状态被篡改. 认真阅读 Solidity’s documentation 并学习注意事项. 完成这一关的另一个方法是构建一个 view 函数, 这个函数根据不同的输入数据返回不同的结果, 但是不更改状态, 比如 gasleft().
抽时间可以看看
1 | // SPDX-License-Identifier: MIT |
12 privacy
漏洞类型:隐私数据上链
跟 Vault 那题区别不大,也是计算出变量存储的 slot 然后 getStorageAt 就可以了,只不过 unlock 函数里取了 data[2] 的前十六字节
1 | bool public locked = true; //0 |
1 | await web3.eth.getStorageAt(instance,0); |
13 Gatekeeper One
漏洞类型:不算漏洞
take-away msg:全局变量gas;数据类型转换
先看gate1,跟 telephone 那道题一样,写个合约调就行了。
gate2需要控制 gas,比较直接的方式就是爆破 call 函数里的 gas 参数
1 | for (uint i = 0; i < 500; i ++) { |
gate3 有这样几条限制:
1 | // 低 2byte 与 低 4byte 相等 |
bytesxx 取的是高位的xx字节,uintxx 取的是低位的xx位,当小变量与大变量作比较时,会将小变量零扩展,即前面添0. 了解这些 gate3 就可以绕了。很简单,只要取自己钱包地址的低 64bit,与 0xffffffff0000ffff 相与就行了
1 | // SPDX-License-Identifier: MIT |
14 Gatekeeper Two
漏洞类型:不算漏洞
take-away msg:yul
gate1 也是要调合约
对于 gate2 ,caller 函数等价于返回 msg.sender,extcodesize 函数是计算该地址的合约代码长度,gate2 中要求合约代码长度为 0,而当合约的构造函数正在执行时,该合约的代码还未被完全部署到链上,因此 extcodesize 在这个时间点上返回的结果为 0。
所以我们选择在 Attack 函数的构造函数中执行攻击代码
对于gate3,我们需要让 gateKey 等于 0xffffffffffffffff ^ MitM 调用 enter 的函数签名,exp 如下
1 | contract Attack { |
15 Naught Coin
漏洞类型:不算漏洞
take-away msg:ERC代币
ERC-20 标准支持将一定数量的代币授权 (approve) 给某个地址 (被授权方), 然后被授权方就能够使用 transferFrom 支配授权方的代币
1 | // current balance: 1000000000000000000000000 |
16 Preservation
漏洞类型:delegatecall修改状态变量
take-away msg:delegatecall是本地上下文执行
我们再来重新认识一下 delegatecall:
假设我们有合约 A 通过 delegatecall 调用了合约 B 的函数 funcB,那么 funcB 中修改的变量其实都是合约 A 中的变量。那么可能有人要问,合约 B 是怎么知道 A 的变量的?实际上,delegatecall 是根据状态变量的定义顺序去寻找被调用合约对应的 slot 位置, 然后进行访问和修改。
因此,LibraryContract 中的 setTime 修改的 storedTime,其实是修改了 Preservation 合约中的 timeZone1Library,我们可以将其修改为恶意合约地址。
1 | await contract.setFirstTime("0xAC93AF93Afb73D776694684bDD39dD900EF27C9D"); |
但这里我有一个不太明白的点是,明明传入的参数是 32bytes 的,而 timeZone1Library 是 20bytes 的 address 变量,我不清楚是怎么数据转换以及放入 slot 当中的
而当我们修改了 timeZone1Library 为恶意合约地址,再用 setFirstTime 调用 setTime 就调用的是恶意合约的 setTime 了,此时恶意合约直接写好同样的 setTime 签名,内部实现 owner = msg.sender 即可成功提权。
1 | // 改为恶意合约的地址 |
1 | // SPDX-License-Identifier: MIT |
17 Recovery
漏洞类型:不算漏洞
take-away msg:链上一切信息都是公开透明的
十七题了,还是没办法脱离题解自己想到哪里是 vulnerable 的,合约基础还没打牢…得继续努力😭
这道题大意为 Recovery 合约创建了 SimpleToken 合约,但是找不到 SimpleToken 这个合约的地址了,所以需要我们找到这个地址,并把这个地址里的币转移出来。
首先要解决的问题是:如何找到这个合约?
- 方案一:etherscan 直接搜实例地址
通过跟进“实例地址”(其实就是 Recovery 这个合约的地址)创建的新合约的地址,我们可以找到 SimpleToken 的地址
- 方案二:计算
这个方案我没仔细看…先把链接放在这儿
1 | import web3 |
找到合约之后,我们要解决的问题是,如何把 SimpleToken 中的合约代币转出来?
我们可以看到 SimpleToken 合约中存在 destruct 函数,而 Remix 为我们提供了与现有合约交互的功能,即部署时的 “At Address”,这里借了张图👇
将我们钱包的地址写在 destroy 中,就可以将合约中的代币转出啦
18 MagicNumber
漏洞类型:不算漏洞
take-away msg:EVM bytecode
今天完全不想看原理。。先 fork 一下别人的 wp 改天再来看
条件: 提供一个合约地址, 该合约最多包含 10 个 opcode, 并在调用 whatIsTheMeaningOfLife 方法时返回数字 42
https://medium.com/zeppelin-blog/deconstructing-a-solidity-contract-part-i-introduction-832efd2d7737
代码分为两部分
- initialization code
- runtime code
具体讲解看文章就行
runtime code
1 | PUSH1 0x2a ; store 42 in memory |
转成hex正好10bytes602a60805260206080f3
根据上面几篇文章里介绍的原理, 编写 runtime code 的时候其实无需考虑具体的方法名是什么 (whatIsTheMeaningOfLife)
因为 EVM 执行字节码时永远都是从上至下执行, 而正常合约字节码的开头会根据 calldata 内 selector 的值 JUMPI 到特定的位置, 以此实现不同方法的调用 (路由)
对于这题来说, 只需要返回 42 就行 (RETURN), 也就无需加入针对 selector 的判断
initialization code
1 | PUSH1 0x0a ; copy runtime code to memory |
转为hex600a600c600039600a6000f3
最终 hex, 前 12 bytes 为 initialization code, 后 10 bytes 为 runtime code
0x600a600c600039600a6000f3602a60805260206080f3
1 | // deploy contract |
19 Alien Codex
漏洞类型:
take-away msg:数组越界访问+slot对应keccak标识符(还不是很懂)
做这道题之前先阅读这篇,读到动态数组的存储方式
另外,我们可以通过以下代码来查看 codex 存放的真实 slot 地址:
1 | function getSlotForArrayElement(uint256 _elementIndex) public pure returns (bytes32) { |
此外,一个合约中存在 2^256 个 slot 槽,也就是说,slot[2^256 - 1] 就是 slot 的最后一个 32bytes 长的元素。同时,本题 revise 函数虽然在修改下标大于 length 的越界变量时会报错,但 retract 函数并没有对容量做检查,因此,我们很轻松地就可以通过 retract 函数将 codex 的 length 减为 2^256-1,进而修改通过 revise(i,’evilcontent’) 将 slot 中任意一个位置修改为 ‘evilcontent’。
在本题中,我们需要将 slot[0] 中的 owner 改为我们自己钱包的地址。因此,思路如下:
- makeContact
- 通过 retract 函数将 length 减为 2^256 - 1
- 此时使用 keccak256(abi.encodePacked(uint256(1))) 计算出存放 codex 的插槽的 slot 地址
array_addr
- 因为 array_addr 距离 slot[0] 还有 diff = (2^256 - 1) - array_addr + 1 这么多;而 codex[0] 正好是 slot[array_addr]。
- 因此,slot[0] = codex[0 + diff],exp如下
有了 slot[0] 的位置,而且我们现在也已经知道 contact 在 slot[0] 中,codex 的长度独占 slot[1]。而根据这篇文章,Ownable-05 中的 owner 地址变量存放在 AlienCodex 合约变量的上方,也就是 slot[0],从代码角度看,可以理解为
1 | address private _owner; |
因此,我们可以将 slot[0] 修改为 0x..001\
。exp 如下:- await contract.makeContact()
await contract.retract()
我们可以在本地部署以下合约,并调用getSlotForArrayElement(0),这里本地回显的
0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6
这个地址跟远程存储 codex 的 slot 地址一模一样计算 diff = 2^256 - 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6,得到diff = 0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a,即35707666377435648211887908874984608119992236509074197713628505308453184860938
await contract.revise(diff,’0x0000000000000000000000019a80a78bebE92EF9cDfC4CA116fC4925237fDbd0’)
1 | // SPDX-License-Identifier: MIT |
20 Denial
漏洞类型:gas耗尽
条件: 在 owner 调用 withdraw 时拒绝提取资金 (合约仍有资金, 并且交易的 gas 少于 1M)
其实就是在 receive/fallback 里写一个死循环将 gas 耗尽, 这样后续调用 transfer 转账的时候就会 revert
1 | // SPDX-License-Identifier: MIT |
1 | await contract.setWithdrawPartner('0x"evil_contract_addr"'); |
21 Shop
漏洞类型:
take-away msg:合约调用的非原子性?
本题针对 view 函数的缺陷进行了攻击,跟 Elevator 那题的思路很像,都是在 victim 合约在调用外部 evil 合约函数时,自身状态变量的更新前没有合理约束条件导致的,攻击流程如下:
当 MyBuyer 合约调用 shop.buy() 时,Shop 合约会调用 MyBuyer 的 price 函数。
在 price 函数被调用时,shop.isSold() 仍然是 false,因此返回 101。
Shop 合约检查 price 返回的值为 101,满足条件,于是设置 isSold 为 true,并更新 price 为 101。
然后 Shop 再次调用 price 函数为状态变量 price 赋值,此时 shop.isSold() 已经是 true,所以返回 99。因为 isSold 已经更新为 true,Shop 合约不会再次进行检查或更新。
exp 如下
1 | // SPDX-License-Identifier: MIT |
22 Dex
漏洞类型:精度缺失
take-away msg:solidity不支持原生浮点数,DEX
这道题背后考察的是 dex 的原理,但由于浮点数处理异常,每次交换时用户希望交换的数目可能小于实际交换的数目,因此来回倒几笔钱,我们就可以多拿到本来不属于我们的钱(?
dex 的基本原理是将一对代币加入流动池以提供流动性, 这样就可以实现两种代币之间的交换, 交换的价格是根据流动池内代币的比例动态计算的
对于这道题来说, 流动池内一对代币 X 和 Y 之间的汇率与这两种代币在流动池内的总量成反比
比如池子里有 100 X 和 10 Y, 那么汇率就是 1 Y = 10 X
如果流动池内代币 X 的总量增大, 那么 X 相对于 Y 在贬值, 即每个 X 能兑换的 Y 会变少
相反, 如果 X 的总量减少, 那么 X 相对于 Y 在升值, 即每个 X 能兑换的 Y 会变多
题目不能够手动修改 token1 或 token2 的地址
然后虽然 addLiquidity 函数被限制了 onlyOwner, 但实际上仍然可以通过手动调用 token1/token2 合约的方式来强制添加流动性, 不过这个点具体怎么利用目前还没怎么想到
getSwapPrice 用于动态计算用代币 from 兑换 to 时的价格,但 Solidity 没有浮点数, 整数之间相除得到的结果会被去整, 即丢掉后面的小数位数。
当用户在 token1 和 token2 之间来回兑换时, 可以看到每次能拿到的代币数量其实是在变多的, 这样多倒几次最终就能将 dex 池内某一类型的代币搬空
1 | let token1 = await contract.token1(); |
23 DEX TWO
漏洞类型:精度缺失
twm(take-away msg):同上
这题与 Dex 的区别在于 swap 函数没有了对 from 和 to 代币地址的限制, 这表示我们可以利用自己发行的代币与 dex 内的 token1/token2 交换
1 | // SPDX-License-Identifier: MIT |
1 | let token1 = await contract.token1(); |
做完这道题我才理解代币到底是干啥的…比如说有很值钱的 token1 与 token2,在 dex 交换时没有检测好币类,让非法币混入了池里,token1/2 就会被 token3 大量替代
同样,如果没有做好浮点数约束,也会造成资金的损失..
链子还是蛮有意思的 :D
24 Puzzle Wallet
漏洞类型:代理合约slot冲突
twm:代理合约存储槽冲突
这代码好长..这之后的题做的都懵懵的,之后对合约有一定的了解之后再回过头来看吧。
exp:
1 | // invoke PuzzleProxy.proposeNewAdmin() to change `owner` in PuzzleWallet |
25 Motorbike
漏洞类型:代理合约context错误
twm:利用代理合约上下文绕过限制
这道题使用了 ERC1967 ,ERC1967 提供了一种不改变合约地址而升级合约的标准方式。而在 ERC1967 中牵扯到了两种合约:代理合约与逻辑合约。
代理合约的作用是保持合约地址不变,同时通过更新 _IMPLEMENTATION_SLOT 中的地址来改变其指向的逻辑合约,实现合约的升级。在本题中,Motorbike 是代理合约(proxy contract),它在 constructor 函数中设置了逻辑合约的地址,并将逻辑合约初始化;还在 fallback 函数中捕获所有与合约接口不匹配的调用,并移交给 logic 合约去处理。而 engine 是逻辑合约(implementation/logic contract)。
以上是基础知识,我们来看一下这道题该怎么做。
首先,题目要求我们 selfdestruct 掉 engine 这个合约,而 engine 合约中并没有 selfdestruct 这个函数。但通过阅读代码我们可以看到,engine 合约在 _upgradeToAndCall 函数中调用了 “升级后的合约地址的 data 这一函数选择器代表的函数”,如果我们可以把升级后的合约地址指向我们的恶意合约 evil,然后在 evil 中设置 selfdestruct,那么就可以达成我们的目的了。
但题目还设置了一条防线:调用 upgradeToAndCall 的用户必须是 upgrader,而 motorbike 在实例化的时候就已经调用过一次 initialize 了,而 initializer 这个modifier 限定了这个初始化函数只能被调用一次。看起来我们没有办法改变 upgrader了。
但实际上,proxy contract的上下文中,initialize 函数确实已经被调用过了,但在 engine 这个 logic contract 的上下文中并没有被调用过。因此我们可以直接找到 engine 的合约地址,然后主动调用 initialize 函数。
exp 如下
1 | // SPDX-License-Identifier: MIT |
但这道题貌似现在出了些认证问题,我们可以在etherscan上看到这个合于确实调用了自毁函数,但好像没有执行自会,因此仍然提交不通过..
26 DoubleEntryPoint
这道题的主要合约为 DoubleEntryPoint(简称DEP),该合约持有 DET 这种代币,同时设计了 CryptoVault 这个合约用来管理 DET 代币,在 CryptoVault 中用户可以清理走合约中的非 underlying 代币(underlying 代币也是合约自定义的,是用来维护合约的、不可清理的代币)。而本题目想让我们利用Forta
来监控 DEP 的行为,防止 underlying 代币被清理,从而导致合约不可用。
当我们调用 sweepToken(LGT_address) 时,sweepToken 会调用 LegacyToken.transfer,
而此时 LegacyToken.delegate 已经被设置为了 DEP 合约的实例地址,因此 LegacyToken.transfer 会调用 DEP.delegateTransfer(sweptTokensRecipient, token.balanceOf(CryptoVault.address), CryptoVault.address),
此时如果 CryptoVault 中存在 DET,就会被全数转走。这是一个典型的“代币双重入口”漏洞,即通过操纵一个代币的转账逻辑间接影响另一个代币的行为。
防御起来也很简单,只要让 DEP 中的 delegateTransfer 的 origSender 不要是 CryptoVault 就好了
1 | // SPDX-License-Identifier: MIT |
27 Good Samaritan
这个合约是一个“好心的 Samaritan 人”(简称 S人)模板,这个 S人有一个钱包和一种代币,并且初始就有一百万个币。并且如果你问他要10个币,他会无私的捐款给你;即使他也穷得响叮当了、穷的不多于10个币,S人仍然会把自己剩余的钱捐给你。
现在我们是恶意用户,想要诈骗S人这一百万个币,但如果通过donate10来诈骗,需要调用十万次requestDonation,因此我们需要想一个走 catch 途径的方式。
只需要实现 INotifyable 接口, 然后在 amount 等于 10 的时候 revert NotEnoughBalance error, 这样 GoodSamaritan 就会再触发一次转账, 将钱包内所有的钱转走。
exp如下:
1 | // SPDX-License-Identifier: MIT |
28 GateKeeper Three
首先我们发现这个构造函数 construct0r 居然写错了,并且还用的public,所以我们写个 evilContract 调用一下 construct0r 就能将 owner 设置成 evilContract 的地址。
GateOne 要求调用者是 owner,但源头不是owner。很简单,我们通过 evilContract 操作即可。
GateTwo 要求 allowEntrance 为 true。只要 createTrick 与 getAllowance 在同一次 function(交易) 内执行,block.timestamp 就是一样的。
GateThree 要求该合约超过 0.001 ether,并且发给 evilContract 0.001 ether 失败。
exp 如下
1 | // SPDX-License-Identifier: MIT |
29 Switch
这道题要求我们掌握 CALLDATA 是如何编码的。所以我们先来恶补一下 calldata 的知识。
当合约A调用合约B,这条调用信息会被打包成一条交易信息,广播给区块链上的每个节点,并放入这些节点的交易池中;当前一个区块验证/挖掘完毕,所有节点会选择自己池中的一组交易信息,初步验证gas、余额等信息后生成一个候选block,在生成候选区块的同时,节点会启动EVM执行交易;当满足PoW后,节点会广播自己的候选区块让其他人验证;
而合约A调用B时,A会构建 CALLDATA,而B则通过 CALLDATA 来判断 A 调用了自己的哪一个 function。
现在我们已经了解了 CALLDATA 的应用场景,接下来我们看一下它长什么样子(编码方式):
1 | 0xbabecafe -> (keccak(function))[:4] |
上面是一个例子,CALLDATA 由四字节的函数签名和数据组成,具体怎么编码可以看这篇
回到这道题,我们肯定是需要调用 turnSwitchOn
来打开开关,但是这个函数限定了 onlythis,也就是说我们需要让目标合约自己调用 turnSwitchOn
可以看到 flipSwitch
有自己调用自己的 call 命令,但是需要绕过一些 modifier
首先我们来看一下这几个函数的签名:
1 | await web3.utils.keccak256("turnSwitchOff()") |
我们希望调用 flipSwitch
函数,因此我们会先在头部四字节写下0x30c13ade
,然后因为 bytes 是动态变量,因此我们会用 32 字节来记录 offset,32字节记录 length,然后是实际 data
1 | 0x30c13ade |
接着我们希望调用 turnSwitchOn
函数,所以实际 data 处我们希望是 turnSwitchOn 的标识符
1 | 0x30c13ade |
但 filpSwtich 被 onlyOff 修饰了,我们需要绕过这个修饰符。而这个修饰符中使用 calldatacopy(t,s,f),将 offset = s 处的 f 个字节拷贝到 t 地址处。而 calldatacopy(selector, 68, 4)
正好是我们现在 76227e12 的位置,我们需要在这写下 keccak256(“turnSwitchOff()”) 也就是 20606e15
之后,我们修改一下offset就能改变data的位置
1 | 0x30c13ade |
exp如下
1 | web3.eth.sendTransaction({from: player, to: instance, data: "30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000"}) |
30 HigherOrder
跟上一题差不多,也是 calldata 可以被我们人为操控进而达成一些目的
soliditylang 上两个 Yul 函数的介绍
1 | sstore(p, v) storage[p] := v |
1 | await web3.utils.keccak256("registerTreasury(uint8)") |
所以我们构造data为
1 | 0x211c85ab |
就可以绕过 treasury > 255 的检测了。
然后调用 claimLeadership 就行了
1 | 0x5b3e8fe7 |
exp 如下
1 | await web3.eth.sendTransaction({from: player, to: instance, data: "0x211c85ab0000000000000000000000000000000000000000000000000000000000000100"}) |
31 Stake
本题要求我们:
- Stake 合约的 ETH 大于 0
- totalStaked 需要大于合约的 ETH
- 我们必须成为 Staker
- 我们的 staked balance 为 0
StakeWETH 中的两个 abi:
0xdd62ed3e = allowance
0x23b872dd = transferFrom
对于第一点,我们可以调用 StakeETH 向合约转账,当然也可以调用自毁函数进行强制转账
对于第二点,由于 StakeWETH 函数被调用后没有检查 WETH.call 是否成功,所以我们可以实现零成本增加 totalStake
对于第三点,我们只需要调用 StakeETH 就能顺带成为 Staker
对于第四点,只要调用 unStake 全部取出就好了。
我们可以看以下几点:
getBalance(stake): Stake 合约的 ETH
totalStaked: totalStaked
Stakers[player]: 我们是不是 Staker
UserStake[player]: 我们的 staked balance
所以我们的逻辑如下:
用其他合约调用 WETH.allowance(0.0012 ether),再用其他合约调用 StakeWETH(0.0012 ether),同时自毁这个合约。这样,在不增加 UserStake[player] 的前提下,增加了 totalStaked,同时增加了合于存储的 ETH,满足了第一二点
我们自己调用 StakeETH(0.0011 ether), 增加了 totalStaked 和 Stake 合约的 ETH,将我们自己设置为 Staker,满足了第三点
我们自己再调用 Unstak(0,0011 ether),将我们的 staked balance 清零,满足了第四点
exp 如下
1 | // SPDX-License-Identifier: MIT |
1 | await contract.StakeETH({value: toWei("0.0011")}); |