Repository navigation
bug(consensus): non-canonical keys in ValidatorRegistry trigger unhandled node halt during block transition #489
Description
Summary
A structural discrepancy between ValidatorRegistry.sol validation and abi_decode_validator_set allows non-canonical Ed25519 public keys to enter active registry state and subsequently halt the consensus node during block transition. Additionally, aggregate active voting power is not validated against u64 overflow in the registry or during ValidatorSet construction, deferring overflow checks to an unhandled panic in total_voting_power.
Technical Details
In contracts/src/validator-manager/ValidatorRegistry.sol#L98-L99, validator registration only asserts raw byte length:
require(publicKey.length == ED25519_PUBLIC_KEY_LENGTH, InvalidPublicKeyFormat());The contract does not verify that the 32 bytes represent a valid decompressed Curve25519 point. When keccak256(publicKey) is unique, arbitrary 32-byte sequences are accepted and activated. In Curve25519 point decoding, approximately half of candidate 32-byte representations are invalid or non-canonical field elements, as evidenced by MALFORMED_PK in crates/eth-engine/src/abi_utils.rs#L249.
The registry contract relies on _countActiveValidatorsWithPositiveVotingPower in removeValidator (#L147-L149) and updateValidatorVotingPower (#L182-L184) to guard against reducing the active validator set below 1. Because the registry treats a malformed entry as fully active, other valid active validators can be removed or decremented to zero voting power while the malformed entry remains.
When the consensus layer fetches the active set via get_signing_validator_set, abi_decode_validator_set in crates/eth-engine/src/abi_utils.rs#L85-L96 invokes try_decode_validator. Any entry failing PublicKey::from_bytes is logged and skipped. If the remaining active registrations are all malformed, lines 98-103 bail:
if validators.is_empty() {
eyre::bail!(
"No active validators with valid public keys — validator set would be empty \
({skipped} active validator(s) skipped due to malformed public keys)"
);
}In crates/malachite-app/src/handlers/decided.rs#L102-L105, prepare_next_height propagates this error directly via ?:
let next_height_info =
prepare_next_height(decided_height, block, new_sync_state, engine).await?;Because decided::handle terminates before setting state.decision, the error bubbles up through handle_consensus in crates/malachite-app/src/app.rs#L297 and terminates the main consensus select loop at app.rs#L177-L178, aborting the node process entirely.
A related arithmetic boundary condition exists with aggregate voting power. ValidatorRegistry.sol does not track the aggregate voting power of active validators. Neither abi_decode_validator_set nor ValidatorSet::new validates cumulative voting power during construction. When cumulative voting power exceeds u64::MAX, the overflow check in crates/types/src/validator_set.rs#L105-L110 triggers .expect("total voting power overflow"), panicking within active consensus execution.
Remediation
Remediation requires validating cumulative voting power during decoding in abi_decode_validator_set and inside ValidatorSet::new to reject overflowing sets cleanly prior to consensus runtime. Additionally, ValidatorRegistry.sol should track active aggregate voting power against type(uint64).max, while crates/malachite-app/src/handlers/decided.rs should prevent decoder errors in prepare_next_height from abruptly crashing the main application loop.
Activity
osr21 commentedon Oct 10, 2026
A few details that may help make the impact and regression test precise:
Yudis-bit commentedon Oct 11, 2026
On mutation reachability,
ValidatorRegistryis owned byPermissionedValidatorManager, which delegatesregisterValidatortoValidatorRegistereraccounts andactivateValidator,removeValidator, andupdateValidatorVotingPowerto individualControlleraccounts. This does not require root contract owner key compromise. An authorized operator provisioned with a registerer role providing a 32-byte non-canonical Ed25519 representation (such as a non-square field element during point decompression, whichValidatorManagement.s.solexplicitly notes cannot be verified on-chain in EVM), followed by routine operational rotation via a controller removing or zeroing a valid validator, satisfies_countActiveValidatorsWithPositiveVotingPower > 1on-chain because the contract treats the malformed registration as active power.The minimal regression flow matches that exact sequence: activate one valid key and one 32-byte payload failing
PublicKey::from_bytes, then zero the valid validator's voting power. On-chain validation passes since the active count remains 1, leaving only the non-canonical key ingetActiveValidatorSet(). On block transition,abi_decode_validator_setskips the malformed key, leaving the filtered set empty and bailing with"No active validators with valid public keys". Fail-closed semantics here are necessary: falling back to a stale validator set across nodes would split the consensus state machine and violate BFT safety invariants if nodes observe divergent registry states across block transitions.For the arithmetic case with individual powers of
u64::MAXand1, constructingValidatorSetviaValidatorSet::try_newcatches aggregate overflow during construction and returns a structuredValidatorSetError::VotingPowerOverflowrather than triggering a thread panic insidetotal_voting_power()during the active consensus round. Threading this throughabi_decode_validator_setsurfaces the failure directly as aneyre::Resultat the block transition boundary, matching the fail-closed error handling of empty validator sets.On the runtime lifecycle, the propagated error bubbles from
prepare_next_heightthroughdecided::handleintohandle_consensusincrates/malachite-app/src/app.rs. Ingo(),handle_consensus(...).await.wrap_err(...)returnsErr, exiting the consensus event loop. InApp::start(), theapp_req_taskis aborted, a redb savepoint is persisted, and the error is returned tohandles.app.awaitinApp::run(), which returns directly tort.block_on(app.run())inmain.rsand terminates the node process with a non-zero exit status.feafel commentedon Oct 11, 2026
Hi, I'd like to take this one per CONTRIBUTING.md.
I've traced the full crash path described here and agree with the analysis:
abi_decode_validator_set(crates/eth-engine/src/abi_utils.rs) silently skips entries failingPublicKey::from_bytes, then bails when the set is empty;prepare_next_height(crates/malachite-app/src/handlers/decided.rs) propagates via?intohandle_consensus(crates/malachite-app/src/app.rs), killing the main select loop.ValidatorSet::new(crates/types/src/validator_set.rs) uses.expect("total voting power overflow")— a panic inside consensus runtime.ValidatorRegistry.solonly checkspublicKey.length == 32and never validates Curve25519 decompressibility, and tracks no aggregate voting power, so malformed entries can satisfy the "active set >= 1" guards while being undecodable by the CL.Proposed remediation (split into reviewable commits):
abi_decode_validator_set, validate cumulative voting power againstu64::MAXbefore construction and fail cleanly; indecided::handle, catchprepare_next_heightdecoder errors and transition to a recoverable error state instead of aborting the process loop.ValidatorRegistry.solcapped attype(uint64).max, and reject non-decompressible Ed25519 points at registration time (via a validation helper or precompile).Could you please assign this issue to me? Happy to start with the CL-hardening portion if you'd like it split across PRs.