I didn't have much time to participate in last week's Remedy CTF. Just some spare hours on Friday's afternoon. But I wanted to tackle at least one challenge. You know, a quick bite to satiate my CTF needs.

Glad to say: I managed to solve Diamond Heist 🪅 Here's how.


First clue

The prompt was fairly simple: some ERC20 tokens (a.k.a "Diamonds") were stuck in an upgradeable Vault contract, and we had to find a way to rescue them.

To get started, I looked at the Challenge contract, which was used to handle the deployment and setup of the challenge.

Deployment in Challenge contract

In there I found how the Vault contract was deployed and initialized. Also saw the diamonds I'd have to rescue being deposited. Next, I headed for the Vault itself.

The Vault contract

OK so there was governance, UUPS upgradeability, destructing... Destructing? Isn't the SELFDESTRUCT party over already? What EVM version was in use? I had a look at the foundry.toml file:

First clue found

That was my first clue. Shanghai. The almighty SELFDESTRUCT was available to have fun with.

The infamous trio

Selfdestruct + UUPS upgrades can be a well-known killer combo. With this in mind, I explored how the proxy and implementation were deployed. The Vault used the UUPS upgradeability pattern, and was deployed behind a proxy using the VaultFactory contract. The proxy itself was deployed using CREATE2.

The VaultFactory contract

Selfdestruct, UUPS upgrades, CREATE2... what a trio. I knew were this was going.

On top of it, the Vault implementation wasn't initialized. Which led me to consider the consequences of selfdestructing the implementation. Though that wasn't possible. Yes, I could take over the implementation, but then the function I'd need to upgrade it + trigger the delegatecall (UUPSUpgradeable::upgradeToAndCall) was using the onlyProxy modifier.

That was my first dead-end. I wasn't discouraged though. I knew the infamous trio of selfdestruct + UUPS + CREATE2 would come handy at some point.

Blackbox governance pwn

The Vault contract was controlled either by an owner, which I wasn't, or by governance. To execute its governanceCall function I needed at least 100k votes (a.k.a, the AUTHORITY_THRESHOLD), in the form of delegated Hexen coins.

The Challenge contract allowed me to claim 10k Hexen coins just once:

I could've reviewed the whole HexensCoin contract, starting with its getCurrentVotes function, then its delegation checkpoints and so on. But this was a CTF, not an audit. I couldn't care less about the little internal details of the contract.

So, I hacked a few black-box tests to see how the contract reacted. After claiming the first 10k hexen coins in Challenge, I kicked-off my question-driven approach to breaking things. I'd ask a question, and come up with a test to answer it, without looking at the actual contract code:

  • Can I self-delegate? Yes.
  • Can I self-delegate twice to double my votes? No.
  • Can I delegate to another account, and have that account delegate to me? No.
  • Can I have another account claim in Challenge their 10k and delegate to me? No.
  • Can I self-delegate 10k, transfer the tokens to another other account, have that account delegate to me? YES! 🧨

I didn't care why. I just knew my tests said I could do it. So I generated 10 fresh accounts, funded them with some ETH I had in my player account, and began the transfer + delegate chain.

This allowed me to obtain the 100k votes I needed, starting only with 10k 🙌

🗒️
I tried to be all fancy and do this with a one-time Foundry script, only to fail miserably. Not sure why. In any case, I ended up hand-crafting cast calls to fund 10 accounts with ETH, then sequentially transfer the hexen coins to each other, executing delegate on the HexensCoin contract.

Having done this, now I was able to execute governanceCall on the Vault... for what?

What on earth should I do with this call?

Burning

If I controlled governance, perhaps I could upgrade the Vault calling upgradeToAndCall and rescue the tokens? Nope. Because _authorizeUpgrade allowed an upgrade only once there were no diamonds in balance.

The only option left: calling burn on the Vault.

Although... this would mean losing access to all diamonds! Because Burner was just a contract that selfdestructed itself when called:

Unless...

What if I could destroy the Burner and deploy a new version to its same address but with different bytecode? This way I wouldn't lose the tokens, and would be able to include a new function to recover them.

Let's see. The Burner contract was deployed from the Vault with CREATE, an opcode that depends on the deployer's address and nonce. So I'd also need to reset the nonce of the Vault. Which could only be done by destructing and redeploying the Vault, to the same address.

So how could I destruct and redeploy the Vault? Well, I knew it was deployed with the factory using CREATE2, and that I could now upgrade it. That is, I could make it delegatecall to whatever account I wanted. Because I now controlled governance, and could set its diamonds balance to zero by first calling burn.

Seemed doable!

Final steps

Having taking over governance, I created a Foundry script to implement the final exploit chain:

  1. Execute governanceCall and call burn on the Vault. This would deploy a new Burner contract, send all diamonds to it, and destroy it.
  1. Execute governanceCall and call upgradeToAndCall targeting a custom contract that would execute SELFDESTRUCT in the context of the Vault proxy.
  1. Redeploy the Vault proxy to the same address, using the factory and original salt. Then initialize it properly.
  1. Owning the redeployed Vault, upgrade it to a new version that uses a different Burner contract. Because the Vault's nonce has been reset, the BurnerV2 lands on the original Burner's address, despite having different bytecode. In the same upgrade call, do the transfer of diamonds from BurnerV2 to the player's account.
Final upgrade with transfer of diamonds to player
  1. Get the flag:
    rctf{m1ss10n_n0t_th4t_1mp0ss1bl3_f9992a6d1710843ecf50497a}

All in all, nice challenge. We're all kind of used to these SELFDESTRUCT shenanigans, that's probably why this was the most solved challenge of the CTF. Honestly, in my case finding the issues was relatively simple. I spent most time debugging my implementation and fighting with Foundry scripts. I'm a slow coder - always impressed how some teams can code their solutions so fast 🔥