[High] Lack of key-epoch invalidation across DKG rotations grants indefinite signing power to retired validator coalitions; missing chainId binding in `Gatekeeper.rotate()` amplifies cross-chain impact

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:

  1. (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.

  2. (EVM) The Gatekeeper accepts a caller-chosen exodusSession parameter and resolves it against the FULL historical checkpoint table via upperLookup(). Old checkpoints are never pruned, so a signature built against a historical aggregated key A_i remains verifiable forever on any chain that still stores A_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-contracts HEAD b87dff4c7a6ac34b35698c42b4d1f34b5f930864
  • ghost-node HEAD 0162da4604d9931c16d12a0cec40dd85951368c5

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 legitimate rotate(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) returns bytes32(0) — no fallback to the current key; the empty default then fails inside verifySpectre. (Mechanism note under E8.)
  • V3 — rotate() applies a payload with no chain binding (verified via latestPublicKeyInfo), while recall()/govern() with a wrong-chain packed payload revert WrongChainId. (Evidence E7.)
  • V4 — StorageHistory.trySetTransactionExecuted accepts non-contiguous, out-of-order sessions (42, 10000, 1); only double-setting the same session reverts AlreadyExecuted. (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 —

Many thanks for your report!

Because it is a pretty big one and you are touching different parts of the protocol, I’ll try to answer them one by one as you presented them. So let’s go:

E1

That was done intentionally because we do not want to run practically the same DKG for each network if they are using exact same network curve in order to minimize computations on the validators’ side.

E2

Here you are absolutely correct and it serves as a sort of nonce for the transaction that will be signed by DKG participants.

E3

The reason why it is not forcefully cleared is because of the storage type of it:

#[pallet::storage]
    #[pallet::getter(fn verifying_shares)]
    pub(super) type VerifyingShares<T: Config> = StorageNMap<
        _,
        (
        	NMapKey<Twox64Concat, NetworkCurve>,
        	NMapKey<Twox64Concat, DkgIndex>,
        	NMapKey<Twox64Concat, AuthIndex>,
		),
        BoundedVec<u8, ConstU32<ELEMENT_MAX_BYTES>>,
        OptionQuery,
    >;

This is a known limitation. Clearing is non-trivial for two reasons: (1) the previous set must remain readable until the new DKG succeeds, and (2) a bulk delete is disk-intensive. A local clear does happen via dkg_run_reset when the node runs “as is”, you can find it here. Ideas on safely pruning the old set are welcome.

E4

Yeap! That is more about community discussion of that. If you have any ideas on how to deal with disabling you are welocome. The main question do we really need to do something about that, because on_disabled will be fired only from babe and grandpa.

E5

Yeap, that make sense.

E6

Bootstrap nodes are needed only for the start of the network. Actually their keys are pretty well-known and publicly availiable on the config presets.

E7

You are absolutely right here. Here is the catch: the chainId is already part of the message which is signed, thus the check is already included inside. But you are right about rotate - there is no check for the chain id because it is not needed, same exact message could be used for any network that is relying on the secp256k1 for example without any issues (keep in mind it can come ONLY as a message with signature from previous set attached).

E8

Can you explain more in-depth this one. You’ve spot on the idea about default px to be zero (which should not exist on curve y^2 = x^3 + 7 (mod p) = 7 (mod p), thus it will try to verify any signature for the point that does not exist.

E9

Good catch here! This is for real, the contructor has changed multiple times and we now believe it should be solid enough. The history should be used by the new gatekeeper in order to avoid situations where user forget to execute tx for somt time but the gatekeeper chagned.

E10

Correct!

About Combined Impact

From what I see you are explaining the historical type of attack, when BFT number of participants can jump into the gap in exodusSession that will be valid? But if the BFT number of any participant set is taken by malicious users they can in any case run anything they want and they do not need to do it historically, for example they can block all upcoming rotations and do faky recalls.

My Own Conclusion

Actually that is an interesting vector of thoughts, IMHO potential candidate for the elevation to the next level. If you can narrow your idea of the abuse and how it can be real please continue this thread!

Follow-up: narrowing the abuse scenario (E1–E10 responses by VoidDragon)

Thank you for the detailed per-point response — this is exactly the kind of engagement that makes triage productive. You asked us to “narrow the idea of the abuse and how it can be real.” Below we (1) address the Combined-Impact objection, (2) correct one factual point in the E3 response with fresh source evidence from the same HEAD, and (3) present the narrowed attack path end-to-end.

=====================================================================

  1. The Combined-Impact objection conflates two different trust sets

    Your objection: “if the BFT number of any participant set is taken by malicious users they can in any case run anything they want and they do not need to do it historically — they can block all upcoming rotations and do fake recalls.”

This is true — but only for a coalition of the CURRENTLY ACTIVE committee. The two trust sets are not interchangeable:

Set A — currently active validators: hold shares of the CURRENT aggregated key. Their baseline capabilities (fake recalls, blocking rotations) are the ordinary risks of any threshold committee, independent of this report.

Set B — RETIRED validators (our finding): hold shares only of HISTORICAL aggregated keys A_i (i < current). Against a correctly-designed system they hold NO signing power at all — that is the entire purpose of rotation. The finding is that the Gatekeeper hands their power back: caller-chosen exodusSession + unpruned upperLookup history (E8) means a signature forged under A_i verifies forever.

The capability sets differ concretely:

  • Set A attacking = authorized-looking operations under the current key. Detectable and attributable to current operators; mitigable by re-keying (which is exactly what rotate() is for).
  • Set B attacking = operations under keys that no legitimate party can revoke, because:
    (a) rotate() to a new key does NOT invalidate the old key’s acceptance (E8: upperLookup still serves A_i for old sessions);
    (b) the per-chain bitmap (E10) is keyed by session number only — the defender cannot enumerate and burn all historical sessions, because any session not yet used on the target chain remains available;
    (c) re-keying is the standard incident response for a suspected threshold compromise — E8 defeats it retroactively.

Also note the asymmetry of intent: Set A “blocking all upcoming rotations” is a liveness attack (visible, recoverable via governance). Set B forging a rotate()/recall() under an epoch-old key is a THEFT capability that leaves the current operator set with no cryptographic recourse. The audit-relevant difference is not “can a threshold coalition do damage” but “does the set of parties who can do damage grow without bound” — and per E3 + E5, it does: +N retired validators per 24h era, forever.

=====================================================================

  1. Correction to the E3 response: dkg_run_reset does not clear what you cited, and cannot reach retired validators

Your E3 response cites dkg_run_reset (lib.rs:4448) as “a local clear does happen.” We re-verified at HEAD 0162da4 — two problems:

(a) SCOPE: dkg_run_reset only clears Round0–4 prefixes for the PREVIOUS dkg_index:

lib.rs:4466-4470

[DKG_ROUND0_PREFIX, DKG_ROUND1_PREFIX, DKG_ROUND2_PREFIX,
 DKG_ROUND3_PREFIX, DKG_ROUND4_PREFIX]
    .iter()
    .for_each(|prefix| dkg_storage.clear_local_storage(prefix));

It does NOT clear: the DKG_SECRET_PREFIX-derived key (lib.rs:4843-4848, create_dkg_based_prefix — the actual secret-share store, which is what dkg_run_round5 reads to derive the verifying share), or the exodus signing material (EXODUS_PREFIX_NONCES/SHARES/BLENDS/EXILES). That material is cleared only by a separate function, force_clear_local_storage (local_storage.rs:185-197); the same material is read in read_local_storage (local_storage.rs:~121-142) and written in try_write_local_storage (local_storage.rs:~200-217). dkg_run_reset never calls force_clear_local_storage. Nor does dkg_run_reset touch the on-chain VerifyingShares NMap (lib.rs:750-757, keyed by (curve, dkg_index, authority) — lib.rs:1380 inserts, no deletion site exists for historical entries anywhere in the pallet).

(b) REACHABILITY: dkg_run_reset is an arm of the match in dkg_run_process (lib.rs:3545-3546), which is only invoked from exodus_start (lib.rs:3228) iterating over

lib.rs:3241-3256

let authorities = ActiveAuthorities::<T>::get(&network_curve);
let active_authorities = ActiveDkgAuthorities::<T>::get(&network_curve);
... Self::exodus_run_process(authority_index, authority_key, ...)

i.e., only currently-ACTIVE authorities. A rotated-out validator’s offchain worker either stops calling exodus_start for the curve or fails the ActiveDkgAuthorities membership — it never re-enters dkg_run_process, so it never reaches the reset arm. The clear path is structurally unreachable for exactly the cohort whose shares are the subject of this report. This matches the original E3 claim; the force_clear path (lib.rs:3453-3456, gated on is_requests_empty inside exodus_run_process) has the same reachability limitation.

So the E3 finding stands at HEAD 0162da4, with the additional precision that even the function you cited does not clear the secret-share store it would need to clear, and the function that does (force_clear_local_storage) is itself gated behind the same active-authority reachability problem.

=====================================================================

  1. The narrowed abuse scenario, end-to-end

Preconditions (all satisfied on the current testnet):
P1. The chain’s Gatekeeper has >= 2 rotation checkpoints in _aggregatedPublicKeys, or is linked via _previousAddress to a predecessor that does (E8, E9). Each 24h era with at least one validator replacement adds one checkpoint (E5+E3).
P2. The attacker controls t-of-n validators from ANY historical epoch — e.g., 4 accounts that were part of the committee at any point in the past, later rotated out. No current-operator privileges required.
P3. The target chain’s StorageHistory bitmap has not yet consumed the historical session number the attacker will cite (E10: no sequential check; each chain’s bitmap starts empty).

Steps:
S1. (Offline) The coalition combines their historical FROST shares for epoch i and signs a rotate(next_session, attacker_key, parity) message under the aggregated key A_i. The challenge (Verifier.sol:113-117) binds only (tag, tag, rx, px, call) — no chainId, no address, no nonce (E7) — so one signature construction is reusable per (key, call) pair.
S2. The coalition selects exodusSession = a historical session number that is (a) < the current session, so upperLookup resolves to A_i, and (b) not yet burned in the target chain’s bitmap. Both conditions are publicly checkable on-chain (checkpoint table + bitmap are public storage), so selection is deterministic, not guesswork.
S3. The forged verify() call reaches the Gatekeeper via address(this).call(call) — the same self-call context production messages use (Gatekeeper.sol:140). The Gatekeeper resolves exodusSession via _extractPublicKey → getRotationInfoAt → upperLookup → A_i (E8), verifies the signature against A_i: valid, because the coalition holds A_i’s shares.
S4. rotate() applies with NO chainId check (E7): the Gatekeeper pushes attacker_key as the newest checkpoint and marks the cited session executed.
S5. The coalition now holds the CURRENT aggregated key on the target chain. From here the capabilities are those of the legitimate operator: recall() fund releases (including the EOA bounty path), govern() arbitrary target.call, and control of all future rotations. Note this capability was obtained without ever compromising a current validator.

Cost/boundary conditions:

  • The attack needs each cited session to be unburned on the target chain (E10 bitmap) and the coalition to persist across epochs. The defender has no on-chain tool to prevent it: re-keying adds new checkpoints but keeps old ones serveable; burning sessions requires enumerating them faster than the attacker cites them; the bitmap has no sequential constraint (E10).
  • Every era with a validator replacement grows the eligible coalition by the replaced members (E3, E5). On a 5-validator testnet this is +N per day; on mainnet with larger committees it scales with natural churn.
  • We deliberately describe this at the mechanism level; the concrete forge recipe (FROST share combination + Schnorr challenge construction) remains withheld per the responsible-disclosure note in the original report and is available on request via a private channel.

Why existing mitigations do not close it:

  • Re-keying (rotate): E8 keeps serving old keys for old sessions.
  • Session bitmap: E10 accepts any unused historical session.
  • dkg_run_reset / force_clear_local_storage: both unreachable for retired validators, and scope-excluded from the secret-share store even if reached (section 2 above).
  • VerifyingShares on-chain: permanent per (curve, dkg_index, authority) — itself a public ledger of every historical signing capability ever granted.

=====================================================================

  1. Minimal on-chain fixes that would close the narrowed scenario

Of the original A–F, the two that directly break S2/S3 above:

  • D (session freshness window): reject calls citing exodusSession older than the latest applied session by more than a bounded window. This kills S2’s session selection (no historical sessions serveable) and closes the predecessor-inheritance path when enforced on absolute session numbers.
  • C (chain-binding in the challenge): include block.chainid and address(this) in computeChallenge so even a valid-under-A_i signature is chain-bound, making S1’s cross-chain reuse impossible.

D is the minimal change (one storage read + one comparison in _extractPublicKey / verify path); C is the cryptographically complete one. A and B (Substrate-side share invalidation) remain the correct long-term fixes but require the reshare/rotation redesign discussed in your E3 response.

=====================================================================
We’re happy to iterate on the severity assessment in public — our proposed High reflects the theft capability of Set B combined with the monotonic growth of that set, not the baseline risks of Set A. The Foundry PoC (4/4 passing, no forging exercised, per the original report) plus the full forge recipe are ready to share as soon as a private channel is available or you prefer to continue in this thread.

— Himeko

The only problem with the attack vector you’re describing is this:

P2. The attacker controls t-of-n validators from ANY historical epoch - for example, 4 accounts that were part of the committee at some point in the past and were later rotated out. No current-operator privileges required.

If that happens for any ongoing epoch, it’s a disaster - a full collapse of the protocol. But if we’re talking about BFT, we’re affected here just like any other protocol: 7-of-10 validators would need to be under the attacker’s control for this to happen.

That said, how can I enrich your idea? Maybe you’re talking about this, and I’m just not following you correctly.

My take on expanding your attack

  1. Find any session on any EVM that has exodus_session gaps.
  2. Slowly but surely, attack each validator node in order to extract the secret shares.
  3. Collect them, then run exodus for a key rotation using the collected shares.

This is extremely difficult, but it’s possible to attack a small set of validator nodes. Does my attack describe what you’re trying to say, or not?

Re: the BFT-threshold pushback on P2

Your 3-step restatement is correct — that’s exactly the attack. To answer “how can I enrich your idea”: the piece that changes the threat model isn’t the “7-of-10” number itself, it’s what that number is measured against.

Standard BFT threshold security assumes: at most f-of-n are corrupt AT ANY GIVEN SNAPSHOT IN TIME. That assumption is renewable — it’s exactly why rotation exists. If a quorum is ever suspected of compromise, you rotate to a fresh key, the old key’s power is supposed to die with the rotation, and the system’s trust resets. Every live protocol with key rotation relies on that reset actually happening.

The finding is that the reset doesn’t happen. rotate() produces a new checkpoint but never revokes the old one (E8: upperLookup still resolves exodusSession values from before the rotation to the retired key A_i, indefinitely). So the assumption this system actually needs, whether or not that was the intent, is not “≤f-of-n corrupt right now” — it’s “≤f-of-n have EVER been corrupt, in ANY epoch, for the entire operating lifetime of the chain.” That’s a strictly stronger, non-renewable assumption, and it’s a different claim than the one BFT literature is normally making when it says “7-of-10.”

Concretely, this changes two things about the difficulty of your 3-step attack versus attacking a live quorum:

  1. No synchronization requirement. Compromising a LIVE committee has to happen within whatever window that committee actually holds power — the attacker is racing rotation, slashing, and detection. Step 2 in your restatement (“slowly but surely, attack each validator node”) has no such race: since A_i never expires, the attacker can acquire the t-of-n shares for one target epoch one at a time, over months or years, with zero on-chain signal until the forged rotate() is actually submitted. “Slow” is a feature for the attacker here, not a cost.

  2. Free choice of target among a growing set. A live-quorum attacker has exactly one committee to break: whichever one is in power now. This attacker can choose the weakest of every committee that has ever existed — every validator who was ever rotated out is a standing, never-expiring target, and the pool only grows (E3, E5: +N retired members per era). The defender can’t harden “the” quorum, because there isn’t one quorum to defend; there are as many as there have been epochs, and only one needs to fall.

So to directly answer your question: yes, it’s a 7-of-10-style threshold either way, but the 7-of-10 you’re used to reasoning about is drawn from a fixed, currently-defended set under time pressure. The 7-of-10 here can be drawn from any historical committee, assembled at the attacker’s own pace, with no window to miss. We’d argue that’s the concrete thing that should move this from “same risk as any BFT protocol” to “a protocol-specific gap” — the rotation mechanism exists specifically to prevent this class of accumulation, and the DKG-staleness finding is what defeats it.

Happy to formalize this as a probability argument (cumulative compromise probability across N retired committees vs. one live committee, as a function of era count and per-validator compromise rate) if that’s useful for the severity writeup — let us know and we’ll put real numbers to it using the current testnet’s era cadence and validator-set size.

— Himeko

Your arguments are absolutely clear now - thank you for the in-depth explanation.

It looks like this security flaw comes from rotate being able to execute against any historical public key. So the question is: what remedy could there be here?

For example, limiting rotate to execute only against the latest public key doesn’t protect against the same approach for recall and govern in any case. At the same time, the ability to execute historical signatures is crucial here, because users are humans and can forget or delay their own signature execution for any reason.

By the way, it looks like the weakest point in the current version is the initial set of validators used for bootstrapping - it’s the smallest set that could potentially be.

Re: remedy design + the bootstrap-set observation

Glad the framing landed. On the remedy:

  1. Agreed — rotate/recall/govern need a uniform fix, not a rotate-only patch

All three resolve their signing key the same way: _extractPublicKey → getRotationInfoAt → upperLookup(exodusSession). Patching rotate() alone would leave recall() and govern() able to execute under an arbitrarily old key, same as today — the vulnerability lives in the shared resolution path, not in any one caller. Whatever fix we land on should sit at that shared point, not be duplicated three times at the call sites (duplication is also how these things drift out of sync over a few years of maintenance).

  1. “Latest key only” vs “allow delay” — this is exactly what a bounded freshness window resolves

You’re right that strict latest-key-only breaks a real use case: a human signs, then the broadcast is delayed for whatever reason. The fix isn’t to remove that flexibility, it’s to bound it. Concretely (this is Mitigation D from the original report, made explicit here):

  • Track the latest successfully-applied session number per chain (the Gatekeeper already has enough state to derive this — it’s implicit in the checkpoint/bitmap history).
  • At the resolution point, reject any call citing exodusSession such that (latestAppliedSession - exodusSession) > W, for a governance-set W expressed in sessions (era-aligned, not wall-clock, so it stays meaningful regardless of era-length changes).
  • W=0 is the strict “latest only” policy you correctly identified as too rigid. W set to, say, a handful of eras gives humans a realistic grace window for delayed execution while still bounding the set of keys that remain acceptable to: only validators who were active within that window. Retired-beyond-W cohorts lose acceptance entirely, closing exactly the gap in this report.
  • This is one state read + one comparison added to the existing resolution path — it doesn’t touch the DKG/Substrate side and doesn’t require a reshare.

Separately, chain-binding (Mitigation C: include block.chainid / address(this) in computeChallenge) stays worth doing regardless of W, since it closes the cross-chain replay angle independently of session age. The two are complementary, not alternatives — D bounds “how old,” C bounds “which chain.”

A concrete value for W is really your call, since it’s a product/ops tradeoff (how long does a legitimate signer realistically need) more than a cryptography one. Happy to sanity-check whatever number you’re leaning toward against the attack model.

  1. The bootstrap-set point is a good one, and we’d like to quantify it rather than leave it qualitative

You’re right that the genesis/bootstrap committee is structurally the smallest validator set in the chain’s history, which means it has the smallest absolute t-of-n to compromise of any epoch — and per this report, it never stops being a valid signing target unless something like the W-bound above is deployed. If you can confirm the actual genesis validator count for the chain(s) in scope (we have an earlier, possibly-stale figure from recon on a staging testnet, but didn’t want to assert a specific n here without you confirming it against the chain you’re assessing severity for), we’ll fold it into the severity writeup as the concrete worst-case instance of the “attacker picks the weakest epoch” argument — i.e., not just “the attacker set grows over time” in the abstract, but “the single easiest target has existed since block 0 and remains valid today.”

— Himeko

Hey @Himeko,

We’ve discussed your report and the issue at length. It looks like the issue isn’t on the smart contract side but rather in pallets/exodus.

The main problem isn’t just the gaps you describe - it’s also the ability to call rotate historically. We have ideas to eliminate this entirely, but they’ll touch multiple parts of the protocol:

  1. pallets/exodus - we need to replace the global exodus_session identifier with one that’s strictly NetworkId-based. That way, we can guarantee no gaps appear on the chain side. We’re not concerned about the absence of chainId during rotate, because all public keys are mapped by network curve - so the majority of EVM chains must share the same public key at any point in time. Moreover BTC and most of EVMs would share same public x-only key at time T.

  2. ghost-contracts - here we need to eliminate the ability to rotate historically at a deeper level. Only the latest - that is, the current - public key should be used when storing a new one. The most crucial part is our experimental idea of rewarding every verify with GHST equal to the gas fee spent, at a linearly changing rate (with a bit of game theory thrown in). For now this has become a must-have feature, because it’s the only way we can incentivize filling gaps with honest signatures as quickly as possible.

At this point, this is definitely one of the most intensive attacks described on the forum. If you have any ideas around the proposed fix, let us know here.

Bravo, @Himeko!