[Ethernaut] 1. Fallback

Ethernaut · 2/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. 문제

아래의 컨트랙트 코드를 잘 살펴보고 다음의 문제를 해결하자.

    1. 컨트랙트의 소유권을 탈취해라.
    2. 컨트랙트의 이더 잔액(balance)을 0으로 만들어라.

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

contract Fallback {

  mapping(address => uint) public contributions;
  address public owner;

  constructor() {
    owner = msg.sender;
    contributions[msg.sender] = 1000 * (1 ether);
  }

  modifier onlyOwner {
        require(
            msg.sender == owner,
            "caller is not the owner"
        );
        _;
    }

  function contribute() public payable {
    require(msg.value < 0.001 ether);
    contributions[msg.sender] += msg.value;
    if(contributions[msg.sender] > contributions[owner]) {
      owner = msg.sender;
    }
  }

  function getContribution() public view returns (uint) {
    return contributions[msg.sender];
  }

  function withdraw() public onlyOwner {
    payable(owner).transfer(address(this).balance);
  }

  receive() external payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
  }
}

2. Fallback 함수

 fallback 함수는 컨트랙트에 정의되어 있지 않은 함수가 호출될 경우 자동으로 호출되는 함수입니다. 예를 들어, 계정 간의 이더를 전송하는 transfer 함수를 컨트랙트를 대상으로 실행했을 때 이는 컨트랙트 내부에 정의된 함수가 아닌 외부 함수이기 때문에 fallback 함수가 실행됩니다.

 fallback 함수에는 fallback, receive 두 가지가 존재하는데, 메시지에 데이터(calldata)가 담겨 있으면 fallback이 호출되고, 비어있으면 receive가 호출되는 정도의 차이를 가지고 있습니다.

Fallback 함수 테스트

Remix IDE에서 실행해 보세요.

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.19;

import "hardhat/console.sol";

contract Fallback {
    fallback() external payable {
        console.log("fallback!");
        console.logBytes(msg.data);
    }

    receive() external payable {
        console.log("receive!");
    }
}

1. calldata가 비어있는 경우

receive 호출
receive 호출

2. calldata에 데이터가 들어있는 경우

fallback 호출
fallback 호출

3. receive 또는 fallback 하나만 존재할 경우

3-1. fallback만 있는데 calldata가 비어있는 경우

fallback 호출
fallback 호출

3-2. receive만 있는데 calldata가 들어있는 경우

calldata를 읽어들일 수 없다
calldata를 읽어들일 수 없다
트랜잭션 실행 자체가 안된다!
트랜잭션 실행 자체가 안된다!

4. 결론

calldata가 들어가야 한다 > fallback 사용
calldata 없어도 된다 > 둘 다 사용 가능


3. 컨트랙트의 소유권 탈취

 컨트랙트 코드를 살펴보면 소유권을 탈취할 수 있는 방법은 두 가지가 있는 것을 확인할 수 있습니다.

1. contribute 함수를 활용

mapping(address => uint) public contributions;
address public owner;
function contribute() public payable {
  require(msg.value < 0.001 ether);
  contributions[msg.sender] += msg.value;
  if(contributions[msg.sender] > contributions[owner]) {
    owner = msg.sender;
  }
}

 함수의 if 절을 확인하면, 함수를 호출한 sender의 기여도가 owner보다 클 경우에 소유권을 sender에게 넘겨주게 되어 있습니다.

constructor() {
  owner = msg.sender;
  contributions[msg.sender] = 1000 * (1 ether);
}

 그런데 보시다시피 컨트랙트를 생성할 때 owner의 기여도를 무려 1000 이더로 설정해 놓았기 때문에 이 방법은 사실상 불가능한 방법입니다. 또한 한 번에 전송할 수 있는 이더의 양이 0.001 미만이어야 하므로 무한으로 즐겨야 하는 상황에 놓이게 됩니다. 소유권을 탈취하기 어렵게 만들기 위해 이런 식으로 설계를 한 것 같습니다.

2. Fallback 함수를 활용

receive() external payable {
  require(msg.value > 0 && contributions[msg.sender] > 0);
  owner = msg.sender;
}

 앞서 fallback 함수를 다룬 이유가 여기 있습니다. 이 컨트랙트는 외부 함수로 이더를 송금받으면 fallback 함수를 통해 sender에게 컨트랙트의 소유권을 넘겨주도록 설계되어 있습니다. 이렇게 쉽게 소유권을 넘겨줄거면 contribue 함수에서 짤짤이로 무한반복하게 하는 설계도 아무런 의미가 없어지는 것이죠.

 한 가지 주의할 점은 receive 함수가 동작하기 위해서는 require 문에 명시되어 있다시피 먼저 극소량이라도 기여도를 올려놓아야 합니다.


4. 컨트랙트의 이더 잔액을 0으로 만들기

 문제 해결의 두 번째 조건으로 컨트랙트의 이더 잔액을 0으로 만들어야 한다고 명시되어 있습니다. 이 부분은 컨트랙트의 소유권을 탈취하고 withdraw 함수를 호출하기만 하면 달성되므로 어려운 점은 없습니다.

function withdraw() public onlyOwner {
  payable(owner).transfer(address(this).balance);
}

5. 문제 해결하기

 먼저 기여도를 쌓아야 합니다. 콘솔창에서 다음과 같이 코드를 입력하고 실행합니다.

await contract.contribute({value: toWei("0.00001")})
트랜잭션 컨펌
트랜잭션 컨펌

 트랜잭션을 컨펌하고 잠시 기다리면 트랜잭션 해시값이 콘솔창에 출력되고, 그러고 나면 기여도를 확인할 수 있습니다.

 다음으로 컨트랙트로 이더를 전송해 보겠습니다. help 명령어를 실행하면 사용할 수 있는 명령어 목록이 출력됩니다. 이더를 전송하기 위해서는 sendTransaction 명령어를 사용합니다.

await sendTransaction({from: player, to: instance, value: toWei("0.00000000000001")})

 트랜잭션이 컨펌되고 나면 다음과 같이 소유권을 확인합니다.

소유권 탈취 확인
소유권 탈취 확인

 마지막!으로 withdraw 함수를 호출하여 컨트랙트의 이더 잔액을 모두 빼옵니다.

await contract.withdraw()

아니…

 이거 왜 취소도 안되고 가속화도 안되는데… 나 문제 풀었으니까 좀 보내줘…

*아무튼 해결함. 결과 업데이트 예정*

일시 미정

1월 5일 오전 8시 44분 추가

 자고 일어나니까 트랜잭션이 컨펌되어 있었습니다. 제출까지 완료.


6. 결론

fallback 함수, 제대로 알고 사용하자. 당신의 소유권, 휴지 조각으로 대체되었다.