Discover more from unhackedctf
audius walkthrough (challenge 3)
how a storage slot collision turned into a $6mm hack
when we first met, anon, i had hopes that we could use my time machine for good. that our wits could match those of the most sinister blackhats, and that — together — we could go back and stop them.
but we aren’t just a match for the blackhats.
we’re so much more.
not only did two of you submit brilliant solutions in the same minute. one of you found an exploit that stole 15x more than the original hacker did.
all i can say is that we’re lucky you’re the good guys.
how to hack audius
the core of the audius exploit was well publicized. a storage slot collision with the proxy contract left all their contracts vulnerable to be reinitialized.
their post-mortem explains it well, but the basic idea is this:
they stored the proxyAdmin in storage slot 0 of the proxy contract (big no-no!)
their other contracts stored the
initializingbools in the same slot
they stored an address as the proxyAdmin, which overwrote the initialization bools with truthy values
this allowed all calls to functions with the
initializermodifier to succeed, because
require(initializing || isConstructor() || !initialized, "Contract instance has already been initialized");
the result is that all contracts could be reinitialized. so we know that. but how can we use that to empty the treasury?
if i have control over the contract parameters, maybe i can set things up in a way that i’ll be able to make a proposal to send me all the funds in the treasury, and get it through governance.
this is the path the attacker took, and that we’ll take too.
so let’s start going through what’s stopping us and seeing if we can unblock ourselves: to get our proposal to pass, we’ll need a lot of votes, which takes us to the delegate manager. in that contract, there’s a function called
delegateStake(). sounds about right.
this function moves votes from the sender to any address called. in this case, we’ll call it and pass ourselves, with a massive quantity.
how is this possible? we don’t have any votes to move?
you can puzzle through the contracts to figure out how it’s possible, but i took a different approach: just try it, run the test in foundry, check the trace to see where it fails, and see if we can resolve that issue. that took me on the following journey:
the staking contract tries to transfer tokens, so i reinitialized the staking contract with my contract as the token address, and implemented a dummy transferFrom function.
this initialization calls a check that
Governance(_governanceAddress).isGovernanceAddress() == true. well that’s pretty easy to get around. i implemented the isGovernanceAddress() function on my own contract as well.
the delegate manager contract calls out to the service provider factory to validateAccountStakeBalance(). i reinitialized the delegate manager to set myself as the governance address, then called setServiceProviderFactoryAddress() to set that to myself as well.
this initialization also calls a few extra functions to the governance contract, so i had to implement dummy getVotingPeriod() and getExecutionDelay().
voila. i now have unlimited votes delegated to me. it’s like test driven development, but for breaking things.
the will of the people
now that i have unlimited votes, i just need to set up governance so i can use them. i reinitialize this contract, changing the vote parameters so that votes last 3 blocks, execution can happen immediately, and voting only requires a quorum of 1 percent. i also set my contract as the registry (you’ll see why in a minute).
then i’m able to clear out the in progress proposals by grabbing them from a view function and iterating through, calling gov.evaluateProposalOutcome().
and, finally, the fun begins :)
i create a new proposal to transfer the full treasury balance to the address of my attacking contract, with some dummy description text.
i wait three blocks so we’re in the final voting block.
i place a vote in favor of the proposal with my enormous number of votes.
i wait 1 more block for the proposal to end, and (since there’s no longer an execution delay), call evaluateProposalOutcome()
it calls the registry to get the contract address to call to for a given proposal id. because we’re already set as the registry, we set up a function to return the $AUDIO token contract.
the result? the full balance of $AUDIO tokens in the treasury are transferred to my account.
many ways to skin a cat
interestingly, neither of the winning solutions took the same path the attacker did. hacking is an art, not a science, and there were many ways to approach this challenge.
here are POCs for the two other attacks:
@dediranTofunmi delegated a huge number of a fake token to the staking contract, changed it back to the real token, and then withdrew all the stake (up to $300mm AUDIO tokens, instead of the $18mm the thief stole)
each challenge you impress me more, anon.
but there are important hacking skills you haven’t had the chance to show me yet.
flashy skills :)
rest up — on monday morning, your next challenge will arrive.
get the next challenge straight to your inbox: