Ethernaut Writeup CTF/Wargame

Ethernaut 05 Token

Ethernaut 05 Token 문제 풀이Ethernaut 05 Token writeup

Contents

문제

지문

The goal of this level is for you to hack the basic token contract below. You are given 20 tokens to start with and you will beat the level if you somehow manage to get your hands on any additional tokens. Preferably a very large amount of tokens. Things that might help: What is an odometer?

코드

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

contract Token {
    mapping(address => uint256) balances;
    uint256 public totalSupply;

    constructor(uint256 _initialSupply) public {
        balances[msg.sender] = totalSupply = _initialSupply;
    }

    function transfer(address _to, uint256 _value) public returns (bool) {
        require(balances[msg.sender] - _value >= 0);
        balances[msg.sender] -= _value;
        balances[_to] += _value;
        return true;
    }

    function balanceOf(address _owner) public view returns (uint256 balance) {
        return balances[_owner];
    }
}

배경지식


uint256은 부호 없는 256비트 정수다. 따라서 표현할 수 있는 값은 00부터 2256−12^{256}-1까지다. 문제는 이 범위를 벗어나는 연산이다. 예를 들어 uint256 값 0에서 1을 빼면 2256−12^{256}-1로 돌아간다. 문제 힌트에 나온 odometer도 같은 의미다. 주행거리계가 최대값을 넘으면 다시 0으로 돌아가듯이, 고정 크기 정수도 범위를 벗어나면 처음이나 끝 값으로 이어진다.


이 문제는 pragma solidity ^0.6.0을 사용한다. Solidity 0.8.0 이전에는 기본 산술 연산에서 오버플로우/언더플로우를 자동으로 검사하지 않는다. 따라서 SafeMath 없이 uint256 뺄셈을 하면 언더플로우가 발생해도 revert되지 않고, 값이 uint256 범위 안에서 순환한다. 그래서 balances[msg.sender] - _value 검사는 아무것도 막지 못한다.

문제 코드 분석


잔액은 다음처럼 저장된다.

solidity
mapping(address => uint256) balances;
uint256 public totalSupply;

constructor(uint256 _initialSupply) public {
    balances[msg.sender] = totalSupply = _initialSupply;
}

balances는 주소별 토큰 잔액을 저장한다. 플레이어는 시작할 때 20 토큰을 가지고, 클리어 조건은 20보다 많은 토큰을 갖는 것이다. 즉 totalSupply를 정상적으로 늘릴 필요는 없다. balanceOf(player)만 20보다 커지면 된다.


문제는 잔액 검사에 있다.

solidity
require(balances[msg.sender] - _value >= 0);

겉으로는 현재 잔액보다 많이 보내지 못하게 막는 코드처럼 보인다. 하지만 balances[msg.sender]와 _value가 모두 uint256이라서 뺄셈 결과도 uint256이다. uint256은 음수가 될 수 없으므로 >= 0 비교는 의미가 없다. 잔액 20에서 21을 빼면 revert 없이 언더플로우가 발생해 2256−12^{256}-1이 된다. 의도대로 막으려면 뺄셈 전에 아래처럼 비교해야 한다.

solidity
require(balances[msg.sender] >= _value);

상태 업데이트는 다음 순서로 진행된다.

solidity
balances[msg.sender] -= _value;
balances[_to] += _value;

플레이어 잔액이 20일 때 _value로 21을 넣으면 첫 줄에서 플레이어 잔액이 2256−12^{256}-1이 된다. 이 값은 20보다 훨씬 크므로 레벨 조건을 만족한다. _to를 내 주소로 넣으면 안 된다. 같은 슬롯에서 21을 뺐다가 다시 21을 더하게 되므로 결과적으로 원래 잔액으로 돌아온다. 그래서 _to에는 플레이어가 아닌 다른 주소를 넣어야 한다.

풀이

플레이어의 시작 잔액은 20이다. 여기서 transfer에 21을 넣으면 잔액 차감 단계에서 언더플로우가 발생한다. 받는 주소는 내 주소만 아니면 된다. 여기서는 레벨 주소로 보냈다.

익스플로잇

javascript
await contract.transfer("0x478f3476358Eb166Cb7adE4666d04fbdDB56C407", 21)

Challenge

Description

The goal of this level is for you to hack the basic token contract below. You are given 20 tokens to start with and you will beat the level if you somehow manage to get your hands on any additional tokens. Preferably a very large amount of tokens. Things that might help: What is an odometer?

Code

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

contract Token {
    mapping(address => uint256) balances;
    uint256 public totalSupply;

    constructor(uint256 _initialSupply) public {
        balances[msg.sender] = totalSupply = _initialSupply;
    }

    function transfer(address _to, uint256 _value) public returns (bool) {
        require(balances[msg.sender] - _value >= 0);
        balances[msg.sender] -= _value;
        balances[_to] += _value;
        return true;
    }

    function balanceOf(address _owner) public view returns (uint256 balance) {
        return balances[_owner];
    }
}

Background


uint256 is an unsigned 256-bit integer, so it can represent values from 00 to 2256−12^{256}-1. The problem is arithmetic that goes outside this range. For example, subtracting 1 from a uint256 value of 0 wraps around to 2256−12^{256}-1. The odometer in the hint means the same thing. Just as an odometer rolls back to 0 after passing its maximum, a fixed-size integer wraps to the start or end of its range when it goes out of bounds.


This challenge uses pragma solidity ^0.6.0. Before Solidity 0.8.0, basic arithmetic operations are not automatically checked for overflow/underflow. So subtracting uint256 values without SafeMath does not revert on underflow; the value simply wraps within the uint256 range. That is why the balances[msg.sender] - _value check protects nothing.

Code analysis


Balances are stored like this.

solidity
mapping(address => uint256) balances;
uint256 public totalSupply;

constructor(uint256 _initialSupply) public {
    balances[msg.sender] = totalSupply = _initialSupply;
}

balances stores the token balance per address. The player starts with 20 tokens, and the win condition is holding more than 20 tokens. So there is no need to legitimately increase totalSupply. We only need balanceOf(player) to exceed 20.


The bug is in the balance check.

solidity
require(balances[msg.sender] - _value >= 0);

On the surface, this looks like it prevents sending more than the current balance. But since balances[msg.sender] and _value are both uint256, the result of the subtraction is also uint256. A uint256 can never be negative, so the >= 0 comparison is meaningless. Subtracting 21 from a balance of 20 underflows to 2256−12^{256}-1 without reverting. To enforce the intended check, the comparison must be done before the subtraction, like this.

solidity
require(balances[msg.sender] >= _value);

The state update happens in the following order.

solidity
balances[msg.sender] -= _value;
balances[_to] += _value;

With a player balance of 20, passing 21 as _value makes the player's balance 2256−12^{256}-1 on the first line. That is far greater than 20, so it satisfies the level condition. _to must not be my own address. Otherwise 21 is subtracted from and then added back to the same slot, leaving the original balance. So _to has to be some address other than the player.

Solution

The player's starting balance is 20. Passing 21 to transfer triggers an underflow at the balance deduction step. The recipient can be any address other than mine. Here I sent to the level address.

Exploit

javascript
await contract.transfer("0x478f3476358Eb166Cb7adE4666d04fbdDB56C407", 21)

Esc

Search titles, content and code.