[Ethernaut] 01. Fallout

5q1·2025년 2월 25일

? fallback func ?
호출한 함수가 컨트렉트 내에서 조회되지 않을 경우
외부에서 특정 컨트렉트를 호출했을 때, 해당 호출 주소(function identifier)가 확인되지 않으면 디폴트로 fallback 함수가 실행된다.
이더(ETH, ether)를 보낼 때 자동으로 실행

fallback 함수 선언 방식

fallback () external [payable] {
   ///....
}

fallback (bytes calldata _input) external [payable] returns (bytes memory _output)
    ///...

fallback 특징

  • function 키워드가 없음
  • external 식별자가 붙음
  • 함수 이름이 존재하지 않음 ( 함수명은 fallback으로 고정)
  • 파라미터를 받지 않음. ( 2번에서 byte calldata 형태의 파라미터를 설정하면 컨트랙트에 보내진 모든 데이터(=msg.data)를 반환하게 할 수 있음.
  • 반환 값이 없음
  • payable 옵션 적용 시, 이더를 받고 나서도 실행됨.
  • payable fallback 함수에서는 2300 가스 제한이 존재
  • virtual 키워드를 가질 수 있기 때문에 override 할 수 있음.
  • modifier들을 가질 수 있음.

calldata가 없는 상황에서는 receive.
calldata가 있으면, fallback이 호출됨.
만약, receive만 존재하는데, calldata가 존재하면, ERROR가 발생.

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

contract Fallback {
    mapping(address => uint256) 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 (uint256) {
        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;
    }
}

object 1) 주인이 되어라
object 2) 잔고를 0원으로 만들어라


[object 1] 주인이 되어라
코드를 살펴 보면,

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

배포자가 owner로 지정되게 되며, contribution[msg.sender]는 1000 ether로 지정된다.
배포자가 owner이므로, 내가 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;
        }
    }

해당 함수를 실행하면, 내가 owner로 지정될 수 있다.

해당 조건을 만족하면, owner로 변할 수 있다.
1. require(msg.value < 0.001 ether)
msg의 값이 0.001 ether 보다 작아야한다.
2. contribution[msg.sender] > contributions[owner]
msg.sender의 contribution이 onwer의 contribution보다 커야한다.

(help)

1을 만족시키는 것은 다음과 같다.

await contract.contribution.sendTransaction({value:to Wei("0.0001")})

sendTransaction을 통해 전송하는데, 여기서 ether를 전송하는 것은 toWei를 사용하며
여기서 Wei란? ether를 전송하는데 사용되는 단위

이렇게하여, 전송하면, 0.0001보다 작으므로, 1의 조건에 만족하게 된다.
require을 만족하여, contributions[msg.sender] += msg.value가 실행된다.
처음 호출 하게 된 것이라면, msg.sender = msg.value(0.0001)가 contribution에 저장되게 된다.
그러나, 처음에 선언 할 때, owner의 contribution을 1000 ether라고 지정하였기 때문에
이를 만족하기 위해서는 해당 작업을 1,000,000번 수행해야한다... (진땀)

이 방법 외에 더 좋은 방법이 있는지 조회를 해보면

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

receive()를 통해, msg.value가 0보다 크고, contribution[msg.sender]가 0보다 크면, owner가 될 수 있음을 확인했다.

위에서 말했다시피, calldata가 없으면 receive가 호출되고, 이게 fallback 함수에 의해 작동되는 것이기 때문에 문제의 이름이 fallback임을 유추할 수 있다.

앞에서 했던 작업들에 의해 require이 만족되어, owner가 되었음을 console창을 통해 확인할 수 있었다.


[object 2]
잔액을 0원으로 만들어야한다고 한다.
그럼, 인출을 해야한다.
인출에 관련된 부분을 확인해보면,

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

withdraw라는 함수를 통해, 작동되는 것을 확인할 수 있었다.
또한, 해당 함수 내에서, onlyOwner가 적혀있는데 이를 "함수제어자"라고 부르며
이에 대한 내용 또한 위의 코드에서 확인할 수 있었다.

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

msg.sender가 owner여야 하는데, 이미 우리는 owner의 권한을 획득하였으므로
withdraw를 수행할 수 있게 되었다.

await contract.withdraw()

위의 명령문을 통해, 인출을 진행하여 0원으로 만들 수 있었다.

profile
+82 02 web vulnerability tester

0개의 댓글