Limited Voting Options Allow Ballot Creation Spam
mediumLines of code
https://github.com/code-423n4/2023-11-zetachain/blob/main/repos/node/x/crosschain/keeper/keeper_cross_chain_tx_vote_inbound_tx.go#L95 https://github.com/code-423n4/2023-11-zetachain/blob/main/repos/node/x/observer/keeper/msg_server_add_blame_vote.go#L39 https://github.com/code-423n4/2023-11-zetachain/blob/main/repos/node/x/observer/keeper/msg_server_add_block_header.go#L79
Vulnerability details
Impact
The message functions for certain types of voting allow only successful observation votes. This creates a problem if a compromised or faulty observer creates spam ballots with false observations. In this situation, honest observers have to spend resources processing the ballot but cannot cast an opposite vote to slash the spammer when the ballot reaches maturity.
Theoritically, a similar situation could happen with all ballots that do not reach the threshold. But in non-vulnerable cases, honest observer can cast an opposite vote to make the ballot reach threshold and be accounted for slashing. The real issue comes when honest observers cannot cast the honest opposite vote.
Proof of Concept
For certain types of votes, the only vote that is possible to be cast is an VoteType_SuccessObservation.
- https://github.com/code-423n4/2023-11-zetachain/blob/main/repos/node/x/crosschain/keeper/keeper_cross_chain_tx_vote_inbound_tx.go#L95
goballot, err = k.zetaObserverKeeper.AddVoteToBallot(ctx, ballot, msg.Creator, observerTypes.VoteType_SuccessObservation)
- https://github.com/code-423n4/2023-11-zetachain/blob/main/repos/node/x/observer/keeper/msg_server_add_blame_vote.go#L39
goballot, err = k.AddVoteToBallot(ctx, ballot, vote.Creator, types.VoteType_SuccessObservation)
- https://github.com/code-423n4/2023-11-zetachain/blob/main/repos/node/x/observer/keeper/msg_server_add_block_header.go#L79
goballot, err = k.AddVoteToBallot(ctx, ballot, msg.Creator, types.VoteType_SuccessObservation)
A compromised or faulty observer can then create ballots for spam observations that do not exist, and honest observers can never vote for the opposite option. Since the ballot's threshold will never be reached, the ballot will remain with the status BallotStatus_BallotInProgress.
Eventually, the ballot will be processed when the DistributeObserverRewards function gathers the list of matured ballots.
- https://github.com/code-423n4/2023-11-zetachain/blob/main/repos/node/x/emissions/abci.go#L63
goballotIdentifiers := keeper.GetObserverKeeper().GetMaturedBallotList(ctx)
But, since BuildRewardsDistribution does not account for ballots with status BallotStatus_BallotInProgress, the ballot spammer will have a totalRewardUnits == 0 instead of a negative value. This way, no slashing will be executed as a punishment for the false observation, but observers will have to process the spam observation ballot anyway.
- https://github.com/code-423n4/2023-11-zetachain/blob/main/repos/node/x/observer/types/ballot.go#L77-L102
gofunc (m Ballot) BuildRewardsDistribution(rewardsMap map[string]int64) int64 { totalRewardUnits := int64(0) switch m.BallotStatus { case BallotStatus_BallotFinalized_SuccessObservation: // ... SNIP ... case BallotStatus_BallotFinalized_FailureObservation: // ... SNIP ... return totalRewardUnits
The compromised observer is then free to create as many false observation ballots as he wishes, effectively spamming the network and draining honest observer's resources without a punishment.
Additional Note
Besides wasting network participant's execution resources, the ballots are never deleted from storage after the distribution. But there is a comment evidencing this is an obviously known issue and, thus, included as merely a reminder in this report.
- https://github.com/code-423n4/2023-11-zetachain/blob/main/repos/node/x/emissions/abci.go#L127C1-L128C50
go// TODO : Delete Ballots after distribution // https://github.com/zeta-chain/node/issues/942
Tools Used
Manual: code editor.
Recommended Mitigation Steps
Similarly to what happens in other types of votes, allow honest observers to cast a negative observation vote if they wish. Thus, when the ballot reaches maturity and is processed for reward distribution, the dishonest or faulty observer is slashed to pay for any resources spent with the false observation.
This is how msg_tss_voter.go and keeper_cross_chain_tx_vote_outbound_tx.go work:
- https://github.com/code-423n4/2023-11-zetachain/blob/main/repos/node/x/crosschain/keeper/msg_tss_voter.go#L68C1-L78C3
goif msg.Status == common.ReceiveStatus_Success { ballot, err = k.zetaObserverKeeper.AddVoteToBallot(ctx, ballot, msg.Creator, observerTypes.VoteType_SuccessObservation) if err != nil { return &types.MsgCreateTSSVoterResponse{}, err } } else if msg.Status == common.ReceiveStatus_Failed { ballot, err = k.zetaObserverKeeper.AddVoteToBallot(ctx, ballot, msg.Creator, observerTypes.VoteType_FailureObservation) if err != nil { return &types.MsgCreateTSSVoterResponse{}, err } }
- https://github.com/code-423n4/2023-11-zetachain/blob/main/repos/node/x/crosschain/keeper/keeper_cross_chain_tx_vote_outbound_tx.go#L105C1-L108C3
goballot, err = k.zetaObserverKeeper.AddVoteToBallot(ctx, ballot, msg.Creator, observerTypes.ConvertReceiveStatusToVoteType(msg.Status)) if err != nil { return nil, err }
Special attention must be give to ensure the corrected voting type's Digest function output is generalised for all types of votes. Similarly to what is done with MsgVoteOnObservedOutboundTx.
- https://github.com/code-423n4/2023-11-zetachain/blob/main/repos/node/x/crosschain/types/message_vote_on_observed_outbound_tx.go#L76C1-L86C2
gofunc (msg *MsgVoteOnObservedOutboundTx) Digest() string { // ... SNIP ... // Set status to ReceiveStatus_Created to make sure both successful and failed votes are added to the same ballot m.Status = common.ReceiveStatus_Created // ... SNIP ...
Assessed type
DoS
