Audius
Abstract
Audius is a fully decentralized music platform. It was hacked due to improper proxy contract usage. The hacker re-initialised multiple contracts of the project to tamper with malicious parameters.
Status | Exploited |
Type | Proxy |
Date | Jul 24, 2021 |
Source | |
Direct Loss | $6M |
Project Repo |
Related Accounts
Governance Contract https://etherscan.io/address/0x4deca517d6817b6510798b7328f2314d3003abacâĻ (Proxy) https://etherscan.io/address/0x35dd16dfa4ea1522c29ddd087e8f076cad0ae5e8âĻ (Impl)
Staking Contract https://etherscan.io/address/0xe6d97b2099f142513be7a2a068be040656ae4591âĻ (Proxy) https://etherscan.io/address/0xea10fd3536fce6a5d40d55c790b96df33b26702fâĻ (Impl) DelegateManagerV2 Contract https://etherscan.io/address/0xf24aeab628493f82742db68596b532ab8a141057âĻ
Hackerâs EOA
https://etherscan.io/address/0xa0c7bd318d69424603cbf91e9969870f21b8ab4câĻ
One of hacker's helper contracts
https://etherscan.io/address/0xbdbb5945f252bc3466a319cdcc3ee8056bf2e569
Attack Vectors & Details
Holistic View
Tamper with vote parameters by re-initialisation
Submit malicious proposal
Tamper with vote weight by re-initialisation
Vote
Execute proposal
Details
Tamper with vote parameters by re-initialisation
Call initilize()
of Governance Contract to tamper with vote params:
Set votePeriod = 3
and Delay = 0
, so that only three blocks were needed to finish voting. Set _votingQuorumPercent = 1%
, meaning that only 1% of the total staked token will meet the quorum.
Submit malicious proposal
Submit malicious proposal(id=85) to Governance: Call submitProposal()
with _functionSignature = transfer(address,uint256)
, address = attacker
, amount = 18,564,497,819,999,999,999,735,541
, _targetContractRegistryKey= 307800..00
(resolve as token contract in registry).
Tamper with vote weight by re-initialisation
_quorumMet() will check whether the vote has met the quorum. If not then no result will come out. The attacker needed to increase its own weight to pass this check.
In DelegateManagerV2 call initilize()
, set himself as governanceAddress
, who has the power to delegatestake. Call delegatestake()
, as we can see thereâs no limitation on _amount, so the hacker wrote a very large number. Only vote yes to meet the quorum.
Vote
Call submitVote() to vote for proposal 85. Block height was 15201796 at that time.
Execute proposal
Call evaluateProposalOutcome()
in Governance to settle proposal 85. Block height was 15201799. Settlement time window reached, quorum met, and only him voted for yes, so the proposal passed. Function inside the proposal will be called, i.e. transfer token to the attacker.
Re-initialisation
The reason why the hacker could re-initialise an initialised contract is improper usage of the proxy architecture.
Proxy Architecture
A proxy architecture can be simply divided into two contracts: proxy(Proxy) and implementation (Impl). In fact, there are some other auxiliaries, more than two contracts.
When a user interacts with the contract, the Proxy contract doesn't "know" what functions and variables are there in the Impl. Calling functions in Impl is actually using Delegatecall from Proxy to Impl.
Delegatecall's main feature: code of callee is copied and run in the caller. I.e., the results of the computation, eg state variables, are all stored in the Proxy. And these variables are not indexed by name but by storage slot, so there is a problem of storage conflict.
Proxy | Impl | Note | |
---|---|---|---|
Slot 0 | address impl | <- bool var1 | Collision! |
Slot 1 | <- bool var2 | ||
Slot 2 | <- uint256 var3 | ||
Slot 3 | |||
In Proxy, a variable is used to record the address of the Impl contract, which has already conflicted in the above figure.
To solve this, EIP-1967 defined a storage slot location IMPLEMENTATION_SLOT = keccak256 ('eip1967.proxy.implementation' )) - 1
, and stores the impl address in this slot.
IMPLEMENTATION_SLOT is a constant, and it won't occupy storage slot. Then, use solidity assembly to write the variable into the specified slot.
Then there's the collision is eliminated.
Proxy | Impl | Note | |
---|---|---|---|
Slot 0 | <- bool var1 | ||
Slot 1 | <- bool var2 | ||
Slot 2 | <- uint256 var3 | ||
Slot 3 | |||
... | |||
Slot Custom | address impl | No collison! | |
What happened in Audius
There were a customized variable proxyAdmin
in its multiple proxy contracts, which caused the Initalized
and Initializing
variables in Impl that marked the initialization state to conflict with the proxyAdmin
storage.
We ran a test on Rinkeby and found that both initialized
and initializing
was compromised(equals True) since deployment, as address
type can cover multiple bool
types(uint 8) with non-0 value.
With initializing == true
, the modifier always allow initialisation.
In our test, if we remove the customized variable proxyAdmin
, no re-initialisation was allowed. So the conclusion is clear.
Summary
This attack happened due to re-initialisation was doable, which was caused by the improper understanding of proxy architecture.
Developers should read OpenZeppelin Proxy Docs before using its proxy module. Any customisation should be made with caution. The standard method instead of a customised variable should be used to manage access control. If you have to use a customised variable for special purposes, it should be implemented as EIP1967's storage guide.
References
Last updated