Calldata数据结构解析

在研究multicall+ERC2771造成任意地址欺骗的漏洞时发现自己对calldata的内部数据结构不太明了。因此特地研究了下,在此记录。

什么是calldata

calldata是智能合约调用时按照约定规范做编码和结构化处理后的数据。client在调用合约函数时,通过abi.encodeWithSignature或abi.encodeWithSelector可以把函数选择器和参数列表打包成calldata数据。

calldata中如何存储数据

在calldata中,约定俗成的前4个bytes为函数选择器。函数选择器由函数原型进行keccak-256计算生成,如:

bytes4 selector = bytes4(keccak256("transfer(address,uint256)"))

在函数选择器之后的就是编码过的实参。根据参数数据类型不同,在calldata的存储方式也不相同(以32字节作为一个编码单元)。 大致的存储编排方式如下:

  • int uint address bool等类型的参数值会以16进制原样存储

  • strings array 等类型的参数值 会以偏移量 + 长度的方式来定义存储:先通过偏移量定位到偏移量,再通过32字节定义的偏移量找到数据的具体存储位置

例1

如果调用如下函数

function transfer(uint256 amount, address to) external;

// calldatar如下
0xb7760c8f000000000000000000000000000000000000000000000000000000004d866d9200000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45

// 格式化成之后
0xb7760c8f
000000000000000000000000000000000000000000000000000000004d866d92
00000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45

按照上方所说,前4个字节为函数选择器,可以通过cast 4byte 0xb7760c8f还原出函数原型

post image

该函数的两个参数分别为uint256 address类型,均为静态。所以会原样存储。

uint amount = 0x4d866d92

address to = 0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45

例2

调用如下函数

transfer(uint256[] amount, address to);

// 产生如下calldata

0x8229ffb60000000000000000000000000000000000000000000000000000000000000040000000000000000000000000f8e81d47203a594245e36c48e151709f0c19fbe8000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004d200000000000000000000000000000000000000000000000000000000000011d700000000000000000000000000000000000000000000000000000000000022ce

// 格式化之后
0x8229ffb6
0000000000000000000000000000000000000000000000000000000000000040
000000000000000000000000f8e81d47203a594245e36c48e151709f0c19fbe8
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000004d2
00000000000000000000000000000000000000000000000000000000000011d7
00000000000000000000000000000000000000000000000000000000000022ce

通过cast 4byte 0x8229ffb6

由于第一个参数为uint256[]类型,存储方式为偏移量+长度,因此第一个32字节0x40=64 也就是2个32字节。因此第一个参数的实际存储位置为2个32字节偏移之后。然后是第二个32字节,由于是address类型的参数 因此原样存储。

address to = 0xf8e81d47203a594245e36c48e151709f0c19fbe8

然后我们看到2个32字节之后的下一个32字节 值为0x3 也就uint[]的长度为3。也就是之后的三个32字节 就是原样存储的uint值了。 分别为: 0x4d2 0x11d7 0x22ce

例3

如下calldata 怎么反推出函数原型和参数?

step 1

先看第一个4字节 cast 4byte 0xac9650d8 -> multicall(bytes[]) 由于是array问题,采用偏移+长度的方式存储。因此偏移量为第一个32字节:0x20。偏移到第二个32字节,也就是长度值:0x3 说明bytes[]数组长度为3。而bytes也是一个动态类型,存储方式也是偏移+长度。所以之后的3个32字节:0x60 0x120 0x2c0分别为 bytes[0] bytes[1] 和bytes[2]的起始地址(存储长度的地址)

0x60 : 3个32字节 0x120: 9个32字节 0x2c0:22个32字节

step2

跳到偏移0x60处,看到值为0x84 就是说bytes[0]的长度为0x84个字节,而又由于已知函数为multicall,所以每个bytes实际上是子函数的calldata:

13ead56200000000000000000000000061fe7a5257b963f231e1ef6e22cb3b4c
6e28c531000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead908
3c756cc200000000000000000000000000000000000000000000000000000000
00002710000000000000000000000000000000000000000000831162ce86bc88
052f80fd

// 格式化之后
13ead562
00000000000000000000000061fe7a5257b963f231e1ef6e22cb3b4c6e28c531
000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2
0000000000000000000000000000000000000000000000000000000000002710
000000000000000000000000000000000000000000831162ce86bc88052f80fd

其中前四个字节就是子函数的选择器 通过cast 4byte 13ead562 -> createAndInitializePoolIfNecessary(address,address,uint24,uint160)

函数参数分别是address address uint24 uint160,均属于静态类型。原样存储。因此可以分析出multicall调用的三个子函数中第一个函数如下: createAndInitializePoolIfNecessary(address,address,uint24,uint160) 参数1 address = 61fe7a5257b963f231e1ef6e22cb3b4c6e28c531 参数2 address = c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 参数3 uint24 = 0x2710 参数4 uint160 = 0x831162ce86bc88052f80fd

step3

同理我们走到0x120: 9个32字节这里 看到值为0x164 也就是bytes[1]长度为0x164个字节,整理之后为

其中cast 4byte 88316456 -> mint((address,address,uint24,int24,int24,uint256,uint256,uint256,uint256,address,uint256)) 均为静态类型 依次往下找即可,最终调用参数为:

mint(
    address(0x61fe7a5257b963f231e1ef6e22cb3b4c6e28c531),
    address(0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2),
    0x2710,
    0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffaf178,
    0,
    0x2e3bdc25349196582d720,
    0xc249fdd327780000,
    0x2e1e525c2ef9dcec50c53,
    0xc1cd7c9adfb0d9dc,
    address(0xed6c2cb9bf89a2d290e59025837454bf1f144c50),
    0x635ce8bf
)

以此类推,最后一个bytes[2]也就很好解析了。

cast 4byte 12210e8a → refundETH()

最终的calldata切分结果:

post image