Ethernaut#12 - Privacy
Ethernaut #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
1 | // SPDX-License-Identifier: MIT |
Contract Breakdown
Let us breakdown the contract to understand what each function and piece of code does. The code consists of a single contract Privacy compiled with solidity version ^0.8.0. This challenge is similar to the Vault challenge but goes deeper into understanding how EVM storage works.
Variables
1 | bool public locked = true; |
There are quite a few state variables here. locked is a boolean indicating if the contract is locked. ID stores the block timestamp. flattening, denomination, and awkwardness are smaller integer types. And data is a fixed-size array of 3 bytes32 values marked as private.
Constructor
1 | constructor(bytes32[3] memory _data) { |
The constructor takes an array of 3 bytes32 values and stores them in the data array.
Functions
1 | function unlock(bytes16 _key) public { |
The unlock function takes a bytes16 _key and checks if it matches bytes16(data[2]). If it does, the contract is unlocked. Notice it casts data[2] (which is bytes32) to bytes16, meaning it takes only the first 16 bytes (the higher-order bytes).
Solution
Just like the Vault challenge, private variables are still readable from the blockchain. But the tricky part here is figuring out which storage slot holds the data we need.
In the EVM, each storage slot is 32 bytes. Variables smaller than 32 bytes can be packed together in a single slot if they fit. Understanding the storage layout is crucial.
Let us map out the storage slots:
- Slot 0:
locked(bool, 1 byte) - takes up the whole slot since the next variable is uint256 - Slot 1:
ID(uint256, 32 bytes) - fills an entire slot - Slot 2:
flattening(uint8, 1 byte) +denomination(uint8, 1 byte) +awkwardness(uint16, 2 bytes) - these three get packed together in one slot since they fit within 32 bytes - Slot 3:
data[0](bytes32) - each array element gets its own slot - Slot 4:
data[1](bytes32) - Slot 5:
data[2](bytes32) - this is what we need
So data[2] is stored at slot 5.
Steps to pass:
- Read storage slot 5 using
await web3.eth.getStorageAt(instance, 5)from the Ethernaut console. This gives us the full bytes32 value. - The
unlockfunction compares withbytes16(data[2]), which takes the first 16 bytes (32 hex characters after the 0x prefix) of the bytes32 value. - Take the first 34 characters of the result (0x + 32 hex chars) to get the bytes16 key.
- For example, if the storage returns
0xabcdef...1234567890, the bytes16 key would be0xabcdef...(first 16 bytes). - Call
unlockwith this bytes16 value on Remix or through the console:await contract.unlock("0x...")
Once again, nothing on the blockchain is truly private. The
privatekeyword is an access modifier for Solidity contracts, not a security mechanism. Understanding EVM storage layout (slot packing, array storage, mapping storage) is essential for both security auditors and developers. Always assume that any data stored on-chain can be read by anyone.