Early rate$2,400 of senior audit time for $500. Early members keep the rate as it climbs.$2,400 of senior audit time for $500See how →
F-2026-0022·signature-replay

Relayed ballot signatures carry no round or nonce binding

Fixedescrowarbitrationdispute-resolution
TL;DR

Relayed ballot signatures carry no round, nonce, pool address or expiry, and a quorum failure re-queues the same case ID. A ballot signed for the failed round can be replayed into the next one, casting a vote the juror never authorised.

Severity
MEDIUM
Impact
MEDIUM
Likelihood
MEDIUM
Method
MManual review
CAT.
Complexity
MEDIUM
Exploitability
MEDIUM
02Section · Description

Description

The gasless envelopes omit the pool address, the panel-seating epoch, a nonce and an expiry:

solidity
keccak256(abi.encodePacked("COMMIT_VOTE", caseId, commitHash, block.chainid));
keccak256(abi.encodePacked("REVEAL_VOTE", caseId, uint8(vote), splitBps, salt, block.chainid));

The commit preimage checked at reveal is less bound still — keccak256(vote, splitBps, salt, juror) and keccak256(vote, salt, juror). Neither carries a caseId, a round, nor a nonce.

The round-zero quorum-failure branch clears the consumed ballot slots (#L759-L761) and re-queues the same caseId (#L787), so a genuine signature from the failed panel reconstructs to a byte-identical hash and is accepted again if that juror is re-seated. Re-draw is permitted: the branch also clears wasOnPriorPanel[caseId][j], and wasOnPriorPanel is only ever set for appealRound == 0, so appeal jurors are never marked at all.

The signature check cannot detect this — the signature is genuine, from the same juror, over an identical hash. Once replayed, the juror cannot correct the ballot: AlreadyCommitted and AlreadyRevealed then block them.

03Section · Impact

Impact

The party disfavoured by the replayed ballot loses escrowed cNGN under a ruling the juror never authorised for that round — up to the entire disputed balance. If the replayed ballot dissents from the resulting ruling, the impersonated juror additionally takes the capped stake slash for a vote they did not cast. The actor is the configured relayer: a semi-trusted role authorised to forward ballots, not to choose their context.

04Section · Recommendation

Recommendation

Introduce a persistent monotonic ballot epoch per caseId, incremented on every panel seating including quorum-failure re-queues and appeal retries. It must survive delete cases[caseId] (#L784), so it cannot live in the Case struct. Bind pool address, caseId, epoch, juror, action, the complete ballot payload, a nonce and an expiry into both signatures via EIP-712 typed data carrying chainId and verifyingContract. Bind the same epoch and caseId into the commit preimage, not only the outer signature — otherwise the commit/reveal pair still replays as a unit.

Do not implement this with Escrow.signatureNonce. That counter is episode-scoped and _enterDispute is seller-reachable, so reusing it would invalidate every outstanding buyer authorisation.

05Section · Resolution

Resolution

Fixed. Relayed ballots are EIP-712 typed and bind the pool domain, caseId, ballot epoch, juror, payload, a split commit/reveal nonce and an expiry. A round-zero signature replayed after a quorum-failure re-queue is now rejected, and the epoch advances with every seating.

06Section · Affected files

Affected files

  • ArbitrationPool.sol#L678-L684, #L691-L699, #L759-L761, #L787, #L1171, #L1176
Status
Fixed
F-2026-0022