Ethernaut Writeup CTF/Wargame

Ethernaut 01 Fallback

Ethernaut 01 Fallback 문제 풀이Ethernaut 01 Fallback writeup

Contents

문제

지문

Look carefully at the contract's code below.

You will beat this level if:

  • you claim ownership of the contract
  • you reduce its balance to 0

Things that might help:

  • How to send ether when interacting with an ABI
  • How to send ether outside of the ABI
  • Converting to and from wei/ether units (see help() command)
  • Fallback methods

코드

solidity
// 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;
    }
}

배경지식


컨트랙트에 이더를 보내거나 존재하지 않는 함수를 호출하면 일반 함수 호출과 다른 진입점이 실행된다. 이때 사용되는 함수가 receive()와 fallback()이다. 동작은 다음처럼 나뉜다.

  • msg.data가 비어 있고 이더만 전송되면 receive()가 있으면 receive()가 실행된다.
  • msg.data가 있거나, 호출한 함수 시그니처가 컨트랙트에 없으면 fallback()이 실행된다.
  • receive()는 이더 수신 전용 함수라서 항상 payable이다.
  • fallback()으로 이더를 받으려면 payable로 선언되어 있어야 한다.

이 문제에서는 실제로 fallback() 함수가 아니라 receive() 함수가 사용된다. 그런데 제목이 Fallback인 이유는 솔리디티 0.6.0 이전에는 이 역할이 fallback() 하나로 처리되다가, 이후 receive()와 fallback()으로 분리되었기 때문이다.


contract.contribute({value: 1})처럼 함수를 호출하면서 이더를 보내면 ABI에 정의된 함수가 실행된다. 반대로 함수명을 지정하지 않고 컨트랙트 주소로 이더만 보내면 ABI 함수를 거치지 않는 단순 이더 전송 트랜잭션이 된다. 이 문제에서는 이 차이를 이용한다. contribute()를 한 번 호출해서 contributions[msg.sender] > 0 상태를 만든 뒤, 함수 호출 없이 이더만 보내야 receive()가 실행된다.


1 ether는 10^18 wei다. 따라서 콘솔에서 value: 1이라고 보내면 1 ether가 아니라 1 wei를 보내는 것이다. 이 문제의 contribute()는 msg.value < 0.001 ether만 요구하고, receive()는 msg.value > 0만 요구한다. 즉 1 wei만 보내도 두 조건을 모두 만족한다.

문제 코드 분석


초기 owner와 contributions는 생성자에서 정해진다.

solidity
mapping(address => uint256) public contributions;
address public owner;

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

배포자는 처음부터 owner가 되고, contributions[owner]에는 1000 ether가 들어간다. contribute()만 보면 기여도가 기존 owner보다 커야 owner를 바꿀 수 있는데, 공격자가 1000 ether보다 크게 기여하는 것은 문제 의도상 현실적인 풀이가 아니다. 따라서 owner를 바꾸는 다른 경로를 찾아야 한다.


contribute()는 다음과 같다.

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

contribute()는 직접 owner를 탈취하기에는 불리하다. msg.value가 0.001 ether보다 작아야 하므로, 1000 ether를 넘기려면 너무 많은 호출이 필요하다. 하지만 receive() 조건을 맞추기 위해 contributions[msg.sender]를 0보다 크게 만드는 용도로는 충분하다.


소유권 변경은 receive()에서 일어난다.

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

이 함수는 이더만 들어오는 트랜잭션에서 실행되고, 조건은 두 가지다.

  • msg.value > 0
  • contributions[msg.sender] > 0

첫 번째 조건은 1 wei만 보내도 만족한다. 두 번째 조건은 앞에서 contribute({value: 1})를 한 번 호출하면 만족한다. 따라서 공격자는 먼저 아주 작은 기여를 등록하고, 그 다음 함수 호출 없이 이더만 보내서 receive()를 실행시키면 owner가 된다.


마지막은 withdraw()다.

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

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

문제의 두 번째 조건은 컨트랙트 잔액을 0으로 만드는 것이다. withdraw()는 onlyOwner만 통과하면 컨트랙트의 전체 잔액을 현재 owner에게 보낸다. 정리하면 contribute()로 receive() 조건을 만들고, 이더만 보내 owner를 가져온 뒤, withdraw()로 잔액을 빼내면 된다.

풀이

먼저 contribute()에 1 wei를 보내 내 contributions 값을 0보다 크게 만든다. 이후 함수 호출 없이 컨트랙트에 1 wei를 한 번 더 보내면 receive()가 실행되고 owner가 내 주소로 바뀐다. 소유권을 가져왔으니 withdraw()만 호출하면 된다. 컨트랙트에 남아 있던 잔액은 현재 owner인 내 주소로 전송된다.

익스플로잇

javascript
await contract.contribute({ value: 1 });
await contract.sendTransaction({ value: 1 });
await contract.withdraw();

Challenge

Description

Look carefully at the contract's code below.

You will beat this level if:

  • you claim ownership of the contract
  • you reduce its balance to 0

Things that might help:

  • How to send ether when interacting with an ABI
  • How to send ether outside of the ABI
  • Converting to and from wei/ether units (see help() command)
  • Fallback methods

Code

solidity
// 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;
    }
}

Background


When you send ether to a contract or call a function that does not exist, an entry point other than a regular function call runs. The functions used for this are receive() and fallback(). The behavior breaks down as follows.

  • If msg.data is empty and only ether is sent, receive() runs if it exists.
  • If msg.data is present, or the called function signature does not exist in the contract, fallback() runs.
  • receive() exists solely for receiving ether, so it is always payable.
  • For fallback() to receive ether, it must be declared payable.

This challenge actually uses receive(), not fallback(). The title is still Fallback because before Solidity 0.6.0 this role was handled by a single fallback() function, which was later split into receive() and fallback().


If you send ether while calling a function, as in contract.contribute({value: 1}), the function defined in the ABI is executed. Conversely, if you send only ether to the contract address without specifying a function, it is a plain ether transfer that does not go through any ABI function. This challenge relies on that difference. You call contribute() once to get contributions[msg.sender] > 0, and then send ether without any function call so that receive() runs.


1 ether is 10^18 wei. So sending value: 1 from the console sends 1 wei, not 1 ether. In this challenge, contribute() only requires msg.value < 0.001 ether, and receive() only requires msg.value > 0. So sending just 1 wei satisfies both conditions.

Code analysis


The constructor sets the initial owner and contributions.

solidity
mapping(address => uint256) public contributions;
address public owner;

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

The deployer becomes owner from the start, and contributions[owner] is set to 1000 ether. Looking at contribute() alone, you need a larger contribution than the current owner to take ownership, but contributing more than 1000 ether is not a realistic solution for this challenge. So we need to find another path to change the owner.


Here is contribute().

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

contribute() is a poor way to take ownership directly. Since msg.value must be less than 0.001 ether, exceeding 1000 ether would require far too many calls. It is still good enough for making contributions[msg.sender] greater than 0, which the receive() condition needs.


The ownership change happens in receive().

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

It runs on transactions that carry only ether, and it has two conditions.

  • msg.value > 0
  • contributions[msg.sender] > 0

The first condition is satisfied by sending just 1 wei. The second is satisfied by calling contribute({value: 1}) once beforehand. So the attacker first registers a tiny contribution, then sends ether without a function call to trigger receive(), and becomes owner.


Last is withdraw().

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

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

The second goal is to reduce the contract balance to 0. As long as onlyOwner passes, withdraw() sends the contract's entire balance to the current owner. So the full flow is: use contribute() to set up the receive() condition, send plain ether to take owner, then drain the balance with withdraw().

Solution

First, send 1 wei to contribute() to make my contributions value greater than 0. Then send another 1 wei to the contract without a function call; receive() runs and owner changes to my address. With ownership taken, I only need to call withdraw(). The remaining balance in the contract is sent to my address, the current owner.

Exploit

javascript
await contract.contribute({ value: 1 });
await contract.sendTransaction({ value: 1 });
await contract.withdraw();

Esc

Search titles, content and code.