DAO voting vulnerabilities

Author: Konstantin Nekrasov
Security researcher at MixBytes
Coin voting
A decentralized autonomous organization (DAO) operates in a blockchain and is governed by voting. Coin voting is the most popular one: a member of a DAO makes a proposal and other coin holders cast their approval with tokens. When the proposal's quorum is reached its script can be executed.

There is a reasonable criticism of such approach which is highlighted by Vitalik Buterin [1] [2] [3]:

  1. Small groups of wealthy participants («whales») are better at successfully executing decisions than large groups of small-holders.
  2. Coin voting governance empowers coin holder interests at the expense of other parts of the community.
  3. Conflicts of interest arise for coin holders who also hold tokens of other DeFi platforms that interact with the platform in question.
  4. Coin voting's deep fundamental vulnerability to attackers: vote buying [4] [5] [7], vote lending [9] and whales collusion [6].
  5. Exposure to complex Game-Theoretic Attacks [10].
These are non-technical problems. There are also purely technical bugs that a programmer may introduce in the code due to distracted attention or insufficient knowledge of how blockchain works.

There are many DAOs that use coin voting: Aragon-based DAOs, X-DAO, Nexus Mututal, Showball Finance, Pickle Finance, Spirit Swap, Keep3r Network, and many others.

In this article we'll examine technical vulnerabilities that may arise in coin votings and check whether they exist in some of the aforementioned DAOs.
Attack #1. Flash loan
A DAO may be vulnerable if a hacker can vote and execute a proposal in the same block (e.g. via emergency method). Those kind of vulnerabilities have been encountered before in such projects as Beanstalk [5] and MakerDAO [11].
Aragon
Aragon uses MiniMeToken's balanceOfAt() to calculate the user's balance one block before the proposal was created. This makes flash loan attacks impossible.
X-DAO
X-DAO reverts on transfers [→]:
function transfer(address, uint256) public pure override returns (bool) {
    revert("GT: transfer is prohibited");
}
Thus it is impossible to lend the token or sell it in a DEX as well as borrow or buy and thus flash loan attacks are impossible.
Nexus Mutual
Proposals in Nexus Mutual are divided into those on which only the advisory board can vote, and those on which ordinary members can vote. In advisory board voting, the weight of each participant is equal to one. A member voting is an ordinary coin voting. Our scope is coin voting only.

It is possible to vote and execute a proposal in one transaction if all other members have already voted [→]:
function canCloseProposal(uint _proposalId)
    ...
    if (numberOfMembers == proposalVoteTally[_proposalId].voters
      || dateUpdate.add(_closingTime) <= now)
      return 1;
But there is a complication — the user's ability to transfer their tokens is locked for the next 7 days after each vote is cast [→]:
tokenInstance.lockForMemberVote(msg.sender, tokenHoldingTime);
So, technically a hacker can take a flash loan, pull NXM tokens from the market, vote and execute a proposal in the same transaction. But they still need to return the loan, and that requires a profit from the executed proposal not lower than the flash loan itself. However, it seems to be difficult to come up with a successful attack due to various restrictions introduced after the discovery of calldata validation vulnerability [14].
Keep3r Network
The project uses a token similar to MiniMeToken. It calculates the voting power via getPriorVotes() one block before the proposal creation block. The same conclusions can be made.
Attack #2. Incorrect re-vote
A vulnerability may arise if a contract allows a user to re-vote on a proposal but it subtracts the user's old vote incorrectly.

It is recommended checking the following dangerous scenarios:

  • vote for a non-existent proposal;
  • vote → transfer → vote;
  • vote for the proposal in the same block it was created;
  • vote with mangled parameters but for the same proposal ID;
  • replay an off-chain transaction.
Those kinds of vulnerabilities have been encountered before in such projects as MakerDAO [12] and KP3R Network [13].
Aragon
It is possible to revote in Aragon but it adds/subtracts the previous voting power correctly [→]:
// This could re-enter, though we can assume the governance token is not malicious.
uint256 voterStake = token.balanceOfAt(_voter, vote_.snapshotBlock);
VoterState state = vote_.voters[_voter];

// If the voter had previously voted, then we decrease the count.
if (state == VoterState.Yea) {
    vote_.yea = vote_.yea.sub(voterStake);
} else if (state == VoterState.Nay) {
    vote_.nay = vote_.nay.sub(voterStake);
}
Vote for a non-existent proposal
There is a voteExists modifier on the vote casting method so it is impossible to vote for a non-existent proposal [→]:
function vote(
    uint256 _voteId, 
    bool _supports, 
    bool _executesIfDecided
) external voteExists(_voteId)
Other cases
The voting power of a user cannot be transferred and utilized for the same proposal since Aragon uses MinimeToken's balance at one block before the proposal was created.
X-DAO
Voting in X-DAO occurs off-chain and there is no mechanism to re-vote on the same proposal. If a user signed a rejection, they can still issue a signed approval and the approval will be counted. But it cannot be done the other way around — only positive votes are counted [→]:
for (uint256 i = 0; i < signers.length; i++) {
    share += balanceOf(signers[i]);
}

if (share * 100 < totalSupply() * quorum) {
    return false;
}
A signed vote cannot be counted twice [→]:
require(!_hasDuplicate(signers), "DAO: signatures are not unique.");
Replay attacks
To cast a vote a user signs a proposal which is just a set of data: target address, calldata, msg value, nonce, timestamp, block.chainid and address of the X-DAO instance. Thus, it is impossible to replay a user's signed vote neither on different Ethereum chain, nor on another X-DAO instance, nor even on another proposal with a different nonce [→]:
function getTxHash
...
return
    keccak256(abi.encode(
        address(this),
        _target,
        _data,
        _value,
        _nonce,
        _timestamp,
        block.chainid
    ));
}
A proposal cannot be replayed as well [→]:
require(!executedTx[txHash], "DAO: voting already executed.");
Snowball Finance
A user cannot vote for a non-existent proposal but they can re-vote on the same proposal. The contract correctly subtracts their previous decision on a re-vote. User tokens are locked in the escrow contract and cannot be transferred.
Spirit Swap
Spirit Swap has no proposals: a user can select and vote for token weights only. A user can vote once a week with no ability to re-vote.
Keep3r Network
Keep3r Network has a similar behaviour to Aragon but users cannot vote twice on the same proposal.

Vote for a non-existent proposal fails at the requirement [→]:
function _castVote...
    ...
    require(state(proposalId) == ProposalState.Active, "Governance::_castVote: voting is closed.");
It also impossible to re-vote [→]:
require(receipt.hasVoted == false, "Governance::_castVote: voter already voted.");
Nexus Mutual
It is impossible to vote for a non-existent proposal because the following requirement will fail [→]:
function submitVote(uint _proposalId, uint _solutionChosen) external {
    ...
    require(allProposalData[_proposalId].propStatus == uint(Governance.ProposalStatus.VotingStarted), "Not allowed");
A user can vote for a proposal only once [→]:
function _submitVote(uint _proposalId, uint _solution) internal {
    ...
    require(memberProposalVote[msg.sender][_proposalId] == 0, "Not allowed");
    ...
    memberProposalVote[msg.sender][_proposalId] = totalVotes;
Attack #3. Missing proposal validation
If a proposal properties are not fully validated, then there could be social engineering opportunities for a hacker to create a destructive proposal that would look benign.

Answer these questions:

  1. How a proposal is displayed on a website?
  2. Can a hacker provide an arbitrary script or calldata in a proposal?
  3. Can a hacker provide an arbitrary benign description in a malicious proposal?
  4. Is it hard for a normal user to determine what the script of a proposal really does?

Weak validation has been encountered before in such projects as Beanstalk [5] and Nexus Mutual [14].
Aragon
A new proposal in Aragon takes an arbitrary execution script and metadata [→]:
function newVote(bytes _executionScript, string _metadata) external auth(CREATE_VOTES_ROLE) returns (uint256 voteId)
The metadata is emitted but not kept in a storage, so it is implied that a backend will parse a blockchain for events in order to display a proposal author address and a description [→]:
emit StartVote(voteId, msg.sender, _metadata);
So, it is possible to provide an arbitrary execution script. Thus, the responsibility for verifying the data falls on the voters.
X-DAO
There are no on-chain methods to create a proposal in X-DAO.

An off-chain proposal can be created on xdao.app and there are two points to note:

  1. A target address and its calldata are hidden by default so a user may miss a malicious intent. See a proposal example at link
  2. It is possible to specify an arbitrary proposal title and description for a proposal: link

Thus, the responsibility for verifying the data falls on the voters.
Snowball Finance
A new proposal in Snowball Finance takes the following arguments:
function propose(
    string calldata _title,
    string calldata _metadata,
    uint256 _votingPeriod,
    address _target,
    uint256 _value,
    bytes memory _data
) 
and stores all of them in the contract's storage, including msg.sender.

There are no limits on the calldata. Thus, the responsibility for verifying the data falls on the voters.
Spirit Swap
Spirit Swap has no ID or metadata for its voting so this section is irrelevant.
Keep3r Network
A new proposal in Keep3r Network takes a list of targets, msg values, calldatas and additional metadata [→]:
function propose(
    address[] memory targets,
    uint256[] memory values,
    string[] memory signatures,
    bytes[] memory calldatas,
    string memory description
)
An arbitrary calldata can still be provided by an attacker. Thus, the responsibility for verifying the data falls on the voters.
Nexus Mutual
Nexus Mutual proposals are divided into different categories. Each category has a predetermined action address and a method signature. A proposal on the website clearly displays the method to be called and its arguments: https://app.nexusmutual.io/governance/view?proposalId=175

Thus, there seem to be no social engineering opportunities.
Attack #4. No transfer validation
A token locking mechanism should check the return value of an approved transferFrom call (and other transfer-like methods):

  • Aragon Minime token returns false if a call to the transferFrom function fails.

This type of vulnerability has been encountered before in ForceDAO [15].
Nexus Mutual
Nexus Mutual locks tokens via the tokenInstance.lockForMemberVote() method without using transferFrom [→]:
function lockForMemberVote(
    address _of, 
    uint _days
) public onlyOperator {
    if (_days.add(now) > isLockedForMV[_of])
        isLockedForMV[_of] = _days.add(now);
}
Aragon, X-DAO and Keep3r Network: inapplicable
Aragon, X-DAO and Keep3r Network have no locking mechanism.
Snowball Finance and Spirit Swap: escrowed
Snowball Finance and Spirit Swap are using similary escrow contracts to lock tokens for a period of time. Both check transfers result in [→]:
assert ERC20(self.token).transferFrom(_addr, self, _value)
...
assert ERC20(self.token).transfer(msg.sender, value)
Attack #5. Small voting window
Users and veto-holders who are negatively inclined to some proposals may not have time to react. Especially if the quorum is less than 50%.
Aragon
A vote and an execution are open for a voteTime [→]:
function _isVoteOpen(Vote storage vote_) internal view returns (bool) {
    return getTimestamp64() < vote_.startDate.add(voteTime) && !vote_.executed;
}
This is a global parameter that is initialized once. Thus, the possibilities of social engineering depend on the initialization of a particular project.
X-DAO
A vote and an execution are open for 3 days [→]:
uint32 public constant VOTING_DURATION = 3 days;
...
require(
    _timestamp + VOTING_DURATION >= block.timestamp,
    "DAO: voting is over."
);
In our opinion, this should suffice for the active participants of the DAO to vote.
Snowball Finance
Snowball Finance has variable but limited time periods that can be set by the governance:

  • a voting period varies from 1 day to 30 days;
  • an execution delay varies from 30 seconds to 30 days;
  • an expiration period is 14 days.

In our opinion, this should suffice for the active participants of the DAO to vote.
Spirit Swap
Spirit Swap allows to vote once a week [→]:
uint256 public voteDelay = 604800;

...

modifier hasVoted(address voter) {
    uint256 time = block.timestamp - lastVote[voter];
    require(time > voteDelay, "You voted in the last 7 days.");
    _;
}
In our opinion, this should suffice for the active participants of the DAO to vote.
Keep3r Network
Keep3r Network has variable but limited time periods that can be set by the governance:

  • a voting period varies from 1 day to 30 days;
  • an execution period is 14 days.

In our opinion, this should suffice for the active participants of the DAO to vote.
Nexus Mutual
A proposal can be closed and executed after the _closingTime passes [→]:
function canCloseProposal(uint _proposalId)
...
if (numberOfMembers == proposalVoteTally[_proposalId].voters
  || dateUpdate.add(_closingTime) <= now)
  return 1;
It can be seen on the website that this parameter varies from 3 to 7 days depending on the proposal category:
https://app.nexusmutual.io/governance/categories

In our opinion, this should suffice for the active participants of the DAO to vote.
Attack #6. Double vote
Can a hacker vote twice for a proposal with the same tokens?

It is recommended checking the following scenarios:

  • vote → transfer → vote again;
  • vote → delegate → vote again;
  • mangle a vote() arguments to add extra voting power;
  • check for reentrancy.
Aragon
Vote-transfer-vote
It is possible to move tokens between users but only a block previous to a proposal creation matters so no one could double vote.
Vote-delegate-vote
There is no delegation mechanism in default Aragon contracts.
Mangle arguments
Nothing to mangle:
function vote(
    uint256 _voteId, 
    bool _supports, 
    bool _executesIfDecided
) external voteExists(_voteId)
Reentrancy
Voting can lead to an external call via _unsafeExecuteVote() but the code follows a check-effect-interaction pattern and thus invulnerable to reentrancy.
X-DAO
Vote-transfer-vote
It is possible to sign a vote and transfer tokens to another account so they can sign another vote too. But the execute() method counts only a final token distribution and thus the hacking scenario is inapplicable.
Other cases
There is no delegation mechanism and no on-chain vote() method so there is nothing to mangle or to try to re-enter.
Spirit Swap
Spirit Swap voting can only change token weights in the protocol and it applies changes in the same method which the user votes by.
Vote-transfer-vote
It is possible to vote, wait till the tokens are unlocked in the escrow contract, transfer the tokens to another account, lock them again, and vote for the same proposal. But calculations show that the total voting power applied would be the same if the tokens were simply locked for the entire time. No benefit.
Vote-delegate-vote
A user's voting power locked in the escrow contract cannot be delegated to another user.
Mangle arguments
The vote() method takes an array of tokens and weights so it is important to check if these arguments can be mangled in some way to add extra voting power to some tokens. What will happen if you pass an array with the same token twice?

It appears that the voting method takes into account all its argument correctly and passing the same tokens in an array does not affect the calculation correctness since all weights are divided by the total weight sum passed in the array [→]:
for (uint256 i = 0; i < _tokenCnt; i++) {
    _totalVoteWeight = _totalVoteWeight + _weights[i];
}
Reentrancy
The next thing to check is if there is a reentrancy. There is an external call in the vote() method [→]:
IBribe(bribes[gauges[_token]])._withdraw(...);
...
IBribe(bribes[_gauge])._deposit(uint256(_tokenWeight), _owner);
The IBribe contracts can be considered trusted, so even though there may be a vulnerability here, it does not pose a threat.
Snowball Finance
Vote-transfer-vote
It is possible to vote, wait till the tokens are unlocked in the escrow contract, transfer the tokens to another account, lock them again, and then vote for the same proposal. But calculations show that the total voting power applied would be the same if the tokens were simply locked for the entire time. No benefit.
Vote-delegate-vote
No delegation.
Mangle arguments
Nothing to mangle:
function vote(uint256 _proposalId, bool _support)
Reentrancy
There are no untrusted external calls in the vote() method.
Nexus Mutual
Vote-transfer-vote
This is impossible since the user's transfers are locked after each vote cast [→]:
function _setVoteTally(uint _proposalId, uint _solution, uint mrSequence) internal {
    ...
    tokenInstance.lockForMemberVote(msg.sender, tokenHoldingTime);
Vote-delegate-vote
Delegation is currently not allowed [→]:
function delegateVote(address _add) external isMemberAndcheckPause checkPendingRewards {
    revert("Delegations are not allowed.");
But even if there was no revert, there is another requirement,i.e. the user cannot delegate their voting power if they have cast the vote recently [→]:
if (allVotesByMember[msg.sender].length > 0) {    
    require((allVotes[allVotesByMember[msg.sender][allVotesByMember[msg.sender].length - 1]].dateAdd).add(tokenHoldingTime) < now);
}
Mangle arguments
Nothing to mangle:
function submitVote(uint _proposalId, uint _solutionChosen)
Reentrancy
The submitVote() method code follows the check-effect-interaction pattern so there is no reentrancy.
Keep3r Network
Vote-transfer-vote
Keep3r has a similar to Aragon behaviour and checks the user's voting power at one block before a proposal was created. Thus, the case is not applicable.
Mangle arguments
Nothing to mangle:
function castVote(uint256 proposalId, bool support)
Reentrancy
There is no untrusted external calls in the castVote() method.
Attack #7. Double execution
Is there a reentrancy in the execute() method? Can it be called twice in the same block?
Aragon, X-DAO, Nexus Mutual and Keep3r Network
Aragon, X-DAO and Keep3r Network use the check-effect-interaction pattern, so their execution methods are not vulnerable to reentrancy.
Snowball Finance
Snowball Finance implements the nonReentrant modifier and its execution method is also not vulnerable to reentrancy.
Spirit Swap
Spirit swap has no execution() method: it applies changes in the vote() method.
Conclusion
This is a basic list that is worth checking when testing a DAO with voting functionality. Feel free to use it!
Links
  1. Notes on Blockchain Governance
    https://vitalik.ca/general/2017/12/17/voting.html
  2. Blockchain voting is overrated among uninformed people but underrated among informed people
    https://vitalik.ca/general/2021/05/25/voting2.html
  3. Moving beyond coin voting governance
    https://vitalik.ca/general/2021/08/16/voting3.html
  4. On 6th October 2022, Mangata X was targeted by a governance attack which resulted in attackers gaining voting rights on the on-chain Council
    https://blog.mangata.finance/blog/2022-10-08-council-incident-report/
  5. On 17th April 2022, the perpetrator used a flash loan to exploit Beanstalk governance mechanism
    https://bean.money/blog/beanstalk-governance-exploit
  6. Tron Foundation CEO Justin Sun colluded with large crypto exchanges and used their customers' coins to vote for a takeover of the Steem network that the bulk of the community strongly opposed
    https://decrypt.co/38050/steem-steemit-tron-justin-sun-cryptocurrency-war
  7. Proof of Concept Vote Buying Contract for the CarbonVote Ethereum Blockchain Voting Implementation
    https://gitlab.com/relyt29/votebuying-carbonvote
  8. A uniswap pool to buy $TRIBE — a token that governs Fei Protocol
    https://info.uniswap.org/#/tokens/0xc7283b66eb1eb5fb86327f08e1b5816b0720212b
  9. How Much Does A Crypto-Vote Cost?
    https://www.placeholder.vc/blog/2020/1/7/how-much-does-a-crypto-vote-cost
  10. A Call for a Temporary Moratorium on The DAO
    (multiple game-theoretic vulnerabilities were found)
    https://hackingdistributed.com/2016/05/27/dao-call-for-moratorium/
  11. MakerDAO issues warning after a flash loan is used to pass a governance vote
    https://www.theblock.co/post/82721/makerdao-issues-warning-after-a-flash-loan-is-used-to-pass-a-governance-vote
  12. Technical Description of Critical Vulnerability in MakerDAO Governance
    https://blog.openzeppelin.com/makerdao-critical-vulnerability/
  13. KP3R Vulnerability Report: How Statemind Found A Two-Year-Old Exploit In Keep3r Network
    https://statemind.io/blog/2022/09/27/gauge-proxy-bug.html
  14. Nexus Mutual – calldata validation bug
    https://medium.com/nexus-mutual/responsible-vulnerability-disclosure-ece3fe3bcefa
  15. On April 4, 2021, the ForceDAO DeFi aggregator was exploited by one white-hat and four black-hat hackers. The malicious attackers were able to steal FORCE tokens.
    https://halborn.com/explained-the-forcedao-hack-april-2021/
Other posts