Emanuele Ricci
Ethernaut Challenge #12 Solution — Privacy
This is Part 12 of the "Let's play OpenZeppelin Ethernaut CTF" series, where I will explain how to solve each challenge.
The Ethernaut is a Web3/Solidity based wargame created by OpenZeppelin. Each level is a smart contract that needs to be 'hacked'. The game acts both as a tool for those interested in learning ethereum, and as a way to catalogue historical hacks in levels. Levels can be infinite and the game does not require to be played in any particular order.
Challenge #12: Privacy
The creator of this contract was careful enough to protect the sensitive areas of its storage. Unlock this contract to beat the level.
Things that might help:
- Understanding how storage works
- Understanding how parameter parsing works
- Understanding how casting works
Tips:
- Remember that metamask is just a commodity. Use another tool if it is presenting problems. Advanced gameplay could involve using remix, or your own web3 provider.
Level author(s): Alejandro Santander
The goal of this challenge is to be able to unlock Privacy
contract by discovering the "secret" key
stored in it.
Do you remember the [[8 Vault]] challenge? Well, it's pretty much the same, so let's go and study the contract!
Study the contracts
The contract itself is pretty simple, there are many state variables, a constructor
and an unlock
function.
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Privacy {
bool public locked = true;
uint256 public ID = block.timestamp;
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(now);
bytes32[3] private data;
constructor(bytes32[3] memory _data) public {
data = _data;
}
function unlock(bytes16 _key) public {
require(_key == bytes16(data[2]));
locked = false;
}
}
All the state variables are pretty useless, we are just interested in two variables
bool public locked
that is initialized to true and hold the value that must be set to false to win the challengebytes32[3] private data
is the variable where our key is stored. We need to find out the value ofdata[2]
to solve the challenge
We can see all the other variables only as a "storage padding" to reach what we want to read (data[2]
) to solve the challenge.
The constructor(bytes32[3] memory _data)
just initialize the data
variable's value
And then we have unlock(bytes16 _key)
that simply check if the byte16 _key
input we have passed match the data[2]
value. If the comparison return, true
we have unlocked the contract and passed the challenge.
There are three concepts that we need to master to be able to solve the challenge:
- How information are stored in the blockchain. Are
private
variables really private? - How the Solidity Layout of State variable in Storage works
- How "casting" works (we need to downcast
data[2]
frombytes32
tobytes16
)
I have already covered all these topics in the [[1 Private Data]] blog post, so I will freely copy-paste from there and adapt part of the content to this challenge.
Private variable and Solidity Layout of State variable in Storage
The first thing that you must remember when you use or develop on the blockchain is that nothing is private in the blockchain. Everything can be seen even if you declare a variable as private
or internal
. I suggest you to read more about this concept by reading “SWC-136: Unencrypted Private Data On-Chain”.
I'm saying this because the owner of the contract would think that there is no way that I would be able to read directly a private
state variable. But in reality, we have two different way to do that:
- you could re-construct the key by reviewing the deployment data on Etherscan or Tenderly
- you could just fork the network where the contract is deployed in a block after the deployment and use Foundry's Cheatcode to read the slot where that value is stored
We will go with the second options just because I think that it's more fun :D
First, we need to understand how the Layout of State variables in Storage work.
- Each storage slot will use 32 bytes (word size)
- For each variable, a size in bytes is determined according to its type
- Multiple, contiguous items that need less than 32 bytes are packed into a single storage slot if possible according to the following rules:
- The first item in a storage slot is stored lower-order aligned.
- Value types use only as many bytes as are necessary to store them.
- If a value type does not fit the remaining part of a storage slot, it is stored in the next storage slot.
- Structs and array data always start a new slot and their items are packed tightly according to these rules.
- Items following struct or array data always start a new storage slot.
Bonus: constant
and immutable
variables will not take a storage slot because they will be directly replaced in the code at compile time or during deployment time (immutable). See more on the "Constant and Immutable State Variables" documentation page.
Let's now look at the Contract variables layout:
bool public locked = true;
uint256 public ID = block.timestamp;
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(now);
bytes32[3] private data;
And now let's try to guess which sloth each variable will take
- slot_0:
locked
is of typebool
so it would take8 bits (1 byte)
but because the next variable cannot be packed with this, Solidity reserve for thelocked
variable an entire storage. - slot_1:
ID
is of typeaddress
so it would take20 bytes
. Same as before, it cannot be packed and will take an entire storage. - slot_2:
flattening
,denomination
andawkwardness
can all be packed together because in total they only need8 bits + 8 bits + 16 bits = 32 bits
. - From slot_3 to slot_5:
data
is a static sizebytes32
array of 3 elements. Each element will take a single slot.
We now know that the "secret" key we need to unlock the contract is stored in the fifth slot of the storage layout of the contract.
Down casting
What you have to understand when you're downcast is that you are going to lose information because you will store bigger information inside a smaller box.
Let's make an example: inside the bytes32
variable, we have this value 0x66a80b61b29ec044d14c4c8c613e762ba1fb8eeb0c454d1ee00ed6dedaa5b5c5
If we perform a downcast of that value to bytes16
the new value would be 0x66a80b61b29ec044d14c4c8c613e762b
Can you see the result? Solidity takes the higher order 16 bytes and "transfer" them to the new variable.
For our challenge it's not a huge deal because the unlock
function just a simple equality check _key == bytes16(data[2])
but it's still an important concept to know.
Read storage values
We now have all the information that we need, and we can leverage the power of Foundry
by using its cheatcode vm.load
to read a specific slot position from a specific contract's address
If you want to learn more about this cheatcode, just open the Foundry Cheatcodes Reference.
Solution code
Here's the code that I used for the test:
function exploitLevel() internal override {
vm.startPrank(player, player);
// Read the slot 5 from the level address
bytes32 data = vm.load(address(level), bytes32(uint256(5)));
// Call the level's `unlock` function and pass the downcasted bytes16
// value we just took from the private slot
level.unlock(bytes16(data));
// Assert we have unlocked the contract and passed the challenge
assertEq(level.locked(), false);
vm.stopPrank();
}
You can read the full solution of the challenge, opening Privacy.t.sol
Further reading
- SWC-136: Unencrypted Private Data On-Chain
- Foundry's Cheatcode
- Layout of State variables in Storage
- Constant and Immutable State Variables
- Mappings and Dynamic Arrays
Disclaimer
All Solidity code, practices and patterns in this repository are DAMN VULNERABLE and for educational purposes only.
I do not give any warranties and will not be liable for any loss incurred through any use of this codebase.
DO NOT USE IN PRODUCTION.