Component: pallets/exodus @ 0162da46, src/lib.rs:2562-2593 (prepare_round6_storage creates ExodusRequest::evm_rotation), src/lib.rs:2617-2645 (prepare_finalization_storage), src/lib.rs:1803 (the only ExodusRequests::remove).
Description: (a) The new authority set activates only if the OLD set signs all rotation requests (all_rotations_have_signatures, checks ExodusSignedRotations::contains_key, lib.rs:2629); otherwise nullify_indexes() → quorum miss → restart_dkg() → DKG retries: an offline/refusing old set = liveness kill against activating the new set. (b) prepare_round6_storage inserts ExodusRequests and bumps CurrentExodus, but restart_dkg() does not clean up those entries: orphans from a failed DKG can still be fully signed by the old set via exodus_start (iterates all ExodusRequests, lib.rs:3315), producing valid threshold-signed rotation messages for keys that were never activated. Acceptance on the EVM/relayer side is out of scope: HYPOTHESIS for the final impact.
Severity: Low. Reason: (a) is partly by design (rotation genuinely needs the old key); (b) needs threshold cooperation of the old set plus a permissive EVM consumer.
Recommendation: Clean up ExodusRequests tied to rotation_sessions on failed finalization / restart_dkg; add a block expiry to rotation requests; consider a timeout on the finalization gate.
Verification: VERIFIED (static).
Thank you for your report!
Am I right that you’re referring to the code that can be found here?
rust
let all_rotations_have_signatures = dkg_qualification
.iter_rotation_sessions()
.all(|rotation_session| {
let session = rotation_session.exodus_session;
let r#type = rotation_session.network_type;
weight.saturating_accrue(T::DbWeight::get().reads(1));
if ExodusRequests::<T>::contains_key((&network_curve, &session)) {
return false
};
weight.saturating_accrue(T::DbWeight::get().reads(1));
ExodusSignedRotations::<T>::contains_key((session, r#type), dkg_index)
});
Are you saying that there is a vulnerability during the last round of the DKG - such that a malicious user can force the entire DKG to restart, but then add their own signature share and obtain a valid and executable signature?
If the answer is yes, my question is: does the number of malicious validators have to be threshold + 1 for this to be possible? If so, that would mean it breaks the BFT rules. Am I missing something in your report?
Good question, and I think my report’s framing may have invited that reading, so let me clarify what I am actually claiming.
I am not claiming a single malicious user can force a restart and walk away with a valid executable signature on their own. The two parts are:
(a) Liveness dependency on the old set. Finalization requires the old authority set to have signed every rotation request. If enough of the old set is simply unavailable (validators that already rotated off, lost keys, uncooperative), the new set cannot activate and the DKG loops on restart_dkg(). This needs no threshold+1 malicious validators and breaks no BFT rule: BFT liveness assumes a live honest supermajority, and an unavailable old set is outside that assumption. It is a routine operational condition, not an attack. I noted it is partly by design since rotation genuinely needs the old key.
(b) Orphan accumulation. prepare_round6_storage inserts ExodusRequests and bumps CurrentExodus, but restart_dkg() does not clean them up. Those orphans can still be signed later via exodus_start if the old set cooperates at threshold. Producing a valid signature there needs threshold cooperation of the old set, which is the normal signing threshold, not a BFT violation. The defect is the missing cleanup/expiry, which leaves stale rotation material executable.
So to your question directly: no, neither part requires threshold+1 malicious validators, and neither breaks BFT. The finding is the missing timeout on the finalization gate and the missing cleanup of rotation requests on restart. Severity Low stands.
It looks like because the rotation list will be at most the size of all possible network curves - which is limited to 3–5 at maximum - your fix makes sense, since it would only need to remove a small number of entry points, which seems doable.
And it will protect against bloat - even if the bloat isn’t large, it’s still bloat in any case.