Hackit

Posted on Oct 08, 2022Read on Mirror.xyz

以太坊智能合约逆向分析与实战:(6)访问动态数据类型 2

在之前的文章:以太坊智能合约逆向分析与实战:(3)[实战篇] 访问私有动态数据类型 中,我们以破解链上某“猜数字”游戏为例,讲解了映射这种动态数据类型在 EVM 中的存储与访问,这次我们把目标对准另一种动态数据类型: 动态数组 ,看看 EVM 是如何实现动态数组的存取。首先我们先看一个简单的合约:

图1

通过之前的学习,你一定能够轻松地判断出 slot 0 里面的值(没错就是 0x666666 ),但是对于动态数组,它的长度是随时变化的,其内容并不会像这些值类型一样固定在某个 slot 里。那么它是如何存储的呢?让我们先调用 record()函数 ,给动态数组压入一些数值(0xff0000, 0xff0001, 0xff0002,0xff0003), 此时的动态数组长度应该是4,我们通过其 length() 函数也可获知。然后打开调试器看一下:

图2

与之前讲的映射相比,我们可以发现动态数组的存储方式和映射有一些相似之处,却也有所不同。在动态数组中,进行变量声明的位置(slot2)存放着数组的长度,而数组内各元素的值,是按照 slot n、slot n+1、slot n+2 … 的顺序进行排列的。声明的位置我们找到了,但第一个元素所在的 slot n 该怎么找呢?答案是

keccak256(bytes32(1))

其中 bytes32(1)是指该动态数组声明时所占据的 slot 。我们可以计算一下:

图3

看,得到的结果与调试器中 codex[0] 对应的 key 相同。具体到本例, codex[0] = slot ( keccak256(bytes32(1)) + 0 )

codex[1] = slot ( keccak256(bytes32(1)) + 1 )

codex[2] = slot ( keccak256(bytes32(1)) + 2 )

……

然后我们通过读取对应的 slot ,就能够获取到动态数组的元素了。


本次篇幅较短,趁着还有时间,我们寻找一个被破解的对象:ethernaut 的一道题目,作为本次学习的课后习题:

图4

从上图可以看到,这是一个很短的合约,合约里 import 了一个 Ownable.sol 文件,这个文件会让部署的合约里面有一个 Owner 变量(可以参考 OpenZeppelin 的 Ownable.sol )。然而这个合约的 Owner 并不是我们的地址,而且合约也没有提供更改 Owner 的接口(即使引用的文件里实现了 transferOwnership(),我们也因为不是 Owner 而无法更改)。简单来说:我们的任务,就是要通过对这个合约的 动态数组 进行一系列神奇操作,从而获取 Owner 权限。

在研究动态数组之前,我们先铺垫一下:Owner 是谁呢,怎么查询到它(变量的存放位置在哪里)?

答:该Solidity 文件中并没有直接出现 Owner 变量,而是以 import 的形式引入的。一般来讲,它的变量存储 slot 是这样排列的:

图5

也就是说,合约先继承谁,就把谁的变量放在靠前的 slot 里面,等所有合约继承完毕,再存放本合约的变量。所以在本例中, 其 slot 0 中很可能存放的就是 Owner 的地址。我们拿来前一节用过的脚本来读取一下:

图6

看,果然如此。此处注意:由于 EVM 优化的缘故,address 变量 owner 与 bool 变量 contact 被打包放进了同一个 slot ,所以我们在一个 slot 里得到了两个变量的值:

owner = 0x3c34a342b2af5e885fcaa3800db5b205fefa3ffb

contact = false

然而,我们如何修改它呢?这就需要我们上面讲解的关于动态数组的知识了。

首先我们看图4的合约:变量 owner, contact 存放在 slot 0, 动态数组 codex[] 占用了slot 1 。通过阅读合约中的函数定义可以发现,我们无法修改 owner,但是却可以修改 codex[] 。

那么我们可以通过修改 codex[] 来实现修改owner 吗? 对于这个合约来说是可以的,因为它的代码存在漏洞——在 pragma solidity ^0.5.0 环境下,我们可以直接控制 codex[] 的长度( codex.length-- ),也就是说可以自由读写 slot 1 的数值,而高版本的 solc 就补上了这个漏洞。

通过本篇开头的讲解我们知道,在修改动态数组内某个元素的值的时候,例如 codex[9] = 0x12345678,实质上是将 0x12345678 写入到 存储槽 slot (keccak256(bytes32(1)) + n) 里面,而 keccak256(bytes32(1)) 能够被计算出来,等于是向 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6 + 9 这个地址内写入0x12345678,用汇编表示就是

0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cff

0x12345678

sstore

那么,如果把第一行改为 owner 所在的存储槽地址(slot 0),第二行改为 我们自己的账户地址,不就可以修改合约 owner 为我们自己的地址了吗?

上面说过,codex[n] 的值,就是 第 ( keccak256(bytes32(1)) + n )个 slot 的值。如果我们通过构造一个 n ,让 keccak256(bytes32(1)) + n = 0 ,就可以实现我们的目标了。但 keccak256(bytes32(1)) 本身是大于0的,如何让他加上 n 之后 “归零”呢?答案是——溢出。slot共有 2^256 个,按照 0 ~ 2^256-1 的顺序排列。我们需要让 keccak256(bytes32(1)) + n = 2^256 ,从而出现数据上溢,即可实现越过数组下标限制来修改存储槽。

但这就意味着我们要输入一个很大的数组下标,这就需要有一个很大的数组。那我们如何构造出这么大的数组呢?依靠codex.push() 去填充显然是极其不划算的,答案仍是——溢出。在slot 1 里面存放着 codex[] 的初始大小:0 , 我们通过操作 retract() 函数,让 0 减去 1 从而实现数据下溢,达到构造超大数组的目标。

让我们开始操作吧。先调用 make_contact(), 使 contact = true。看看 slot 0 的值:

图7

可以看到,slot 0 存储的是 bool + address 变量,即 true + OwnerAddress .我们的目标是修改 OwnerAddress 为我们自己的地址。

再调用 retract() , 让codex的长度减1 。看看 slot 1 的值:

图8

哦嚯,我们得到了一个“超级大”的动态数组,足足有 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff 这么长!意味着我们可以在codex[n]里面存取任意位置的数据了。记得我们的目标吗?是要让keccak256(bytes32(1)) + n = 2^256 ,也就是说 n = 2^256 - keccak256(bytes32(1))。我们计算出 n =

0x10000000000000000000000000000000000000000000000000000000000000000 - 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6 =

0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a

换算成十进制就是

35707666377435648211887908874984608119992236509074197713628505308453184860938

也就是说,我们修改 codex**[35707666377435648211887908874984608119992236509074197713628505308453184860938]** 的值为 0x000000000000000000000001AAA…..AAA 就可以了(AAA…..AAA 是我们自己的地址)。我们调用函数 revise(uint,bytes32),参数如下

参数1:35707666377435648211887908874984608119992236509074197713628505308453184860938

参数2:0x000000000000000000000001123456…..

然后再看一下存储槽,第一个 1 是 bool 变量 true,而后面的 1234567890…… 说明owner已经成功修改为我们的地址了(0x1234567890…….):

图9

那么,本期节目就到这里啦。

关于作者:

https://twitter.com/0xNezha