October 2, 2026
Unauthenticated pol_round in arc-node: From Hash Preimage Omission to State Divergence
Introduction

By jimmy
2 min read
Introduction
During a security review of arc-node (commit 2a3e8ab), I identified a critical integrity gap in the block dissemination and proposal validation logic. Specifically, the consensus proposal parts are signed over a hash that omits the pol_round field carried in the proposal Init.
While this report was ultimately closed as a duplicate of an existing internal/external finding (#4034183), the vulnerability provides a fascinating study in consensus state-machine safety, signature malleability, and cryptographic omission bugs.
Technical Deep Dive: The Integrity Gap
In distributed consensus engines like Malachite/Arc, ensuring that proposal components cannot be tampered with in transit is paramount. However, a breakdown occurs across three core components:
1. The Hash Preimage Omission (ProposalParts::hash)
Looking at crates/types/src/proposal_parts.rs, the hash function computes the Keccak-256 hash using only the height, round, and data bytes:
hasher.update(self.height().as_u64().to_be_bytes());hasher.update(self.round().as_i64().to_be_bytes());for data in &self.data { hasher.update(&data.bytes); }
Notice that pol_round is completely absent from this hash input.
2. Flawed Signature Verification (validate_proposal_parts)
In crates/malachite-app/src/proposal_parts.rs, the validation function fetches hash = parts.hash() and checks the proposer's Ed25519 signature strictly against that incomplete hash. Because pol_round is excluded from the hash, modifying it does not invalidate the signature.
3. State Machine Overwrite (assemble_block_from_parts)
Once the tampered parts pass signature verification, assemble_block_from_parts() directly sets:
valid_round: parts.init().pol_round
An attacker-controlled value is thus successfully injected into the node's consensus state as the valid round.
Consensus Impact: How State Divergence Occurs
In Malachite's core state machine (state_machine.rs), the prevote_previous function evaluates the voting guard:
vr = proposal.pol_round()- If
locked.round <= vr$\rightarrow$ prevoteVal(proposed) - Else $\rightarrow$ prevote
Nil - Safety Failure: If an attacker relays different
pol_roundvalues to different validators, nodes execute the guard differently, casting conflicting prevotes (Valvs.Nil) on the same height and round. This can result in divergent commit decisions. - Liveness Failure: If the divergence prevents consistent precommit quorums from forming, consensus progress stalls completely.
- Low Barrier: No validator private key is required; an attacker needs only network access to relay tampered proposal parts.
Proof of Concept (PoC)
To demonstrate this signature malleability without needing a live network, I wrote a Python PoC using the cryptography library. It shows that a valid Ed25519 signature over ProposalParts::hash() remains valid even when pol_round is modified from 0 to 5.
You can check out the full code and output files on GitHub:
๐ https://github.com/Mohammed23200/consensus-signature-coverage-poc
Python
# Excerpt from pol_round_malleability.py
def proposal_parts_hash(height_u64, round_i64, data_parts):
msg = bytearray()
msg.extend(height_u64.to_bytes(8, 'big'))
msg.extend(round_i64.to_bytes(8, 'big', signed=True))
for part in data_parts:
msg.extend(part)
return keccak_256(bytes(msg))# Excerpt from pol_round_malleability.py
def proposal_parts_hash(height_u64, round_i64, data_parts):
msg = bytearray()
msg.extend(height_u64.to_bytes(8, 'big'))
msg.extend(round_i64.to_bytes(8, 'big', signed=True))
for part in data_parts:
msg.extend(part)
return keccak_256(bytes(msg))PoC Output (poc_output.txt):
Plaintext
============================================================
PoC: pol_round malleability in arc-node ProposalParts
Ref: github.com/circlefin/arc-node commit 2a3e8ab
============================================================
BEFORE pol_round=0, AFTER pol_round=5, SIGNATURE VALID: True
HASH(height, round, data) unchanged: True
Conclusion: pol_round can be changed freely without
invalidating the proposer signature.
This becomes ConsensusBlock.valid_round via:
assemble_block_from_parts() line 279============================================================
PoC: pol_round malleability in arc-node ProposalParts
Ref: github.com/circlefin/arc-node commit 2a3e8ab
============================================================
BEFORE pol_round=0, AFTER pol_round=5, SIGNATURE VALID: True
HASH(height, round, data) unchanged: True
Conclusion: pol_round can be changed freely without
invalidating the proposer signature.
This becomes ConsensusBlock.valid_round via:
assemble_block_from_parts() line 279Conclusion
Cryptographic bindings in consensus layer protocols must cover all mutable state fields that influence state machine transitions. Omitting fields like pol_round from proposal hashes opens up powerful relay-based state-manipulation vectors.
Thank you to the security team at Circle for handling the report.