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
- Clone and pin:
git clone https://git.ghostchain.io/ghostchain/ghost-dao-contracts.git && git -C ghost-dao-contracts checkout b87dff4c7a6ac34b35698c42b4d1f34b5f930864
- Create
poc/withfoundry.toml(offline, solc 0.8.20 or any 0.8.x >= 20), remappings pointing at the repo’sdependencies/, theChallengeProbe.solabove, and the test above. - Run:
forge test --match-test test_rotateChallengeSameOnBothChains_recallGovernDiffer -vv
- 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.
- 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.