逆向 EVM - 解析原始Calldata数据

逆向 EVM - 解析原始Calldata数据

逆向 EVM - 解析原始Calldata数据

逆转EVM:原始的Calldata

你可能想知道如何破译和读取evm的calldata,并尝试读取以太坊智能合约的交易calldata,EVM(和其他L1分叉)以特定的方式对静态和动态类型的calldata进行编码和解码,在某种程度上让数据变得很困惑。

在这篇文章中,我们将深入研究calldata的编码顺序,以便你能理解验证或未经验证的智能合约代码的交易,并理解这些字节。了解编码顺序后,希望你能创建自己的原始calldata。

什么是Calldata?

Calldata是我们发送给函数的编码参数,在这里是发送给以太坊虚拟机(EVM)上的智能合约。每块calldata有32个字节长(或64个字符)。有两种类型的calldata:静态和动态。

静态变量是相当简单易懂的。另一方面,动态变量则要复杂得多,这可能是你难以直观地阅读原始calldata的原因。然而,一旦我们了解了动态变量是如何工作的,你就能轻松地阅读原始calldata了。

首先,让我们了解一下calldata是如何编码和解码的,以便为这一切的工作建立一个基础。

编码Calldata

要对类型进行编码,你可以将它们传入abi.encode(parameters)方法,以生成原始calldata。

如果你想为一个特定的接口函数编码Calldata,你可以使用 abi.encodeWithSelector(selector, parameters)。这将与直接传入函数和它的参数一样。

比如说:

interface A {
  function transfer(uint256[] memory ids, address to) virtual external;
}

contract B {
  function a(uint256[] memory ids, address to) external pure returns(bytes memory) {
    return abi.encodeWithSelector(A.transfer.selector, ids, to);
  }
}

方法.selector产生了4个字节(称为:函数选择器),在接口上代表该方法。我们用它来告诉EVM,我们正在向该函数发送我们的calldata。这就是UniswapV2如何实现闪电兑换。

还有abi.encodePacked(...),它可以有效地将所有动态变量放在一起,去掉0的填充。它的问题是,它不能防止碰撞,只有在你确定了参数的类型和长度时才可以使用。

解码calldata

那么你有了calldata,你如何解码它呢?

如果calldata是用abi.encode(...)创建的,那么我们可以用abi.decode(...)对参数进行解码,只要传入我们想把calldata解码成的参数。

例如:

(uint256 a, uint256 b) = abi.decode(data, (uint256, uint256))

其中data代表被传入的calldata。

现在我们了解了如何对参数进行编码和解码,我们可以继续讨论不同的变量类型以及它们如何反映在calldata输出中。

静态变量

静态变量是以下类型的简单编码表示,uint , int, address, bool, bytes1 to bytes32 (包括函数选择器), 和tuple (然而它们可以有动态变量)。

例如,假设我们正在与以下合约进行交互:

pragma solidity 0.8.17;
contract Example {
    function transfer(uint256 amount, address to) external;
}

带有输入参数:

amount: 1300655506
address: 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45

我们将生成 calldata: 0x000000000000000000000000000000000000000000000000000000004d866d9200000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45

但是......我们怎么读这个呢?

好吧,让我们把它分成可读的部分,首先去掉前缀0x,然后把每一行分成64个字符(或32字节)的部分

0x
// uint256
000000000000000000000000000000000000000000000000000000004d866d92
// address
00000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45

酷,现在我们知道前32字节是 "uint256 amount "变量,后32字节是 "address to"。

函数

但是如果我们想直接调用transfer函数呢?

我们需要知道参数类型的顺序,并使用一种叫做 "keccak256 "的Hash 算法,将输入的数据变成一个32字节的 hash 值:

在此案例中,要获取函数哈希:

function transfer(uint256 amount, address to) external;

我们会这样做:

keccak256("transfer(uint256,address)");

这将返回以下32字节的哈希值:

0xb7760c8fd605b6ef5a068e1720c115665f9699a5c439e3c0ee9709290ff8a3bb

为了得到函数签名,我们只需要前4字节(或8个字符,不包括0x前缀): b7760c8f

这个4字节的签名,b7760c8f,是告诉EVM我们正在与该函数进行交互,下面的calldata被作为参数传入:

例如,如果我们要调用transfer,参数与之前的静态变量相同,其calldata为:

0x000000000000000000000000000000000000000000000000000000004d866d9200000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45

并在前32个字节的前4个字节的开头加上b7760c8f

0xb7760c8f000000000000000000000000000000000000000000000000000000004d866d9200000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45

0x
b7760c8f
000000000000000000000000000000000000000000000000000000004d866d92
00000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45

你可能想知道,calldata参数究竟是如何被输入到带有签名的函数中的?

答案是,合约的字节码通过匹配目标函数b7760c8f来读取它,然后用00000000替换它,然后传入参数。

动态变量

动态变量是非固定大小的类型,包括bytesstring和动态数组<T>[],以及固定数组<T>[N]

动态类型的结构总是以偏移量开始,偏移量是动态类型开始位置的十六进制表示。例如,十六进制的 "20 "代表 "32字节"。一旦我们到达偏移量,就会有一个更小的数字代表该类型的长度。

简而言之:第一个32字节=偏移量,第二个32字节=长度,其余的是元素。

对于数组,这个长度代表数组中包含的元素数量。对于字节和字符串类型,它代表该类型的长度。例如,字符串 "Hello World!"是12字节的长度,每个字符是1字节。请记住,这些类型从calldata的左边开始,而不是像其他东西一样从右边开始。

例如,这里是对string “Hello World!” 的编码:

0x
0000000000000000000000000000000000000000000000000000000000000020
000000000000000000000000000000000000000000000000000000000000000c
48656c6c6f20576f726c64210000000000000000000000000000000000000000

观察一下前32个字节是如何代表十六进制的偏移量20的,也就是十进制的32。所以我们从000000000000000000000000000000000000000000000020开始跳过32字节,把我们带到下一行,十六进制为0c,十进制为12,代表我们的字符串的字节长度。现在,当我们把48656c6c6f20576f726c6421转换为字符串类型时,会返回我们的原始值。

祝贺你! 现在你知道如何读取动态类型了。

解读静态和动态参数

假设我们正在与下面的合约进行交互:

pragma solidity 0.8.17;
contract Example {
    function transfer(uint256[] memory ids, address to) external;
}

有了下面的 "transfer"的参数:

ids: ["1234", "4567", "8910"]
to: 0xf8e81D47203A594245E36C48e151709F0C19fBe8

我们将生成calldata: 0x8229ffb60000000000000000000000000000000000000000000000000000000000000040000000000000000000000000f8e81d47203a594245e36c48e151709f0c19fbe8000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004d200000000000000000000000000000000000000000000000000000000000011d700000000000000000000000000000000000000000000000000000000000022ce

我们可以把它切成一个更可读的形式:

// 前缀,不管
0x
// 函数选择器 (`transfer(uint[], address)`)
8229ffb6
// `uint256[] ids` 参数数组偏移 (64-bytes below from start of this line)
0000000000000000000000000000000000000000000000000000000000000040
// `address to` param
000000000000000000000000f8e81d47203a594245e36c48e151709f0c19fbe8
// `ids` 数组长度: 3 
0000000000000000000000000000000000000000000000000000000000000003
// 第一个参数 `ids` 元素
00000000000000000000000000000000000000000000000000000000000004d2
// 第二个参数 `ids` 元素
00000000000000000000000000000000000000000000000000000000000011d7
// 第三个参数 `ids` 元素
00000000000000000000000000000000000000000000000000000000000022ce

请注意,数组参数是由一个偏移量来代表数组的开始位置。然后我们转到第二个参数,地址类型,然后完成数组类型。

现在我们知道了如何读取静态参数和动态参数,让我们来剖析一个更复杂的例子!

解码一个Multicall的Calldata

我们将从这个交易中得到一个UniswapV3 multicall的输入calldata,在这里,用户从multicall函数中调用3个不同的函数:

Etherscan很好地给了我们一个简单的解码版本:

MethodID: 0xac9650d8
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000003
0000000000000000000000000000000000000000000000000000000000000060
0000000000000000000000000000000000000000000000000000000000000120
00000000000000000000000000000000000000000000000000000000000002c0
0000000000000000000000000000000000000000000000000000000000000084
13ead56200000000000000000000000061fe7a5257b963f231e1ef6e22cb3b4c
6e28c531000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead908
3c756cc200000000000000000000000000000000000000000000000000000000
00002710000000000000000000000000000000000000000000831162ce86bc88
052f80fd00000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000164
8831645600000000000000000000000061fe7a5257b963f231e1ef6e22cb3b4c
6e28c531000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead908
3c756cc200000000000000000000000000000000000000000000000000000000
00002710ffffffffffffffffffffffffffffffffffffffffffffffffffffffff
fffaf17800000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000002e3bdc2534919
6582d720000000000000000000000000000000000000000000000000c249fdd3
2778000000000000000000000000000000000000000000000002e1e525c2ef9d
cec50c53000000000000000000000000000000000000000000000000c1cd7c9a
dfb0d9dc000000000000000000000000ed6c2cb9bf89a2d290e59025837454bf
1f144c5000000000000000000000000000000000000000000000000000000000
635ce8bf00000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000004
12210e8a00000000000000000000000000000000000000000000000000000000

我们将对其进行一些修改,并在此基础上逐行展开,使其更具有可读性。请记住,每个值都是十六进制格式,"20个十六进制==32字节",以便快速参考。

MethodID: 0xac9650d8
// 数组_1 的偏移 (starting next line)
0000000000000000000000000000000000000000000000000000000000000020
// 数组_1 的长度  (how many elements in array)
0000000000000000000000000000000000000000000000000000000000000003
// 数组_1中 第一个元素 数组_1A 的偏移  (96-bytes / 32 = 3)
0000000000000000000000000000000000000000000000000000000000000060
// 数组_1中 第二个元素 数组_1B 的偏移 (288-bytes / 32 = 9)
0000000000000000000000000000000000000000000000000000000000000120
// 数组_1中 第三个元素 数组_1C 的偏移 (704-bytes / 32 = 22)
00000000000000000000000000000000000000000000000000000000000002c0

// 数组_1A 的长度  (132-bytes (inc. selector))
000000000000000000000000000000000000000000000000000000000000008

// 读接下来的 132 个字节
// 函数选择器; 4 of 132
13ead562
// 1st param; 36 of 132
00000000000000000000000061fe7a5257b963f231e1ef6e22cb3b4c6e28c531
// 2nd param; 68 of 132
000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2
// 3rd param; 100 of 132
0000000000000000000000000000000000000000000000000000000000002710
// 4th param; 132 of 132
// this marks the end of array_1A
000000000000000000000000000000000000000000831162ce86bc88052f80fd

// 32-bytes of `0` indicating next elemet
0000000000000000000000000000000000000000000000000000000000000000
// length 2nd element of array_1, array_1B (356-bytes (inc. selector))
// we have 4-bytes missing due to the embedded fn selector, 13ead562
// the next fn selector, 88316456, will be inserted here
00000000000000000000000000000000000000000000000000000164

// 读接下来的 356 个字节
// 函数选择器; 4 of 356
88316456
// 1st param; 36 of 356
00000000000000000000000061fe7a5257b963f231e1ef6e22cb3b4c6e28c531
// 2nd param; 68 of 356
000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2
// 3rd param; 100 of 356
0000000000000000000000000000000000000000000000000000000000002710
// 4th param; 132 of 356
// notice how all the `0`s are `f`s. this indicates a `int` type!
fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffaf178
// 5th param; 164 of 356
// we have 32-bytes of `0`, but since we're still reading the bytes
// we know this is a paramter, representing 0 of a type
0000000000000000000000000000000000000000000000000000000000000000
// 6th param; 196 of 356
00000000000000000000000000000000000000000002e3bdc25349196582d720
// 7th param; 228 of 356
000000000000000000000000000000000000000000000000c249fdd327780000
// 8th param; 260 of 356
00000000000000000000000000000000000000000002e1e525c2ef9dcec50c53
// 9th param; 292 of 356
000000000000000000000000000000000000000000000000c1cd7c9adfb0d9dc
// 10th param; 324 of 356
000000000000000000000000ed6c2cb9bf89a2d290e59025837454bf1f144c50
// 11th param; 356 of 356
// this marks the end of array_1B
00000000000000000000000000000000000000000000000000000000635ce8bf

// 32-bytes of `0` indicating next elemet
0000000000000000000000000000000000000000000000000000000000000000
// this is the same thing as before, the length!
// we can see there's only 32-bytes left so we can conclude
// that it's going to be a fn with no inputs
00000000000000000000000000000000000000000000000000000004

// a call to the fn selector 12210e8a; 4 of 4
12210e8a00000000000000000000000000000000000000000000000000000000

现在你已经能够读取原始的嵌入式动态类型了!

最后

我希望这些信息能够帮助你理解calldata是如何编码、解码和读取的。为了学习,我花了一些时间来研究和试验这一切,但这是值得的。从这里开始的下一步是学习如何读取字节码,以便在最底层了解EVM(然后一切都变得开源了>:D)。

点赞 3
收藏 9
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
翻译小组
翻译小组
0x9e64...7c84
大家看到好的文章可以在 GitHub 提 Issue: https://github.com/lbc-team/Pioneer/issues 欢迎关注我的 Twitter: https://twitter.com/UpchainDAO