Ethernaut 01 Fallback
Ethernaut 01 FallbackEthernaut 01 FallbackEthernaut 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
코드
// 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는 생성자에서 정해진다.
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()는 다음과 같다.
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()에서 일어난다.
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}이 함수는 이더만 들어오는 트랜잭션에서 실행되고, 조건은 두 가지다.
msg.value > 0contributions[msg.sender] > 0
첫 번째 조건은 1 wei만 보내도 만족한다. 두 번째 조건은 앞에서 contribute({value: 1})를 한 번 호출하면 만족한다. 따라서 공격자는 먼저 아주 작은 기여를 등록하고, 그 다음 함수 호출 없이 이더만 보내서 receive()를 실행시키면 owner가 된다.
마지막은 withdraw()다.
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인 내 주소로 전송된다.
익스플로잇
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
// 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.datais empty and only ether is sent,receive()runs if it exists. - If
msg.datais present, or the called function signature does not exist in the contract,fallback()runs. receive()exists solely for receiving ether, so it is alwayspayable.- For
fallback()to receive ether, it must be declaredpayable.
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.
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().
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().
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 > 0contributions[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().
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
await contract.contribute({ value: 1 });
await contract.sendTransaction({ value: 1 });
await contract.withdraw();