1. 문제

어서 오세요, 익명의 여행자여, 마법의 회전목마에! 
그곳에서는 생명체들이 끝없는 주문 속에서 빙글빙글 돌고 있답니다.
이 신비로운, 무한한 디지털 바퀴 안에서 그들은 황홀한 열정으로 반복하며 춤추듯 회전하죠.

새로운 생명체를 불러와 함께 즐기게 할 수 있지만, 규칙을 반드시 지켜야 합니다.
그렇지 않으면 게임은 무너지고 말 거예요.
만약 어떤 동물이 회전목마에 올라탔다면, 다시 확인할 때에도 그 동물이 반드시 그대로 있어야 합니다!

과연 당신은 이 회전목마의 마법 규칙을 깨뜨릴 수 있을까요?
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

contract MagicAnimalCarousel {
    uint16 constant public MAX_CAPACITY = type(uint16).max;
    uint256 constant ANIMAL_MASK = uint256(type(uint80).max) << 160 + 16;
    uint256 constant NEXT_ID_MASK = uint256(type(uint16).max) << 160;
    uint256 constant OWNER_MASK = uint256(type(uint160).max);

    uint256 public currentCrateId;
    mapping(uint256 crateId => uint256 animalInside) public carousel;

    error AnimalNameTooLong();

    constructor() {
        carousel[0] ^= 1 << 160;
    }

    function setAnimalAndSpin(string calldata animal) external {
        uint256 encodedAnimal = encodeAnimalName(animal) >> 16;
        uint256 nextCrateId = (carousel[currentCrateId] & NEXT_ID_MASK) >> 160;

        require(encodedAnimal <= uint256(type(uint80).max), AnimalNameTooLong());
        carousel[nextCrateId] = (carousel[nextCrateId] & ~NEXT_ID_MASK) ^ (encodedAnimal << 160 + 16)
            | ((nextCrateId + 1) % MAX_CAPACITY) << 160 | uint160(msg.sender);

        currentCrateId = nextCrateId;
    }

    function changeAnimal(string calldata animal, uint256 crateId) external {
        address owner = address(uint160(carousel[crateId] & OWNER_MASK));
        if (owner != address(0)) {
            require(msg.sender == owner);
        }
        uint256 encodedAnimal = encodeAnimalName(animal);
        if (encodedAnimal != 0) {
            // Replace animal
            carousel[crateId] =
                (encodedAnimal << 160) | (carousel[crateId] & NEXT_ID_MASK) | uint160(msg.sender); 
        } else {
            // If no animal specified keep same animal but clear owner slot
            carousel[crateId]= (carousel[crateId] & (ANIMAL_MASK | NEXT_ID_MASK));
        }
    }

    function encodeAnimalName(string calldata animalName) public pure returns (uint256) {
        require(bytes(animalName).length <= 12, AnimalNameTooLong());
        return uint256(bytes32(abi.encodePacked(animalName)) >> 160);
    }
}

2. 코드 분석

저장 레이아웃(256 bit)

[ANIMAL(10byte)=80bit] [NEXT_ID(2byte)=16bit] [OWNER(20byte)=160bit]

uint256 constant ANIMAL_MASK = uint256(type(uint80).max) << 160 + 16;
uint256 constant NEXT_ID_MASK = uint256(type(uint16).max) << 160;
uint256 constant OWNER_MASK = uint256(type(uint160).max);

3개의 변수는 위와 같이 총 256비트를 저장하도록 정의하고 있다.

그런데 encodeAnimalName()이 동물 이름을 최대 12바이트로 인코딩해 버려. 즉, 설계(10바이트)와 구현(12바이트)이 불일치하는 현상이 발생한다

function encodeAnimalName(string calldata animalName) public pure returns (uint256) {
    require(bytes(animalName).length <= 12, AnimalNameTooLong());
    return uint256(bytes32(abi.encodePacked(animalName)) >> 160);
}

그리고 setAnimalAndSpin()은 그 불일치를 보정하려고 >>16 후 <<(160+16)을 써서 정상 영역(10바이트) 에 넣음. 반면 changeAnimal()은 encodedAnimal << 160으로 12바이트 전체를 밀어넣어 NEXT_ID 2바이트를 침범(오버플로우) 한다

function setAnimalAndSpin(string calldata animal) external {
    uint256 encodedAnimal = encodeAnimalName(animal) >> 16;
    uint256 nextCrateId = (carousel[currentCrateId] & NEXT_ID_MASK) >> 160;

    require(encodedAnimal <= uint256(type(uint80).max), AnimalNameTooLong());
    carousel[nextCrateId] = (carousel[nextCrateId] & ~NEXT_ID_MASK) ^ (encodedAnimal << 160 + 16)
        | ((nextCrateId + 1) % MAX_CAPACITY) << 160 | uint160(msg.sender);

    currentCrateId = nextCrateId;
}

이 취약점의 핵심은 NEXT_ID 체인을 고의로 꼬아버린 뒤, setAnimalAndSpin() 함수가 동물을 기록할 때 단순 대입이 아니라 XOR 연산을 사용한다는 점을 이용하는 것이다.

먼저 setAnimalAndSpin("Dog")을 호출하면 currentCrateId가 1로 이동하고, 1번 칸에 Dog가 저장된다. 그 다음 changeAnimal을 이용해 1번 칸의 NEXT_ID를 조작한다. 여기서 이름을 12바이트로 주되 마지막 2바이트를 0xFFFF로 맞추면, 내부 동작 때문에 1번 칸의 NEXT_ID 값이 강제로 0xFFFF로 덮어씌워진다. 결과적으로 1번 칸은 다음 칸으로 0xFFFF번 칸을 가리키게 된다.

이제 다시 setAnimalAndSpin("Parrot")을 호출하면 흐름이 0xFFFF번 칸으로 이어진다. 0xFFFF번 칸에 Parrot이 기록되고, 이 칸의 NEXT_ID는 (0xFFFF+1) % 0xFFFF = 1이 되어 다시 1번 칸으로 돌아오는 루프가 형성된다.