어서 오세요, 익명의 여행자여, 마법의 회전목마에!
그곳에서는 생명체들이 끝없는 주문 속에서 빙글빙글 돌고 있답니다.
이 신비로운, 무한한 디지털 바퀴 안에서 그들은 황홀한 열정으로 반복하며 춤추듯 회전하죠.
새로운 생명체를 불러와 함께 즐기게 할 수 있지만, 규칙을 반드시 지켜야 합니다.
그렇지 않으면 게임은 무너지고 말 거예요.
만약 어떤 동물이 회전목마에 올라탔다면, 다시 확인할 때에도 그 동물이 반드시 그대로 있어야 합니다!
과연 당신은 이 회전목마의 마법 규칙을 깨뜨릴 수 있을까요?
// 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);
}
}
[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번 칸으로 돌아오는 루프가 형성된다.