智能合约安全审计入门篇 —— delegatecall (1)

By:小白@慢雾安全团队

背景概述

上篇文章中我们学习了合约中数据的存储方式以及如何读取合约中的各种数据。这次我们将带大家了解 delegatecall 函数。

前置知识

首先,我们先来了解合约中常见的两种外部函数调用:call 和 delegatecall,下面我们通过一个简单的小实验来看看这两者的区别。

首先来看 A 合约:

contract A { address public a;

function test() public returns (address b){ b = address(this); a = b;

}}

部署后得到 A 合约的地址,我们再利用 A 合约的地址部署 B 合约:

contract B { address public a;

address Aaddress = //这里填入 A 合约的地址; function testCall() public{ Aaddress.call(abi.encodeWithSignature(“test()”)); } function testDelegatecall() public{ Aaddress.delegatecall(abi.encodeWithSignature(“test()”)); }}

当我们调用 B.testCall() 或者 B.testDelegatecall() 函数时,这两个函数都会去调用 A.test() ,我们需要做的是观察 B 合约与 A 合约中的 address a 的变化。

首先我们来看部署后 A 合约与 B 合约中的 address a 的值:

solidity

solidity

这里我们可以看到,部署后 A 合约与 B 合约中的 address a 的值均为 0,我们先调用 B.testCall() 函数看看会发生什么变化:

solidity

调用后我们先查看 B:address a 地址的值,发现并未改变

solidity

我们再来看 A:address a 的值,这里我们可以看到 A:address a 现在被赋值了,当前 address a 的值为

0x9F2b8EAA0cb96bc709482eBdcB8f18dFB12D3133, 这个值正是 A 合约的地址。

这里我们可以得出一个结论:当合约使用 call 函数进行外部函数调用时,是在被调用合约的代码环境中执行相应的代码,对调用者没有影响。

solidity

重新部署后调用 B.testDelegatecall() 函数(这里需要清除之前的合约数据所以需要重新部署,两个合约的地址也会改变):

solidity

成功调用后我们来查看 B:address a 的值,这里我们发现 B:address a 被成功赋值了,当前 address a 的值为 0xB25f1f0B4653b4e104f7Fbd64Ff183e23CdBa582,这个值为 B 合约的地址。

我们再来看 A:address a 地址的值,发现并未发生改变,所以当我们使用 B.testDelegatecall() 调用 A.test() 时,test 函数中的代码逻辑是在 B 合约的环境中执行的,相当于将 A.test() 的代码拿到 B 合约中执行,且这个操作并不会影响 A 合约中的数据。

solidity

总结一下,从上面的小实验中我们可以很明显的看到 call 和 delegatecall 的区别:

  • call:调用后内置变量 msg 的值会修改为调用者,执行环境为被调用者的运行环境;
  • delegatecall:调用后内置变量 msg 的值不会修改为调用者,但执行环境为调用者的运行环境;
  • callcode:调用后内置变量 msg 的值会修改为调用者,但执行环境为调用者的运行环境。需要注意的是 callcode 已经在 0.5.0 以后的版本被禁用了,所以我们这里只做简单了解。

我们可以通过一张图来了解一下:

solidity

了解了 delegatecall 函数与 call 函数的区别后我们再来看 delegatecall 函数的一个有趣的特点:

我们依然是通过一个小实验来为大家讲解(这里涉及到 solidity 中变量的存储方式,在上一篇文章《智能合约安全审计入门篇之访问私有数据》中有比较详细的讲解)。

这里我们还是请出刚刚的两个合约并稍作修改,我们在两个合约中都加入 address c:

contract A { address public c; address public a;

function test() public returns (address b){ b = address(this); a = b;

}}

contract B { address public a; address public c;

address Aaddress = //这里填入 A 合约的地址;

function testDelegatecall() public{ Aaddress.delegatecall(abi.encodeWithSignature(“test()”)); }}

这里从代码中可以看到,我将两个合约中的 address a 和 address c 的声明顺序反过来。下面我们部署合约后来调用 B.testDelegatecall() 看看会发生什么有趣的现象(这里省略部署过程)。

solidity

下面我们来看 address a 和 address c 的值会发生什么变化:

solidity

solidity

这里大家肯定也发现问题了,我们通过 A.test() 明明修改的是 address a,为什么调用后的结果是 address a 没有变化反而 address c 被修改了呢?

这就要引出 delegatecall 函数的一个有趣的特点了,当我们的外部调用涉及到 storage 变量的修改时,变量的修改并不是根据变量名来修改的,而是根据变量的存储位置来修改的。A 合约中 address c 存储在 slot0 中,address a 存储在 slot1 中,反之在 B 合约中 address a 存储在 slot0 中,address c 存储在 slot1 中。当我们通过调用 B 合约中的 delegatecall 函数调用 A 合约中的 test 函数时,test 函数修改的是 A 合约中 slot1 这个插槽,所以代码运行的结果是 B 合约中的 address c 被修改了,因为在 B 合约中的 slot1 对应的正是 address c 这个地址存储的位置。

总结:当使用 delegatecall 函数进行外部调用涉及到 storage 变量的修改时是根据插槽位置来修改的而不是变量名。

漏洞示例

看了前置知识相信大家对 delegatecall 有一定的了解了,下面我们来结合合约代码来模拟真实的攻击场景:

// SPDX-License-Identifier: MITpragma solidity ^0.8.13;

contract Lib { address public owner;

function pwn() public { owner = msg.sender; }}

contract HackMe { address public owner; Lib public lib;

constructor(Lib _lib) { owner = msg.sender; lib = Lib(_lib); }

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

漏洞分析

我们可以看到有两个合约,Lib 合约中只有一个 pwn 函数用来修改合约的 owner,在 HackMe 合约中存在 fallback 函数,fallback 函数的内容是使用 delegatecall 去调用 Lib 合约中的函数。我们需要利用 HackMe.fallback() 触发 delegatecall 函数去调用 Lib.pwn() 将 HackMe 合约中的 owner 改成自己。

攻击合约

// SPDX-License-Identifier: MITpragma solidity ^0.8.13;

contract Attack { address public hackMe;

constructor(address _hackMe) { hackMe = _hackMe; }

function attack() public { hackMe.call(abi.encodeWithSignature(“pwn()”)); }}

我们来请出我们的老朋友受害者 Alice 和攻击者 Eve 这两个角色来分析下攻击流程:

  1. Alice 部署 Lib 合约;

  2. Alice 部署 HackMe 合约并在构造函数中传入 Lib 合约的地址;

  3. 攻击者 Eve 部署 Attack 合约并在构造函数中传入 hackMe 地址;

  4. 攻击者 Eve 调用 attack 函数成功将 HackMe 合约中的 owner 改成自己。

我们先来回顾一下 fallback 函数何时会被触发调用?

  1. 向某合约直接转账时(会触发某合约中的 fallack 函数)

  2. 向某合约调用无法匹配到函数名的函数时(会触发某合约中的 fallack 函数)

现在我们来看看到底发生了什么?

attack 函数首先去调用 HackMe.pwn() ,发现 HackMe 合约中并没有 pwn 函数,此时触发 HackMe.fallback() ,HackMe.fallback() 又使用 deldegatecall 调用 Lib 合约中的函数,函数名取得是 msg.data 也就是 “pwn()”,而 Lib 合约中恰好有名为 pwn 的函数,该函数的作用是将合约中的 owner 修改为 msg.sender。在前置知识中我们了解到 delegatecall 函数的执行环境是调用者的环境,并且对于 storage 变量的修改是根据被调用的合约的插槽位置来修改的。

简而言之在 HackMe 执行 delegatecall 调用 Lib.pwn() 后,相当于将 Lib.pwn() 直接拿到 HackMe 合约中执行了。pwn 函数修改了 Lib 合约中存储位置为 slot0 的变量 owner,这样 HackMe 通过 delegatecall 调用 pwn 函数后也会修改 HackMe 合约中存储位置为 slot0 的变量恰好也是 owner 变量,这样 HackMe 合约中的 owner 就成功的被攻击者 Eve 修改成自己了。

这个攻击流程对初学者来说可能有点绕,但是理解了 fallback 函数的触发条件和 delegatecall 函数的特征也就好很多了。如果你觉得自己已经很了解 delegatecall 函数的各种特点了可以期待下一篇文章:《智能合约安全审计入门篇之 delegatecall (2)》。

修复建议

作为开发者

  1. 在使用 delegatecall 时应注意被调用合约的地址不能是可控的;

  2. 在较为复杂的合约环境下需要注意变量的声明顺序以及存储位置。因为使用 delegatecall 进行外部调时会根据被调用合约的数据结构来用修改本合约相应 slot 中存储的数据,在数据结构发生变化时这可能会造成非预期的变量覆盖。

作为审计者

  1. 在审计过程中遇到合约中有使用 delegatecall 时需要注意被调用的合约地址是否可控;

  2. 当被调用合约中的函数存在修改 storage 变量的情况时需要注意变量存储插槽的位置,避免由于数据结构不一致而导致本合约中存储的 storage 变量被错误的覆盖。

注:本文参考于《Solidity by Example》

参考链接:https://solidity-by-example.org/hacks/delegatecall

1赞