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.
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.
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:
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
.
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 🙌
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?
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:
- Execute
governanceCall
and callburn
on theVault
. This would deploy a newBurner
contract, send all diamonds to it, and destroy it.
- Execute
governanceCall
and callupgradeToAndCall
targeting a custom contract that would executeSELFDESTRUCT
in the context of theVault
proxy.
- Redeploy the
Vault
proxy to the same address, using the factory and original salt. Then initialize it properly.
- Owning the redeployed
Vault
, upgrade it to a new version that uses a differentBurner
contract. Because theVault
's nonce has been reset, theBurnerV2
lands on the originalBurner
's address, despite having different bytecode. In the same upgrade call, do the transfer of diamonds fromBurnerV2
to the player's account.
- 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 🔥