关于合约使用`assembly`操作`calldata`以及`low-level call`接受数据的弊端
关于合约使用assembly
操作calldata
以及low-level call
接受数据的弊端:
[TOC]
大部分合约的编写完全可以通过solidity完成,但是内联汇编是Solidity的一个重要补充,它让你更深入地理解底层操作和合约优化。合约在使用 assembly
操作 calldata
或使用 低级调用(call
, delegatecall
等)接收数据 时,确实能实现一些底层的灵活性,但也引入了一些严重的安全风险、可维护性问题和兼容性弊端。
assembly
操作 calldata
的弊端
- 在
assembly
中直接使用calldataload
加载数据时,是不做类型检查的。如果数据位置或大小不符合预期,会读取到错误的值或导致合约异常。
1 | assembly { |
如果 msg.data
长度不足,则 val
会是 0,或是无意义数据。
- 容易受 ABI 编码方式影响
calldata
的格式遵循 ABI 编码规范。例如 uint8
也会被编码成 32 字节(左填零)。开发者在 assembly
中操作时一旦理解有误,就容易产生错误。
1 | function registerTreasury(uint8) public { |
本意是接收 uint8
参数并写入 treasury
。但由于 calldataload(4)
直接读取 32 字节,而不是仅取 uint8
,攻击者可通过构造 calldata
(例如 uint256
超过 255)来绕过检查逻辑并修改重要状态。虽然函数参数是 uint8
,但通过 calldata
注入可以写入超过 uint8
范围的数据(例如 0xff
→ 255
、甚至更高)。
低级 call
的弊端
- 低级
call
返回的是(bool success, bytes memory returnData)
,不会像普通函数调用一样在失败时自动revert
。开发者若未手动检查success
,将继续执行,这可能导致状态被错误修改。
1 | (bool success, ) = address(target).call(data); |
- 参数位置和格式必须精确匹配 ABI
使用 call
时传入的 data
必须完全符合目标函数的 ABI,否则调用可能失败或执行逻辑被绕过。例如,如果你将 uint8
的函数用 uint256
编码,就可能被攻击利用。
call
的基本语法:
1 | (bool success, bytes memory data) = address(target).call{value: amount, gas: gasLimit}(payload); |
参数 | 说明 |
---|---|
address(target) |
要调用的合约地址 |
.call{value: ..., gas: ...} |
(可选)发送 ETH、限制 gas |
(payload) |
传入的 ABI 编码数据(通常用 abi.encodeWithSelector 构造) |
success |
调用是否成功 |
data |
返回的原始数据(类型为 bytes memory ) |
abi.encodeWithSignature(...)
返回的是一个 bytes
类型的动态数组,它本质上就是一个 byte
数组,即 bytes memory
,你当然可以通过索引(data[i]
)访问和修改它的每一个字节。这会生成一个完整的 calldata:
- 前 4 字节:函数选择器(
registerTreasury()
的 keccak256 hash 前四位) - 后 32 字节:参数部分,但 ABI 会扩展成一个完整的
uint256
占满 32 字节(高位为 0)
所以总共 4 + 32 = 36 字节的数组。你能通过索引 data[0] ... data[35]
访问和修改任何一位。
在 Solidity 的 ABI 编码规则中,可变长度类型(如 bytes
, string
, arrays
)在函数调用中会被按”offset + length + data” 结构编码,函数前四个字节是 selector,后面每个参数占 32 字节,偏移量就是指向该参数数据起始位置的 相对位置(从整个 calldata 开头算起)。
✅ 以 function flipSwitch(bytes memory _data)
为例:
这函数有一个 bytes
类型参数,flipSwitch(bytes)
的 ABI 编码如下结构:
1 | python复制编辑| 0x00 - 0x03 | 4 bytes: 函数选择器 selector(flipSwitch(bytes)) | |
👇 为什么是 0x60
?
这是因为在 ABI 编码中:
- 前 4 bytes 是函数 selector:
30c13ade
- 后面每个参数都占用 32 bytes
- 可变长度参数不在参数位置放值,而是放个偏移(指向 data 起始位置)
所以:
1 | python复制编辑offset = 4 bytes selector |
但实际上更常见的是:
- 如果你只有一个参数(bytes 类型),偏移就是 **
0x20
**; - 你看到的是
0x60
,说明还有别的数据(如构造时的数据前缀或 padding),或者是你手动构造时加了偏移。
✅ 举个具体例子
1 | solidity复制编辑contract Target { |
你调用:
1 | solidity |
会生成这样的 calldata:
偏移(bytes) | 内容 |
---|---|
0x00 - 0x03 | flipSwitch(bytes) 的 selector,例如 0x30c13ade |
0x04 - 0x23 | 0x20 (bytes 参数的偏移量,从0x20开始) |
0x24 - 0x43 | 0x04 (bytes 长度) |
0x44 - 0x47 | 0x76227e12 (turnSwitchOn() 的 selector) |
🧠 如果是 0x60
偏移
说明在你构造的 calldata 中,前面可能还插入了额外字段(比如模拟多个参数的布局),或者用了底层工具手动构造了复杂的数据布局。
✅ 总结
- Solidity 函数参数按 ABI 编码规范进行组织;
- 对于
bytes
类型这样的动态参数,不直接放内容,而是放“偏移地址”; - 偏移地址是从 calldata 开头起算的;
- 一般只有一个动态参数时,偏移是
0x20
; - 出现
0x60
说明你传入的 calldata 中存在更多结构,比如多参数或 padding。
比较memory和calldata
1 | contract Compare { |
源码:
1 | // SPDX-License-Identifier: MIT |
POC:
1 | // SPDX-License-Identifier: MIT |