原文链接:Ethereum Virtual Machine Internals–Part 1
本文作者:NetSPI Distributed Ledger Team;编译:Cointime Freya
以太坊虚拟机(EVM)作为构成以太坊分布式状态机的数千个节点使用的沙盒兼容层,确保了智能合约在独立于平台的环境中确定性地执行。
EVM在以太坊协议的执行层上运行,在虚拟机内进行的所有操作都记录在区块链上,并可由网络中的任何节点进行验证。这样可以确保EVM处理的数据完全透明和不可变,保证了网络的完整性。在本文中,我们将详细探讨EVM的内部运作方式。
在这个由三部分组成的系列中,我们将首先概述易失性内存管理、函数选择以及在EVM内执行不安全的字节码可能产生的潜在漏洞。在第二部分中,我们将深入研究持久性的EVM存储,探索数据如何被智能合约存储和检索。最后,第三部分将重点讨论Solidity和EVM中的消息调用,以及由于不安全的消息调用和存储管理不当而导致的漏洞。
以太坊网络和智能合约回顾
在接收到传入的交易时,以太坊网络验证者首先验证它们,以确保它们的签名是有效和正确的。然后验证者执行交易中包含的智能合约代码,以验证结果的正确性和一致性。如果输出是有效的,验证者会将交易输出传播给其他验证者以达成共识。
未处理的交易存储在称为mempool的列表中,直到它们被添加到区块中。一旦达成共识,来自mempool的有效交易将被添加到下一个要添加到区块链上的区块中。一旦区块被添加到区块链中,它所包含的交易就被视为最终状态,网络中的其他节点可以依赖区块链的状态执行进一步的交易或执行智能合约。
智能合约执行发生在运行在以太坊网络验证者上的EVM实现中。EVM不是您会通过VMware或类似软件启动的实际虚拟机。它更类似于Java虚拟机,主要功能是将高级智能合约代码翻译成字节码,以实现可移植性执行。常见的EVM实现包括基于Golang的geth客户端和基于Python的py-evm客户端。EVM智能合约通常使用高级领域特定语言编写,例如Solidity和Vyper4。
下面是一个代表短期CTF挑战的Solidity智能合约,我们将在EVM中执行该合约。它至少有两个重大漏洞,因此请勿将此合约用于除教育目的以外的任何用途。该挑战的目标是通过利用这两个漏洞来取得合约的所有权。
DodgyProxy.sol

引入这些漏洞是因为它们是理解EVM内存技巧的好方法。本文的其余部分将围绕这个通过EVM执行的合约展开。其中一个漏洞有很好的记录,第二个漏洞的最初灵感可以追溯到Reddit用户u/wadeAlexC6在2018年6月部署到Ropsten测试网的CTF挑战。
字节代码
EVM的核心是一个基于堆栈的虚拟机,其运行的字节码来自于基于高级语言编写的,与EVM兼容的智能合约。以下是使用Solc Solidity编译器的0.8.17版本并进行了默认优化的示例合约的字节码:
608060405234801561001057600080fd5b50600080546001600160a01b031916331790556101e4806100326000396000f3fe608060405234801561001057600080fd5b50600436106100415760003560e01c806373c768d714610046578063812600df1461005b5780638da5cb5b14610081575b600080fd5b61005961005436600461016e565b6100ac565b005b61006e61006936600461016e565b6100cf565b6040519081526020015b60405180910390f35b600054610094906001600160a01b031681565b6040516001600160a01b039091168152602001610078565b60408051602081019091526100e282018082526100cb9063ffffffff16565b5050565b6000816100db81610187565b5092915050565b6000546001600160a01b0316331461012d5760405162461bcd60e51b815260206004820152600a6024820152696e6f74206f776e65722160b01b604482015260640160405180910390fd5b60405133906000818181855af49150503d8060008114610169576040519150601f19603f3d011682016040523d82523d6000602084013e505050565b505050565b60006020828403121561018057600080fd5b5035919050565b6000600182016101a757634e487b7160e01b600052601160045260246000fd5b506001019056fea26469706673582212202e788aebe90e09dbd8b71fced8e6c375f7d2d2989d
让我们先看看这个字节码在EVM的架构中是如何开始移动的,首先看看一些关键的EVM数据结构。
EVM架构
智能合约的执行发生在以太坊网络验证者的EVM实例内。当智能合约开始执行时,EVM会创建一个执行上下文,其中包含如下所述的各种数据结构和状态变量。执行完成后,执行上下文被丢弃,为下一个合约做好准备。以下是EVM执行上下文的高级概述:

堆栈
EVM的堆栈是一种后进先出(LIFO)数据结构,用于在智能合约执行期间存储临时值。截至撰写本文时,该堆栈最多可以运行1024个32字节元素。这些元素可以包括控制流信息、存储地址以及智能合约指令的结果和参数。
在堆栈上操作的主要指令是PUSH操作码变体,POP操作码,以及DUP和SWAP操作码变体。它们分别允许在堆栈上添加、删除、复制和交换元素。
代码
代码区域完整存储了合约的字节码。该区域是只读的。
存储
存储区域是一个持久化的键值存储。键和值都是32字节slot,用于存储构成合约状态的永久但可变的数据。存储中的数据是持久的,因为它在调用之间被保留。这包括状态变量、结构及其局部变量。存储区域的可能用途包括存储和提供如代币余额的公共数据的访问,以及为库提供对存储变量的访问。但是,合约不能随意访问彼此的存储位置。对存储进行操作的相关操作码是SSTORE和SLOAD操作码,它们分别允许向存储区写入和读取32字节。
内存
内存区域是一个易失性的、动态大小的字节数组,用于在合约函数执行过程中存储中间数据。它类似于经典执行环境中分配的虚拟内存。更具体地说,内存部分存储了执行Solidity函数中逻辑所必需的临时但可变的数据。在低层次上,MLOAD和MSTORE操作码变体分别负责读取和写入内存。与存储类似,内存部分中的数据可以以32字节块的形式存储和读取。但是,MSTORE8操作码可以用于写入32字节字的最小有效字节。
调用数据
调用数据区域与内存区域类似,也是一个易失性数据存储区域,但是它存储的是不可变数据。它旨在保存作为智能合约交易的一部分发送的数据。因此,存储在此处的数据可以包括函数参数以及在现有合约中创建新合约时的构造函数代码。CALLDATALOAD、CALLDATASIZE和CALLDATACOPY操作码可用于读取不同偏移量的调用数据。调用数据以一种特定的方式被格式化,以便可以将特定的函数参数从其中分离出来。我们稍后会更详细地介绍这种格式。
由于在此处存储的数据是不可变的,因此简单数据类型的函数参数(例如无符号整数)会自动复制到函数中的内存中,以便可以修改它们。这不适用于字符串、数组和映射,因为它们需要在函数参数中被明确地标记为内存或存储,这具体取决于它们在函数执行期间是否需要修改。
程序计数器
程序计数器(PC)类似于x86汇编中的RIP寄存器,它指向EVM将要执行的下一条指令。在执行一条指令后,PC通常会增加一个字节。其中的例外情况包括JUMP操作码变体,它们将PC重新定位到堆栈顶部指定的位置。
全局变量
EVM还跟踪全局命名空间中的特殊变量。这些变量被用来提供关于区块链和当前合约背景的信息。有相当多的全局变量,但您可能会从我们的智能合约示例中认识到以下一些全局变量:
- msg.sender -当前调用的发送者的地址。
- msg.value -与当前调用一起发送的Wei值。
- msg.data -当前的调用数据。
- tx.origin -启动事务链的原始外部帐户。
返回数据
返回数据部分存储了智能合约调用的返回值。它分别由RETURNDATASIZE/RETURNDADACOPY和RETURN/REVERT操作码进行读取和写入。
Gas与Solidity Compiler
EVM中的每个操作码都有与其执行相关的机会成本。这是以“gas”为单位进行度量的。重要的是要注意,gas不同于以太币,因为它不能像原生加密货币一样直接买卖。但是,它以Gwei(1 Gwei = 10-9 Ether)为单位支付。它只是衡量EVM执行特定指令所需的工作量的单位。
Gas的存在也是为了激励高效的智能合约代码。为了节省gas,Solidity开发人员有时会在合约中间编写EVM汇编代码,将其放入一种叫做Yul13的中间语言。Yul类似于字面意义上的EVM汇编,但它允许额外的控制流(循环、条件语句等),因此开发人员不必在堆栈上下移动PUSH和POP。这是很常见的,因为Solidity还不是一种非常成熟的语言,因此Solidity编译器的实现并没有达到应有的效率。
EVM执行环境考虑到了为当前事务设置的gas限制以及执行每个操作码的gas成本。Gas和gas管理是EVM开发中复杂的主题,因为智能合约的终端用户在执行期间首当其冲地承担了承担了gas成本。较低的gas成本往往会激励用户选择一个合约而不是另一个。
无论如何,尽管为了节省gas效率而经常需要使用assembly/Yul,但以错误的方式这样做可能会产生一些有趣的安全隐患。具体来说,用Yul/assembly中编写合约逻辑可以绕过一些仅在更高级别的Solidity中实现的重要访问控制机制,我们将在本文末尾演示。
应用二进制接口
为将高级别的Solidity映射到字节码,Solidity编译器从合约生成了一个称为应用二进制接口(ABI)的中间数据结构。ABI的作用类似于API,用于公开与后端应用程序服务交互所需的方法和结构。以下是我们示例合约的ABI:

ABI由以下元素组成:
- name:定义函数名。
- type:定义函数的类型。这是区分常规函数、构造函数和特殊函数类型(如receive和fallback)所必需的。
- inputs:一个对象数组,它们本身定义了参数名称和类型。请注意,这里定义了types和internalTypes。这是因为在Solidity和ABI中,某些数据类型的引用方式存在微妙的差异。例如,在solididity中,struct类型的输入将有一个内部类型为tuple。
- outputs:与inputs类似,Output数组包括用于表示函数返回值名称和类型的对象。
- stateMutability:表示任何函数可变性属性,例如pure和view。要确定函数是打算修改链上数据,还是仅仅是返回现有值的getter函数,就需要这些参数。可支付和不可支付的修饰符也在这里表示。
从ABI导出的数据对于以低级方式编码函数调用是必要的,它可以被在合约执行的上下文中引用数据调用区域的EVM解析。
EVM函数选择和Calldata
假设我们已将字节码编译并部署到了测试网络,并且现在我们想调用合约ABI公开的函数。为此,EVM将函数参数格式化为calldata。Calldata是表示函数调用的标准方式,并且通过msg.data全局变量在事务中引用。
Calldata由以下内容组成:
- 函数选择器
- 可选的函数参数
合约通过函数选择器公开和识别公共函数。函数选择器是(大多数)唯一的4字节标识符,它允许EVM定位和调用以字节码表示的函数逻辑。
“公共函数”,我们指的是在Solidity中用公共函数可见性关键字标注的函数。回顾一下,以下是函数和状态变量的可见性关键字:
- public:函数对合约自身、派生合约、外部合约和外部地址可见。
- private:函数仅对合约本身可见。
- internal:函数对合约本身和派生合约可见。
- external:函数对外部合约和外部地址可见。不适用于状态变量。
函数参数与函数选择器一起被类似地编码,以便完整的调用数据可以被编码。大多数数据类型被编码成离散的32字节块。请注意,32字节是最小值;像uint和bool这样的简单类型的参数每个将为32字节,而string、byte和数组类型则根据其长度和它们是固定大小还是动态大小进行编码。
考虑以下函数定义,像foo(16,7)这样的调用:
function foo(uint64 a, uint32 b) public view returns (bool) {}
为了得出calldata,EVM执行以下操作:
1.获取函数的ASCII表示的Keccak256哈希的前4个字节,忽略参数变量名。这个函数的表示被称为函数签名。
- 例如,keccak256(“foo(uint64,uint32)”)→0xb9821716
2.对于调用数据,函数参数在被填充32字节后被添加到函数签名的哈希值中。
- 例如 :uint64 16 → 0x00…..10
- uint32 7 → 0x00…..07
3.总体而言,foo函数选择器和calldata将是:
0xb9821716000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000007
我们可以通过以下的简短合约确认这一点,该合约使用abi.encodeWithSignature方法根据foo()函数签名及其参数生成函数选择器。然后,它将结果(enc)在名为Encoded的事件中发出。事件是一种在事务日志中包含其他详细信息的方法,对于将特定事件细化地提交到链上非常有用:

我们将编译和部署这个合约,然后在Brownie开发环境中调用GetCallData方法:

还应该注意的是,公共状态变量也被赋予了它们自己的选择器,并被编译器视为getter。例如,一个名为owner的公共状态变量将有一个选择器被公开为keccak256(“owner()”)=0x8da5cb5b。它可以从其他以DodgyProxy.owner()方式导入DodgyProxy的合约中访问。
如果比较AbiEncodeTest和DodgyProxy合约的字节码,将出现一个共同的字节码部分,如下所示(绿色表示)。GetCallData()和hitMe()函数的4字节函数选择器紧随这段共同的字节码:
AbiEncodeTest:... 60003560e01c806301cc20f114602d57...
DodgyProxy:... 60003560e01c806373c768d71461004657...
这段字节码表示EVM用于识别公共函数的函数选择逻辑。以下操作码负责DodgyProxy中的函数选择:
026→ 60→ PUSH1 0x00
028→ 35→ CALLDATALOAD
029→ 60→ PUSH1 0xe0
031→ 1C→ SHR
032→ 80→ DUP1
033→ 63→ PUSH4 0x73c768d7 // hitMe(uint256) public function selector
038→ 14→ EQ
039→ 61→ PUSH2 0x0046
042→ 57→ *JUMPI
043→ 80→ DUP1
044→ 63→ PUSH4 0x812600df // inc(uint256) public function selector
049→ 14→ EQ
050→ 61→ PUSH2 0x005b
053→ 57→ *JUMPI
054→ 80→ DUP1
055→ 63→ PUSH4 0x8da5cb5b // owner getter function selector
060→ 14→ EQ
061→ 61→ PUSH2 0x0081
064→ 57→ *JUMPI
让我们跟踪此字节码片段的执行,给定以下函数调用的calldata。下面是我们将对inc()进行的调用,参数为uint256 (1):inc(1);
通过AbiEncodeTest合约中的GetCallData函数运行此函数调用将产生以下calldata。这也可以按照我们之前的方法手动得出:
812600df0000000000000000000000000000000000000000000000000000000000000001
如果您想继续学习,smlXL18有一个出色的EVM平台,可以对字节码执行进行建模,以供学习和调试。
首先,PUSH1操作码将0x00的值推送到堆栈上。这个值将作为下一个操作码CALLDATALOAD的偏移量:

CALLDATALOAD将msg.data全局变量的前32个字节加载到堆栈。这里我们可以看到inc(uint256)的函数选择器包含在这前32字节中:
然而,EVM现在需要从calldata块的其余部分解析出4字节的函数选择器。EVM目前通过将calldata块向右位移一定量,从而只留下4字节的函数选择器来执行此操作。由于calldata块是256位(32字节),因此需要向右移224位(用十六进制表示为0xE0)。

这是通过首先用PUSH1将0xE0推送到堆栈上,然后通过SHR向右移位来完成的。移位后,偏移量将被弹出,留下干净的函数选择器作为堆栈的唯一元素:

选择一个函数
为了理解接下来的几个字节码的重要性,了解EVM将函数选择器作为一种开关语句进行比较是有帮助的,其中函数选择器在与其下面的函数选择器进行比较之前,会在堆栈上相互堆叠。在这样做的过程中,EVM试图通过顺序解析它们来查找匹配项,从而确定传入的calldata是否以有效函数为目标。
这就是为什么DUP1操作码在下一个函数选择器被PUSH4操作码推送到堆栈上之前,在堆栈上复制函数选择器的原因。请注意,此函数选择器不是从任何calldata派生的;它是EVM本身放置在堆栈上的第一个函数选择器。在此最终将推送hitMe(uint256) (0x73c768d7)函数的函数选择器:

然后使用EQ操作码比较堆栈上的最后两个项。如果找到匹配项,最后两项就会从堆栈中弹出,并以1值代替它们,作为正比较结果。如果没有,则用0值代替它们。
在这里,由于0x812600df和0x73c768d7不匹配,0值将被推到堆栈中。请注意,从我们的calldata推导出的原始函数选择器仍然保留在比较结果下面,准备最终与下一个函数选择器进行比较:

既然已经找到了结果,就需要决定是否执行函数选择器所代表的函数。在这种情况下,函数的执行还不会发生,因为calldata的函数选择器与EVM遇到的第一个函数不匹配。

然而,EVM目前仍然需要进行另一次比较,以决定是否跳转到函数选择器对应的函数逻辑,然后才能开始比较下一个函数选择器。这种比较是通过首先使用PUSH2操作码将一个偏移量推到堆栈中函数逻辑的起始位置来完成的。

然后使用JUMPI操作码。请注意,JUMPI代表条件跳转,而JUMP指令只需要跳转到一个偏移量,并且仅在需要无条件跳转时使用。JUMPI指令首先将偏移量和EQ比较的结果从堆栈中弹出。如果比较结果为1,则程序计数器将更改为表示要执行的函数逻辑的开始的偏移量。在这里,比较结果为0,因此执行将继续,而不改变程序计数器。
然后,从DUP1操作码开始重复了前一个字节码的大致过程:复制从calldata中推导出的函数选择器,将要比较的下一个函数选择器推送到堆栈上,比较这两个函数选择器,放置一个偏移量到下一段函数逻辑,然后根据比较结果决定是否跳转到该函数逻辑。
如下所示。这次将比较inc(uint256)函数选择器,因此执行将继续在字节码的偏移量0x5B(91)处进行。
Jumpdests
如果我们在函数选择逻辑之后跟踪执行,我们看到的第一个操作码将是JUMPDEST操作码,位于字节码的第91位。
091→ 5B→ JUMPDEST
092→ 61→ PUSH2 0x006e
095→ 61→ PUSH2 0x0069
098→ 36→ CALLDATASIZE
099→ 60→ PUSH1 0x04
101→ 61→ PUSH2 0x0178
104→ 56→ JUMP
105→ 5B→ JUMPDEST
我们可以将JUMP和JUMPDEST操作码视为从字节码中的一个区域到另一个区域的虫洞的两端。每个JUMP操作码必须有一个相应的“着陆”JUMPDEST,才能使跳转有效。JUMPDEST消除了在跳转后动态评估函数逻辑起始点的需要。
还应该注意的是,JUMPDEST不仅表示函数逻辑的开始。实际上,在低级别的字节码中放置函数逻辑与高级别的Solidity几乎没有关系。
函数签名冲突
之前,我们将函数选择器称为“大部分”唯一,因为函数名称的keccak256哈希值中,有两个或更多不同的函数具有相同的前四个字节的情况并不少见。以太坊签名数据库就是一个很好的例子。owner状态变量的getter函数的选择器实际上与ideal_warn_timed(uint256,uint128)函数选择器相同,为0x8da5cb5b19。
Solidity编译器足够成熟,只要相关的函数在单个合约中,就可以注意到函数签名的冲突。然而,在不同的合约之间,例如在实现合约和被称为代理合约的知名模式之间,可能存在函数签名冲突。与代理合约使用相关的潜在安全风险将在随后的文章中介绍。
私有/内部函数
只有公共和外部函数会为它们创建函数签名。私有和内部函数不会接收函数选择器。例如,在字节码中不会存在 deleg()的函数选择器。事实上,尝试通过Web3.py或类似方式执行私有或内部函数不会导致任何类型的访问错误。相反,由于函数选择器不存在,会明确地引发一个异常:

运行上述脚本将产生以下结果:
❯ python3 web3_check.py
<Function hitMe(uint256)>
Could not find any function with matching name: deleg
然而,私有/内部函数缺乏函数选择器并不意味着私有函数逻辑在内部不可访问,因为私有函数逻辑仍然需要由合约可执行。
目前,以下合约是有效的Solidity,尽管有些冗余。公共函数可以调用同一合约中的私有函数,并处理任何结果,而不会出现问题:

回到我们的DodgyProxy,从hitMe()开始,类似的模式现在可能已经很清楚了:

当以合约所有者以外的任何账户执行hitMe()函数时,由于自定义的onlyOwner修饰符,如果所有者状态变量没有设置为调用者的地址(msg.sender),则不可能从hitMe()直接调用私有 deleg()函数,因为该修饰符会防止私有函数逻辑被执行。
然而,我们在汇编块中引入了某种不太明显的内存损坏漏洞。如果给到hitMe()函数正确的输入,尽管deleg()函数由于onlyOwner修改器而无法访问,并且由于私有函数可见性而受到了外部调用的限制,仍然有可能在deleg()函数的中间位置结束并恢复执行。
这是因为函数的可见性,事实上,修饰符一般只在高级Solidity中作为明确的访问控制。这意味着,如果执行被以某种方式重定向到私有函数的中间位置,则不存在DEP/NX之类的机制来防止未经授权的上下文执行。
利用这种漏洞的必要条件在大多数情况下是不太可能出现的。然而,这种情况并非不可能发生,特别是考虑到Yul经常用于直接将低级EVM操作码编写到合约中,以节省gas费用。
这个缺陷背后的一般想法是,Pointer结构在内存中本质上起到一种“基础”功能,可以从中添加偏移量。通过指定特定偏移量,该结构的fwd()函数可以用作各种jumpdest的跳板,其中一些将属于在高级Solidity中通过传统控制流程被防止执行的函数逻辑。要了解更多关于字节码执行期间如何引用内存的信息,请重新查看EVM调用上下文的内存部分。
关于内存的更多信息
作为一个动态大小的字节数组,EVM调用上下文的内存区域可以以离散的32字节块进行读写。此外还有“已触及”和“未触及”内存的概念,新写入的内存块会产生越来越高的gas和存储成本。在可以利用新内存之前,EVM需要保留一部分实用内存,用于跟踪后续的内存写入。
该实用内存由四个主要部分组成,从内存数组的开头开始:
- 字节0x00-0x3f:主要用于存储哈希操作的中间输出的临时空间,例如keccak256()。请注意,此部分分配为两个32字节的slot。
- 字节0x40-0x5f:为32字节的“free memory pointer”保留的空间。这用作指向未分配内存的指针,供合约执行期间使用。
- 0x60-0x7f字节:32字节的“零slot”,用作不应被写入的动态内存数组的易失性存储空间。
最初,空闲内存指针指向内存中的位置0x80,之后可以分配额外的内存,而不会覆盖已经分配给其他数据的内存。因此,空闲内存指针也作为当前分配的内存大小发挥作用。

利用智能合约中的第一个漏洞的条件是操纵自由内存指针的值来重定向控制流,并最终在受保护的 deleg()函数的中间位置结束。在Remix IDE中跟随DodgyProxy合约的执行,对理解接下来的章节很有帮助。
首先,使用优化值200和编译器版本0.8.17编译合约。更改优化设置和编译器版本可能会稍微增加或减少偏移量,但无论优化如何,一般的字节码逻辑应该保持一致。选择优化是因为独立的Solc编译器默认启用优化,而Remix默认不启用优化。
使用任意帐户部署合约。然后,尝试使用部署合约的相同账户(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4)调用带有参数0的hitMe()函数。这将带来成功的函数调用:

然后试图以合约所有者以外的任何账户进行同样的操作。在这里使用了账户0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2,代表一个恶意行为者的地址。该交易将被撤销:

从攻击者的账户中,调用参数为1uint的hitMe()函数,并对失败的交易进行调试:

执行开始时,将值0x80和0x40推送到堆栈中:

然后通过MSTORE操作初始化自由内存指针,将值0x80存储在内存位置0x40处。

然后,在第22行Pointer结构体的初始化处设置一个断点,并继续执行:

在这个断点之前的执行将涉及许多操作,包括前面描述的函数选择逻辑。一旦达到断点,我们可以看到堆栈包含一些熟悉的值,即hitMe(uint256)函数的函数选择器和参数1:

在第24行汇编块的开头设置第二个断点,并继续执行。最终将值0xE2推送到堆栈中:

然后,堆栈上的第三个值0x01被DUP3操作码复制到堆栈顶部。

然后ADD操作码将0x01添加到0xE2,并将0xE3存储在堆栈上。注意,由于编译器的优化和Yul的翻译,从编译器生成的字节码将与准确的汇编略有不同:

然后堆栈上的最后两个参数被DUP1和DUP3操作码复制,为MSTORE操作码将值0xE3存储在内存位置0x80处做准备:

这遵循了第24行汇编块的逻辑。因此,刚刚存储在自由内存指针处的值很可能是第26行首次实例化的Pointer结构在内存中的位置。
通过发送hitMe(2)的calldata来重复这些调试步骤将证实这一点,因为自由内存指针现在将存储值0xE4,由于增加了hitMe()的偏移量参数,这比我们之前的值多了一个:

使用任意参数继续执行hitMe()函数将导致事务失败,因为最终会在字节码中跳转到一个无效的位置。但是,通过对自由内存指针的值进行细化控制,可以计算出一个偏移量来绕过保护deleg()函数的访问控制修饰符。
为此,我们可以检查合约的字节码,找到与我们想要进入的高级Solidity部分相对应的操作码。在这种情况下很简单,因为正确的字节码部分将只有一个DELEGATECALL操作码,这是因为它只在此合约中被调用一次:
301→ 5B→ JUMPDEST
302→ 60→ PUSH1 0x40
304→ 51→ MLOAD
305→ 33→ CALLER
306→ 90→ SWAP1
307→ 60→ PUSH1 0x00
309→ 81→ DUP2
310→ 81→ DUP2
311→ 81→ DUP2
312→ 85→ DUP6
313→ 5A→ GAS
314→ F4→ DELEGATECALL
然而,回顾前面的内容,要想通过跳转的方式改变控制流,在目标位置必须有相应的JUMPDEST操作码,就像正常的函数选择一样。这就是为什么将挑战设置为具有内部函数的结构体的原因。
如果没有设置函数调用的手段,那么跳转到字节码的另一部分以打破正常的控制流就会更加困难,甚至实际上是不可能的。由于这个原因,这种技术也只有在我们得到的JUMPDEST和目标操作码之间没有干扰执行的操作码的情况下才有可能发挥作用。
在这种情况下,似乎没有任何操作码会阻止我们最终执行DELEGATECALL操作。从这里开始,我们可以通过从DELEGATECALL操作码之前最近的JUMPDEST的偏移量减去Pointer结构体在内存中的位置来轻松计算出要采取的偏移量。
在我们的字节码中,目标JUMPDEST位于偏移量0x012D(301)处。Pointer结构体的位置位于0xE2(226)。这就得到了0x4B(75)的差值。在后续的字节码中,在跳转到位于0x12D的JUMPDEST之前,将此这个差值添加到Pointer结构的基础:


我们可以通过在delegatecall所在行设置一个断点并调用参数为75的hitMe()函数来确认delegatecall是否已经到达。执行将继续进行到delegatecall:

此时,我们已成功绕过了onlyOwner修饰符。然而,为了完成挑战,我们需要通过滥用delegatecall来覆盖owner状态变量,从而接管合约。如果你对Solidity有一定的经验,这应该是比较简单的。
这就是本文的全部内容。在本文的第2部分中我们将更详细地介绍EVM中的存储布局。第三部分将详细介绍低级合约调用、代理合约,以及更多关于如何滥用DELEGATECALL来破坏控制流并接管合约的示例。
*本文由CoinTime整理编译,转载请注明来源。
所有评论