Severity (proposed): High
Rationale: exploit requires a Byzantine threshold coalition of validators that were active at some point in the chain’s history (currently 4-of-5 on Casper Staging testnet), not a single permissionless user. However, the trust assumption silently expands over time: the set of validators capable of forging bridge-authorizing signatures grows monotonically, because FROST key shares are never invalidated after a validator is rotated out, and the EVM-side checkpoint history never expires. This is a design-level gap in a live bridge, not a corner case.
Affected component:
ghost-node/pallets/exodus(Substrate DKG + offchain worker)ghost-dao-contracts/src/Gatekeeper.sol(EVM verification + rotation)ghost-dao-contracts/src/libraries/Verifier.sol(signature challenge)ghost-dao-contracts/src/types/StorageHistory.sol(per-chain replay bitmap)
Program reference (Known Attack Vectors #9):
“Aggressive DKG Authority Rotations: Signing authority handoffs in the final Distributed Key Generation (DKG) rounds performed by outgoing validator sets.”
This report documents a concrete, code-level realization of that vector which the current implementation does not mitigate.
Summary (conceptual, per responsible-disclosure note below)
The cross-chain signing authority of the GHOST bridge is a FROST threshold signature committee. The EVM Gatekeeper contract verifies signatures against an on-chain checkpoint history of aggregated public keys (_aggregatedPublicKeys, an OpenZeppelin Checkpoints.Trace256).
Two independent gaps combine:
-
(Substrate) FROST key shares held by validators who were rotated out of the active committee are never cryptographically invalidated and are never wiped from node-local storage. The number of validators holding still-valid shares against some historical aggregated key grows by design every era (24h) as the validator set churns.
-
(EVM) The Gatekeeper accepts a caller-chosen
exodusSessionparameter and resolves it against the FULL historical checkpoint table viaupperLookup(). Old checkpoints are never pruned, so a signature built against a historical aggregated keyA_iremains verifiable forever on any chain that still storesA_i’s checkpoint, regardless of how up-to-date that chain’s current key is.
Additionally, the signature challenge hash binds neither the chain id nor the contract address, and Gatekeeper.rotate() — unlike recall() and govern() — performs no chainId check on its payload. A forged or replayed rotation/authorization message is therefore valid on every chain sharing the same aggregated key, not just the one it was produced for.
Responsible disclosure note (request to triage): We have intentionally NOT included the step-by-step forge recipe (construction of the challenge preimage, session-number selection strategy, and verify() chaining order) in this public post, because the forum is a public channel with no private submission alternative. We are ready to provide the full reproducible PoC immediately upon request — please reply in this topic or contact us with a private channel. Until then, the code citations below are sufficient to validate the mechanism without enabling third parties.
Evidence — verified by direct source inspection (file:line)
All line numbers refer to:
ghost-dao-contractsHEADb87dff4c7a6ac34b35698c42b4d1f34b5f930864ghost-nodeHEAD0162da4604d9931c16d12a0cec40dd85951368c5
E1. Aggregated FROST pubkey is SHARED per curve across all EVM networks
ghost-node / pallets/exodus/src/lib.rs:773-778
#[pallet::storage]
#[pallet::getter(fn active_verifying_key)]
pub(super) type ActiveVerifyingKey<T: Config> = StorageMap<
_,
Twox64Concat, NetworkCurve,
BoundedVec<u8, ConstU32<ELEMENT_MAX_BYTES>>,
ValueQuery,
>;
One aggregated verifying key per NetworkCurve (Secp256k1 default, pallets/helpers/src/networks.rs:85-89), not per network/chain. All EVM chains registered with the same curve verify against the same key material.
ghost-node / pallets/networks/src/lib.rs:785-794 — per-block round-robin assignment of which EVM network the offchain worker serves:
fn network_for_block(block_number: impl Into<usize>)
-> Option<(Self::NetworkId, NetworkData)> {
let network_indexes = NetworkIndexes::<T>::get();
block_number
.into()
.checked_rem(network_indexes.len())
...
E2. exodusSession is a single GLOBAL counter, shared across chains
ghost-node / pallets/exodus/src/lib.rs:552-553
pub(super) type CurrentExodus<T: Config> =
StorageValue<_, ExodusSession, ValueQuery>;
StorageValue (not a map) — one counter for all networks. Session N on chain A and session N on chain B refer to the same logical protocol step, and are consumed sequentially from the same counter (lib.rs:2165-2169, do_register_evm_exodus).
E3. No proactive refresh / share invalidation anywhere in the pallet
Grep across pallets/exodus/src/ for proactive|reshare|refresh|key destruction: zero hits. On DKG finalization the pallet only OVERWRITES the aggregated key and authority set:
ghost-node / pallets/exodus/src/lib.rs:2640-2642
ActiveDkgAuthorities::<T>::insert(&network_curve, activated_state);
ActiveVerifyingKey::<T>::insert(&network_curve, verifying_key);
ActiveAuthorities::<T>::set(&network_curve, qualified_authorities);
Old shares live in validator-local OFFCHAIN storage (pallets/exodus/src/types/local_storage.rs:217, StorageValueRef::persistent) and are only cleared by force_clear_local_storage() (local_storage.rs:191-208), which is called from exactly ONE site:
ghost-node / pallets/exodus/src/lib.rs:3453-3456
} else {
if is_requests_empty {
exodus_storage.force_clear_local_storage();
This only runs inside exodus_run_process for validators that are still in ActiveAuthorities and still processing the request queue. A rotated-OUT validator never re-enters exodus_start, so its historical shares are never cleared. The clear path is unreachable for exactly the cohort whose shares this report is about.
E4. on_disabled is an acknowledged no-op
ghost-node / pallets/exodus/src/lib.rs
fn on_disabled(_validator_index: u32) {
// NOTE: what should we do if validator is disabled?
// It seems like nothing... Because disabling is not permanent
// but temporary at max for one epoch. But it's not final
// solution.
}
The developers’ own comment acknowledges this is an unfinished area.
E5. Rotation cadence is routine: one era = 24 hours
ghost-node / runtime/casper/src/lib.rs:472
pub const SessionsPerEra: sp_staking::SessionIndex = prod_or_fast!(6, 1);
ghost-node / runtime/casper/constants/src/lib.rs
pub const MILLISECS_PER_BLOCK: Moment = 12_000;
pub const EPOCH_DURATION_IN_SLOTS: BlockNumber =
prod_or_fast!(4 * HOURS, 1 * MINUTES);
6 sessions/era x 4h = 24h per era. Confirmed operationally by the project’s own documentation (https://blog.ghostchain.io/become-ghost-validator/):
“the actual Validator status can take up to 48 hours to obtain since it will take 2 eras for everything to finalize and each era is 24 hours”
“Ever since GHOST TestNet 3.1 new validators can be on boarded on-chain.”
Validator-set churn is therefore continuous and by design. Every era in which at least one validator is replaced adds that validator to the cohort of retired-but-still-valid share holders (per E3), with no cap.
E6. Threshold is 4-of-5 on Casper Staging testnet
ghost-node / pallets/helpers/src/lib.rs:37-48
pub fn get_byzantium_quorum_threshold<I>(value: I) -> I ... {
let f = (value - one) / three;
value - f
}
ghost-node / runtime/casper/src/genesis_config_presets.rs:178+ — casper_staging_initial_authorities() returns exactly 5 authorities. t = 5 - (5-1)/3 = 4.
We deliberately do not list the individual validator identities; they are public in the genesis preset and are not the point of this report.
E7. EVM: signature challenge binds neither chainId nor contract address
ghost-dao-contracts / src/libraries/Verifier.sol:113-117
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;
}
The hash input is exactly (tag, tag, rx, px, call). There is no block.chainid, no address(this), no nonce, no EIP-712 domain separator. The same signature is therefore valid on any chain whose Gatekeeper stores the same px for the referenced session — which, per E1/E2, is all of them sharing the curve.
The only chain-binding that exists lives inside recall() and govern() payload unpacking, NOT in the signature itself:
// src/Gatekeeper.sol:161 (recall)
if (payload.chainId != block.chainid) revert WrongChainId();
// src/Gatekeeper.sol:208 (govern)
if (payload.chainId != block.chainid) revert WrongChainId();
src/Gatekeeper.sol:182-198 (rotate) — NO chainId check at all:
function rotate(
uint256 exodusSession,
bytes32 newPublicKey,
uint8 newParity
) external {
if (msg.sender != address(this)) revert NotGatekeeper();
if (newParity % 2 != 0) revert InvalidPublicKey();
StorageHistory(storageHistory)
.trySetTransactionExecuted(exodusSession);
uint256 packedRotationState =
RotationPacking.pack(newParity, uint64(exodusSession));
_aggregatedPublicKeys.push(exodusSession, uint256(newPublicKey));
_packedRotationStates[newPublicKey] = packedRotationState;
emit Rotated(newPublicKey, newParity);
}
The inconsistency between the three self-call dispatchers (2 of 3 have the check, rotate does not) suggests the packed-payload chainId guard was applied to recall/govern and rotate was either considered internal-only or overlooked. We flag it both as a defense-in-depth gap and as the cross-chain amplifier for finding (1)+(2) above.
E8. EVM: caller-chosen session resolves against permanent history
ghost-dao-contracts / src/Gatekeeper.sol:216-226
function _extractPublicKey(bytes calldata call)
internal view returns (uint256) {
if (call.length < 36) revert InvalidCalldata();
uint256 exodusSession;
assembly {
exodusSession := calldataload(add(call.offset, 4))
}
(bytes32 publicKey,,) = getRotationInfoAt(exodusSession);
return uint256(publicKey);
}
ghost-dao-contracts / src/Gatekeeper.sol:99-109
function getRotationInfoAt(uint256 exodusSession)
public override view returns (bytes32, uint8, uint64) {
bytes32 publicKey =
bytes32(_aggregatedPublicKeys.upperLookup(exodusSession));
...
if (exodusSession < state.session) {
return IGatekeeper(_previousAddress)
.getRotationInfoAt(exodusSession);
}
return (publicKey, state.parity, state.session);
}
_aggregatedPublicKeys is an OpenZeppelin Checkpoints.Trace256 — push-only, never pruned. upperLookup(N) returns the value of the highest checkpoint with key <= N; for a chain that has accumulated one checkpoint per DKG rotation, ANY historical session number resolves to the aggregated key that was active at that time. The verifier therefore happily accepts a signature made under A_i for a call citing session S where S predates the current key A_j — forever.
Note the precise semantics for a freshly-deployed Gatekeeper whose history contains a single checkpoint at session S0: upperLookup(N) for N < S0 finds NO checkpoint with key <= N and returns 0 (the empty default) — it does NOT fall back to the current key. That case is safe by accident: px = 0 then fails inside verifySpectre (src/libraries/Verifier.sol:39-42, sp = N - s*px mod N = 0 -> reject). The safety is an artifact of an empty history, not a designed guard.
E9. Redeployed Gatekeepers inherit the exposure via the predecessor chain
ghost-dao-contracts / src/Gatekeeper.sol:50-68
function initialize(address _previousGatekeeperAddress) external
override {
require(msg.sender == staking);
if (_previousGatekeeperAddress != address(0)) {
...
storageHistory = previousStorage;
deployer = IGatekeeper(_previousGatekeeperAddress).deployer();
Weaver._initialize(_previousGatekeeperAddress);
_previousAddress = _previousGatekeeperAddress;
}
_initialized = true;
}
and the fallback at Gatekeeper.sol:104-105 delegates historical session lookups to the predecessor contract, whose own Trace256 still contains the full A1.. history. A “fresh” Gatekeeper deployed on a long-running chain via this linked-list pattern (PREVIOUS_STAKING_ADDRESS in .env.template, added in commit 3f4e6ff “apply more strict rules for gatekeeper deployment”, 2026-09-24) is therefore NOT protected: it inherits the predecessor’s full historical exposure transparently.
The Gatekeeper constructor has changed multiple times across the file’s history — for example, commit 3f4e6ff (2026-09-24, “apply more strict rules for gatekeeper deployment”) introduced the PREVIOUS_STAKING_ADDRESS deployment configuration for linked-list redeploy initialization referenced above. Repeated redeploys are therefore a plausible operational pattern. We did not find on-chain proof (no broadcast/ artifacts are committed) that a redeploy has already occurred on the live staging chain, so we treat the redeploy path as latent risk rather than confirmed exposure.
E10. Per-chain replay guard is a bare bitmap, keyed by session only
ghost-dao-contracts / src/types/StorageHistory.sol:14, 39-43
BitMaps.BitMap private _executedTransaction;
...
function trySetTransactionExecuted(uint256 session) external
override {
if (msg.sender != _currentOwner) revert NotOwner();
if (_executedTransaction.get(session)) revert AlreadyExecuted();
_executedTransaction.set(session);
}
The bitmap is per-chain (each chain deploys its own StorageHistory) and keyed by session number only — no binding to (rx, px, call) and no sequential/monotonic check. A session never used on a given chain is accepted there, whatever its age and whatever key epoch it cites.
Combined Impact
For any chain that (a) has accumulated >= 2 rotation checkpoints, or (b) is linked to a predecessor Gatekeeper that has:
- A coalition of t-of-n validators from ANY historical epoch (not the current one) can produce signatures that the Gatekeeper accepts, indefinitely, with no time bound and no session bound.
- Because of E7, a message authored for one chain is valid on every chain sharing the curve’s aggregated key, modulo the bare per-chain session bitmap (E10).
rotate()additionally lacks even the payload chainId check the other two dispatchers have. - The cohort of signers holding still-valid shares grows every 24h era (E5 + E3), so the attack surface grows monotonically over the life of the chain. The purpose of validator rotation — shrinking the set of trusted parties — is inverted.
Affected fund flows: recall() (bridge-out fund release, including the EOA bounty path), govern() (arbitrary target.call as the protocol), rotate() (replacing the trusted aggregated pubkey itself).
Recommended Mitigations
A. Proactive share refresh (primary fix, Substrate side): on every DKG finalization, run a reshare/proactive-secret-sharing round that cryptographically invalidates all previous-epoch shares, so retired validators cannot reconstruct signatures under old aggregated keys.
B. Wipe retired validators’ local shares: call force_clear_local_storage() (or an equivalent) for any authority leaving ActiveAuthorities during on_new_session/on_disabled, instead of relying on the empty-queue path that rotated-out validators never reach.
C. Bind the signature to the chain: include block.chainid and address(this) in the challenge (or move to an EIP-712 style domain), so a signature is only ever valid on its home chain.
D. Enforce a session freshness window in the EVM Gatekeeper: track the latest applied session and reject calls citing exodusSession older than a bounded window; this also closes the predecessor-inheritance path if the window is enforced on absolute session numbers.
E. Bound the checkpoint history: either prune _aggregatedPublicKeys entries older than the freshness window, or reject lookups that resolve to a checkpoint older than the window (upperLookup must not silently serve epoch-old keys).
F. Add the missing chainId check to rotate(), symmetric with recall() and govern().
What We Are Withholding, and Why
Per the responsible-disclosure note above: the concrete forge recipe (challenge preimage construction, session selection against a live checkpoint table, and verify() chaining) is withheld from this public post. The code citations above are complete enough for triage to reproduce every claim by inspection. The full PoC (Foundry, local fork, no broadcast to public networks) is ready and will be shared the moment a private channel or explicit go-ahead is provided in this topic.
Validation Status
The core claims are not inspection-only: they were validated by executable Foundry tests against HEAD b87dff4 of ghost-dao-contracts (local anvil, no broadcast):
- V1 —
getRotationInfoAt(10)resolves to key A1 after a legitimaterotate(500, A2)— a fully up-to-date chain still serves the old epoch’s key for historical sessions. (Evidence E8.) - V2b — On a chain first bootstrapped at session 100,
getRotationInfoAt(10)returnsbytes32(0)— no fallback to the current key; the empty default then fails insideverifySpectre. (Mechanism note under E8.) - V3 —
rotate()applies a payload with no chain binding (verified vialatestPublicKeyInfo), whilerecall()/govern()with a wrong-chain packed payload revertWrongChainId. (Evidence E7.) - V4 —
StorageHistory.trySetTransactionExecutedaccepts non-contiguous, out-of-order sessions (42, 10000, 1); only double-setting the same session revertsAlreadyExecuted. (Evidence E10.)
All four tests pass (4/4). The repository’s own test suite also passes at this HEAD (214 passed of 217 total; the only failures are three test_mainnet_* cases that require a mainnet fork RPC and are unrelated to this report). The threshold (4-of-5) and era cadence (24h) figures were independently recomputed from the formulas in pallets/helpers and runtime/casper constants.
The tests in full, so triage can audit exactly what is and is not exercised (note: they deliberately do NOT exercise Schnorr forging — the withheld recipe — only downstream storage/lookup/dispatch semantics; rotate()/recall()/govern() are invoked in the production self-call context via vm.prank(address(gatekeeper)), which satisfies the same require(msg.sender == address(this)) guard that verify() satisfies via address(this).call(call) at Gatekeeper.sol:140):
// test/validation/GatekeeperValidation.t.sol (solc 0.8.20, forge-std 1.9.2)
// fixture keys are format-valid dummies, not tied to any real key
contract GatekeeperValidationTest is Test {
Gatekeeper gatekeeper;
StorageHistory history;
bytes32 constant KEY_A1 = bytes32(uint256(0xAAAA0001));
bytes32 constant KEY_A2 = bytes32(uint256(0xAAAA0002));
function setUp() public {
history = new StorageHistory();
gatekeeper = new Gatekeeper(address(history), address(this));
gatekeeper.initialize(address(0));
history.setOwner(address(gatekeeper));
// deployer == tx.origin is set in the Gatekeeper constructor,
// so the bootstrap caller must be an EOA (same as repo fixtures)
vm.prank(tx.origin);
gatekeeper.updatePublicKeyMetadata(0, KEY_A1, 0);
}
// V1 (Evidence E8): after a NEWER rotation, a HISTORICAL session
// still resolves to the OLD key via upperLookup.
function test_V1_historicalSessionResolvesToOldKey() public {
(bytes32 k,, uint64 s) = gatekeeper.getRotationInfoAt(0);
assertEq(k, KEY_A1);
assertEq(s, 0);
vm.prank(address(gatekeeper));
gatekeeper.rotate(500, KEY_A2, 0); // legitimate newer rotation
// checkpoints now {0: A1, 500: A2}: session 10 and 499 resolve
// to A1 even though the chain's current key is A2
(bytes32 k10,, ) = gatekeeper.getRotationInfoAt(10);
assertEq(k10, KEY_A1, "historical session did not resolve to old key");
(bytes32 k499,, ) = gatekeeper.getRotationInfoAt(499);
assertEq(k499, KEY_A1);
(bytes32 k500,, ) = gatekeeper.getRotationInfoAt(500);
assertEq(k500, KEY_A2); // sanity
(bytes32 k1000,, ) = gatekeeper.getRotationInfoAt(1000);
assertEq(k1000, KEY_A2); // sanity
(bytes32 latest,, uint64 latestSession) = gatekeeper.latestPublicKeyInfo();
assertEq(latest, KEY_A2); // chain is fully up to date
assertEq(latestSession, 500);
}
}
contract GatekeeperFreshBootstrapValidationTest is Test {
Gatekeeper gatekeeper;
StorageHistory history;
bytes32 constant KEY_CUR = bytes32(uint256(0xBBBB0001));
function setUp() public {
history = new StorageHistory();
gatekeeper = new Gatekeeper(address(history), address(this));
gatekeeper.initialize(address(0));
history.setOwner(address(gatekeeper));
// NEW chain bootstrapped LATE: first checkpoint at session 100
vm.prank(tx.origin);
gatekeeper.updatePublicKeyMetadata(100, KEY_CUR, 0);
}
// V2b (mechanism note under E8): sessions predating the chain's
// first checkpoint resolve to ZERO — not to the current key.
function test_V2b_noCheckpointFoundBelowFirstSession() public view {
(bytes32 k,,) = gatekeeper.getRotationInfoAt(10);
assertEq(uint256(k), 0, "expected zero for pre-history session");
(bytes32 k99,,) = gatekeeper.getRotationInfoAt(99);
assertEq(uint256(k99), 0);
(bytes32 k100,,) = gatekeeper.getRotationInfoAt(100);
assertEq(k100, KEY_CUR); // sanity
(bytes32 k5000,,) = gatekeeper.getRotationInfoAt(5000);
assertEq(k5000, KEY_CUR); // sanity
}
// V3 (Evidence E7): rotate() has NO chainId check — recall() and
// govern() revert WrongChainId on a wrong-chain packed payload.
function test_V3_rotateAcceptsAnyChainPayloadWhileRecallGovernReject() public {
// rotate: no payload, no chainId anywhere — applied verbatim
vm.prank(address(gatekeeper));
gatekeeper.rotate(777, bytes32(uint256(0xCCCC0001)), 0);
(bytes32 rotatedKey,, uint64 rotatedSession) = gatekeeper.latestPublicKeyInfo();
assertEq(rotatedKey, bytes32(uint256(0xCCCC0001)));
assertEq(rotatedSession, 777);
// recall: wrong-chain payload reverts
uint256 wrongChainPacked = RequestPacking.pack(
0, uint64(block.chainid + 1), address(0xBEEF)
);
vm.prank(address(gatekeeper));
vm.expectRevert(IGatekeeper.WrongChainId.selector);
gatekeeper.recall(1, 1e18, wrongChainPacked);
// govern: same chainId guard
uint256 wrongGovPacked = GovernancePacking.pack(
uint64(block.chainid + 1), address(0xCAFE)
);
vm.prank(address(gatekeeper));
vm.expectRevert(IGatekeeper.WrongChainId.selector);
gatekeeper.govern(2, wrongGovPacked, hex"");
}
// V4 (Evidence E10): bitmap is session-keyed only — non-contiguous,
// out-of-order sessions accepted; only double-set of the SAME
// session reverts AlreadyExecuted.
function test_V4_bitmapAcceptsNonContiguousSessions() public {
vm.startPrank(address(gatekeeper));
history.trySetTransactionExecuted(42);
history.trySetTransactionExecuted(10_000);
history.trySetTransactionExecuted(1);
assertTrue(history.isTransactionExecuted(42));
assertTrue(history.isTransactionExecuted(10_000));
assertTrue(history.isTransactionExecuted(1));
assertFalse(history.isTransactionExecuted(2));
assertFalse(history.isTransactionExecuted(999));
vm.expectRevert(IGatekeeper.AlreadyExecuted.selector);
history.trySetTransactionExecuted(42);
vm.stopPrank();
}
}
(Both error selectors referenced — WrongChainId and AlreadyExecuted — are declared in src/interfaces/IGatekeeper.sol and thrown from inside StorageHistory.sol; the selectors match by signature regardless of which interface declares them.)
No state-changing calls were made against any GHOST deployment during this research; all findings derive from public source inspection of git.ghostchain.io/ghostchain (ghost-node, ghost-dao-contracts), public documentation, and local-only test execution.
— Report ends —