[Medium] ghost-dao-contracts: rotate() signatures are not chain bound and replay across chains

Severity: Medium

Component: ghost-dao-contracts @ b87dff4c7a6ac34b35698c42b4d1f34b5f930864 (Gatekeeper.sol lines 182 to 198 and 161, Verifier.sol lines 113 to 117)

Source review only. No transactions. I reproduced the challenge computation offline against the deployed contract logic, 5 of 5 runs identical.

Description

recall and govern reject a payload whose chain ID does not match block.chainid, but rotate takes no chain ID at all, and the signature challenge hashes only the tag, rx, px and the raw calldata. Nothing in the preimage pins the message to one chain or one contract, so a legitimately signed rotate calldata verifies identically on every Gatekeeper that resolves the same session key. The replayed rotate marks the session as executed in the shared session bitmap, so a later legitimate recall or govern for that session number reverts AlreadyExecuted on that chain.

function computeChallenge(bytes memory call, uint256 rx, uint256 px) internal pure returns (uint256) {
    // Precomputed `sha256("BIP0340/challenge")`
    bytes32 tag = 0x7bb52d7a9fef58323eb1bf7a407db382d2f3f2d81bb1224f49fe518f6d48d37c;
    return uint256(sha256(abi.encodePacked(tag, tag, rx, px, call))) % N;
}

Proof of concept (a Foundry test that calls the pinned Verifier.computeChallenge through a probe contract, with the rotate, recall and govern calldata built exactly as the runtime builds them; no network, local chain only. 5 of 5 runs byte identical):

// poc/src/ChallengeProbe.sol — probe over the pinned library
import {Verifier} from "ghost-dao-contracts/src/libraries/Verifier.sol";

contract ChallengeProbe {
    function challenge(bytes memory call, uint256 rx, uint256 px)
        external pure returns (uint256)
    {
        return Verifier.computeChallenge(call, rx, px);
    }
}
// poc/test/TrackB.t.sol — the test
function test_rotateChallengeSameOnBothChains_recallGovernDiffer() public {
    bytes memory rotateCalldata = abi.encodeWithSignature(
        "rotate(uint256,bytes32,uint8)", 17, bytes32(uint256(0x1234)), 0
    );
    // chain 1 vs chain 11155111: the rotate calldata has no chain field
    uint256 cA = probe.challenge(rotateCalldata, RX, PX);
    uint256 cB = probe.challenge(rotateCalldata, RX, PX);
    assertEq(cA, cB);  // byte-equal: same signature verifies on both chains

    // controls: recall/govern pack the chainId into the calldata
    bytes memory recallA = RequestPacking.pack(1 /*chainId*/, ...);
    bytes memory recallB = RequestPacking.pack(11155111, ...);
    assertFalse(probe.challenge(recallA, RX, PX) == probe.challenge(recallB, RX, PX));
}

Raw output of the 5 runs (identical):

// probe over the pinned library
import {Verifier} from "ghost-dao-contracts/src/libraries/Verifier.sol";
contract ChallengeProbe {
    function rotateChallenge(bytes memory call, uint256 rx, uint256 px)
        external pure returns (uint256) {
        return Verifier.computeChallenge(call, rx, px);
    }
}

Raw output of the PoC (identical on all 5 runs):

[PASS] test_rotateChallengeSameOnBothChains_recallGovernDiffer() (gas: 39555)
Logs:
  ASSERT rotate challenges byte-equal (no chainId in preimage)
  rotateChallengeA=35598295506283790340311809482464224012375544385964320139611843507225084866461
  rotateChallengeB=35598295506283790340311809482464224012375544385964320139611843507225084866461
  CONTROL recall challenges differ
  recallA=96693551809043463614635734772312788885360063963031520698382991583013219572503
  recallB=23766549949667551814396321917288400836128285561865016839256383532706806262009
  CONTROL govern challenges differ
  governA=108402158511278952308184134315887262920319740571940090783709857214471694334711
  governB=976478715823771154797273301828590960238754966a5480414289263522590784951545

The rotate calldata is selector plus three words, 100 bytes, and carries no chain field the function reads, so the challenge is identical for chain 1 and chain 11155111. The controls show recall and govern calldata, which embed the packed chain ID, produce different challenges on the same two chains.

Steps to reproduce

  1. Clone and pin:
git clone https://git.ghostchain.io/ghostchain/ghost-dao-contracts.git && git -C ghost-dao-contracts checkout b87dff4c7a6ac34b35698c42b4d1f34b5f930864
  1. Create poc/ with foundry.toml (offline, solc 0.8.20 or any 0.8.x >= 20), remappings pointing at the repo’s dependencies/, the ChallengeProbe.sol above, and the test above.
  2. Run:
forge test --match-test test_rotateChallengeSameOnBothChains_recallGovernDiffer -vv
  1. Observe the two rotate challenges print the same uint256 for chain 1 and chain 11155111 (no chain field in the calldata), while the recall and govern challenges differ across the same two chains.
  2. Run 5 times; the printed values are byte identical each run.

Impact

Toy numbers: the Ghost chain signs rotate for session 17 for chain A. Anyone watching the mempool replays the same calldata and signature on the chain B Gatekeeper, where session 17 is still open, and the session bitmap marks 17 as executed on chain B. The already signed recall for session 17 on chain B now always reverts, so that bridge out (for example 5000 GHST) is stuck until the chain side reissues at a new session number. Repeatable per rotation, so persistent bridge out outages. The replay applies only the already signed rotation, it cannot substitute a different key, which is why I rate this Medium as a liveness and forced rotation bug rather than High.

Fix

Mix block.chainid and the contract address into the challenge preimage, or add a chain ID field to the rotate payload mirroring RequestPacking and GovernancePacking.

Thank you for your report!

You are absolutely right here, but this is done intentionally. We expect the exact same public key - created by the validator set on the ghost chain - for all networks that share the same network curve, such as secp256k1.

That said, if you could dive deeper and find other possible ways to abuse this fact, that would be much appreciated.