A validator signature is useful only when it binds the exact block header that honest nodes accept.
WEMIX SPoA did not preserve that property.
The validator signed:
Keccak256(
blockNumber
||
stateRoot
)
The accepted header contained much more than those two values.
A peer that received one valid signed SPoA block could preserve the original Root and MinerNodeSig, change Time, GasLimit, or Extra, recompute the non-PoW seal, and produce a different header that still passed the real WEMIX verifyHeader path.
The peer did not need the validator private key.
The proof demonstrated:
Original signed header
Accepted
Time-mutated header
Accepted with the original signature
GasLimit-mutated header
Accepted with the original signature
Extra-mutated header
Accepted with the original signature
Root-mutated header
Rejected
SealHash-based signature control
Rejected the mutations
Each accepted mutation preserved the signed state root and signature while changing both the block hash and SealHash.
I submitted this report as Major through the WEMIX bug bounty program hosted on CertiK Skynet.
The program classified it as Low and paid a $100 bounty.
That classification does not match the demonstrated security boundary.
This was not a cryptographic best-practice suggestion or an inert metadata issue. The proof showed that a peer could create a different consensus header under a validator signature that never authenticated that complete header, and the real seal-enabled validation path accepted it.
The complete public report and proof of concept are available in the GitHub Gist.
The signature invariant
A block signature should answer:
Did this validator authorize this exact consensus header?
For that statement to be true, the signing domain must include every accepted field that affects the block identity or consensus execution context.
A secure design needs this relationship:
Producer signs
A complete non-circular consensus-header domain
Verifier checks
The same domain reconstructed from the received header
WEMIX instead used:
Producer signs
Number and Root
Verifier checks
Number and Root
The producer and verifier agreed with each other.
They agreed on an incomplete message.
What the producer actually signed
During block assembly, FinalizeAndAssemble called:
coinbase, sig, err :=
wemixminer.SignBlock(
header.Number,
header.Root,
)
The underlying signing path calculated:
crypto.Keccak256(
append(
height.Bytes(),
hash.Bytes()...,
),
)
Because header.Root was passed as hash, the normal SPoA signing digest was:
Keccak256(
blockNumber
||
header.Root
)
The signature did not bind the complete header.
Fields outside that payload included:
ParentHash
Coinbase
TxHash
ReceiptHash
Bloom
GasLimit
GasUsed
Time
Extra
BaseFee
Fees
Rewards
MinerNodeId
The PoC focused on three fields whose mutations were accepted:
Time
GasLimit
Extra
Verification used the same narrow payload
The header-validation path called:
wemixminer.VerifyBlockSig(
header.Number,
header.Coinbase,
header.MinerNodeId,
header.Root,
header.MinerNodeSig,
chain.Config()
.IsPangyo(
header.Number,
),
)
The hash argument was again:
header.Root
It was not:
SealHash(header)
header.Hash()
A dedicated SPoA header-signature hash
The PoC instrumented the real VerifyBlockSigFunc boundary.
That boundary did not blindly return success.
It:
Reconstructed the real Number plus Root digest
Recovered the validator public key with crypto.Ecrecover
Required the recovered key to match the validator
Required the received hash argument to equal header.Root
Required the received hash argument not to equal SealHash(header)
This confirmed that WEMIX verification was authenticating the state root rather than the complete accepted header.
The mutations changed the block identity
WEMIX SealHash included standard consensus fields such as:
ParentHash
Coinbase
Root
TxHash
ReceiptHash
Bloom
Difficulty
Number
GasLimit
GasUsed
Time
Extra
BaseFee when present
Changing Time, GasLimit, or Extra changed:
SealHash
Block hash
The modified header was therefore not another encoding of the same block.
It was a different block identity.
Yet the original MinerNodeSig remained byte-identical and continued to verify because the signed Root and block number had not changed.
The verifier accepted multiple distinct header hashes under the same validator signature.
That is the core flaw.
Recomputing the non-PoW seal required no validator key
Changing a header field also changed its seal input.
The proof handled that rather than bypassing seal validation.
For non-PoW mode, WEMIX calculated the seal through:
digest, result =
hashimeta(
ethash
.SealHash(header)
.Bytes(),
header.Nonce.Uint64(),
)
After each mutation, the PoC recomputed MixDigest for the modified SealHash using the header nonce.
It then invoked:
verifyHeader(
...,
seal = true,
...,
)
The mutated headers were accepted with seal verification enabled.
The non-PoW seal was recomputable.
The cryptographic proof that was supposed to identify validator authorization was MinerNodeSig, and that signature did not cover the modified fields.
Accepted timestamp mutation
The parent timestamp was:
1,000,000
The original child timestamp was:
1,000,001
The peer changed the child timestamp to:
1,000,000
The child timestamp was no longer greater than the parent timestamp.
WEMIX checked this relationship only in PoW mode:
if (
wemixminer.IsPoW()
&&
header.Time <= parent.Time
) {
return errOlderBlockTime
}
The real SPoA verifyHeader path accepted the non-monotonic timestamp.
The PoW control rejected the same relationship.
Accepted GasLimit mutation
The original header used:
GasLimit
30,000,000
The peer changed it to:
GasLimit
1
That reduced nominal transaction capacity by:
29,999,999 gas
The gas-limit validator returned immediately in non-PoW mode:
if !wemixminer.IsPoW() {
return nil
}
The SPoA path therefore skipped the bounded-change and minimum-gas-limit checks implemented by that function.
The real SPoA validation path accepted GasLimit = 1.
The PoW control rejected it.
The proof did not claim that one such header permanently shut down the network. It demonstrated that an unsigned consensus field could be changed to a value rejected by the bounded PoW rule while remaining accepted by SPoA.
Accepted Extra mutation
The peer also replaced Extra with attacker-controlled bytes.
That mutation changed the block hash and SealHash.
It did not change Root or MinerNodeSig.
The same signature remained valid, and the real validation path accepted the modified header.
Extra is useful here because it confirms the general authentication gap:
The signature covers a subset of the header
Consensus accepts a larger object
The attack path
The demonstrated sequence was:
1. A legitimate SPoA validator produces a valid signed block
2. A peer receives that block
3. The peer clones the header
4. Root remains unchanged
5. MinerNodeSig remains unchanged
6. The peer modifies Time, GasLimit, or Extra
7. The peer recomputes MixDigest for the modified SealHash
8. The modified header receives a new block hash
9. verifyHeader checks MinerNodeSig against Number and Root
10. The modified header is accepted
The mutation path required one already valid signed SPoA block.
It did not require:
The validator private key
A governance role
An administrator key
A public RPC
Mainnet or testnet interaction
The PoC demonstrated local consensus acceptance. It did not claim that the mutated header was propagated, selected as canonical, or used to execute transactions on a live network.
What the proof used
The test ran inside the real WEMIX consensus package:
consensus/ethash
It exercised:
Real verifyHeader
Real verifySeal
Real SealHash
Real hashimeta
Real misc.VerifyGaslimit
Real EIP-1559 header validation
Real wemixminer.SignBlock boundary
Real crypto.Sign
Real crypto.Ecrecover
The vulnerable header-validation logic was not copied into a standalone model.
The callback boundary was necessary because validator-registry resolution lived outside the signing primitive. The callback still performed the real cryptographic recovery and strictly verified the payload received from the production validation path.
The test command was:
go test ./consensus/ethash \
-run TestSpoa \
-count=1 \
-v
The test passed:
--- PASS: TestSpoa
PASS
ok github.com/ethereum/go-ethereum/consensus/ethash
Controls that isolated the vulnerability
The proof included several negative controls.
Root mutation failed
The PoC changed Root while preserving the original signature.
Validation rejected the header.
That confirmed the signature was real and did bind the state root.
SealHash-based signature verification failed
The PoC tested the original signature against the modified header’s SealHash.
The validator key could not be recovered under that domain.
A signature that included the changed consensus header fields would have rejected the mutation.
PoW rejected the timestamp
The same non-monotonic child timestamp failed under the PoW control.
PoW rejected the gas limit
The same near-zero gas limit failed the PoW gas-limit rule.
These controls show that the result was not caused by an always-true verifier, a fake signature, or skipped seal validation.
What the PoC proves
The proof establishes:
The SPoA signature covers Number and Root
Time, GasLimit, and Extra remain outside that signature
Mutating those fields changes block hash and SealHash
The original MinerNodeSig remains valid
The non-PoW seal can be recomputed
The real seal-enabled verifyHeader path accepts the mutations
The mutation does not require the validator key
It does not establish:
A permanent chain split
A canonical-chain attack
Accepted transaction execution under a mutated canonical block
Direct token theft
Total network shutdown
Majority validator compromise
Those limitations matter.
They are also why the report was submitted as Major rather than Critical.
Why this is Major, not Low
A Low classification would be reasonable for a theoretical recommendation, harmless metadata, or a malformed header rejected by production validation.
This report demonstrated an accepted consensus-authentication failure.
The validator signature did not bind the accepted header
The validator authorized one narrow digest.
Honest validation accepted multiple distinct header identities under that same signature.
The cryptographic primitive worked.
The protocol applied it to the wrong message.
Consensus-relevant fields were mutable
The accepted mutations included:
Time
GasLimit
Those fields affect header validity and the execution envelope of a block.
The timestamp became non-monotonic.
The gas limit fell from 30,000,000 to 1.
The attacker needed no validator key
The peer reused a valid signature and modified fields outside its domain.
The validator never signed the complete modified header.
The production validation path accepted the result
The PoC did not stop at source inspection.
It called the real verifyHeader and verifySeal logic with seal verification enabled.
The exploit condition was accepted behavior, not merely suspicious code.
The block identity changed
Every accepted mutation changed both the block hash and SealHash.
The same validator signature was therefore reusable across different accepted block identities.
Major is proportionate to the demonstrated scope
The proof showed incorrect use of a cryptographic authentication primitive and accepted mutation of consensus metadata.
It did not prove a permanent fork, direct theft, or total network failure.
Critical would overstate the evidence.
Low understates it.
Major matches the demonstrated consensus-authentication impact.
Why the $100 outcome understates the evidence
The final Low classification produced a $100 bounty.
That records the program decision.
It does not change the result:
Original signed header
Accepted
Mutated Time header
Accepted
Mutated GasLimit header
Accepted
Mutated Extra header
Accepted
Root
Unchanged
MinerNodeSig
Unchanged
Block hash
Changed
SealHash
Changed
Validator key required
No
The strongest proven effect was the creation of distinct accepted consensus headers under another validator’s unchanged signature.
That is not Low impact.
Why I describe the WEMIX handling as systematic downgrading
Severity disagreements are normal in bug bounty programs.
My concern is the recurring pattern across my own valid WEMIX submissions.
Reports supported by reproducible proofs and concrete protocol behavior were repeatedly assigned substantially lower final severities.
This report was submitted as Major.
The proof used real consensus validation code and demonstrated accepted unsigned mutation of block headers.
It was classified as Low.
I cannot infer intent from that decision.
I can document the repeated result and compare the classification with the evidence.
Calling it systematic downgrading describes that pattern across my WEMIX submissions without claiming a motive.
A technically convincing Low justification would need to explain why the following are minor:
The signature omits accepted consensus fields
A peer changes the block hash without the validator key
The same signature remains valid across distinct accepted headers
The real seal-enabled verification path accepts the mutations
Time becomes non-monotonic
GasLimit falls from 30,000,000 to 1
Without addressing those points, the Low classification does not match the proof.
The correction requires a future fork
Changing the signing domain immediately would make historical blocks incompatible with the new verification rule.
The fix must therefore be activated at a future fork height.
Before activation:
Historical blocks continue using the legacy Number plus Root rule
After activation:
New blocks use a versioned SPoA header-signature domain
The new domain should bind:
Chain ID
Signature version
ParentHash
UncleHash
Coinbase
Root
TxHash
ReceiptHash
Bloom
Difficulty
Number
GasLimit
GasUsed
Time
Extra
BaseFee
WEMIX Fees
WEMIX Rewards
MinerNodeId
Circular seal and signature fields should remain excluded:
MinerNodeSig
Nonce
MixDigest
A dedicated function such as:
SpoaHeaderSigHashV2(
header,
)
would make the signing contract explicit.
Restore SPoA header constraints
At the same fork, SPoA validation should enforce:
Time > parent.Time
Bounded GasLimit changes
Minimum GasLimit
Any historical compatibility issue should be handled by applying the stricter rules only after activation.
Regression tests
A complete patch should verify:
Historical pre-fork headers remain valid
Post-fork headers use the complete versioned signing domain
Mutating
TimeinvalidatesMinerNodeSigMutating
GasLimitinvalidatesMinerNodeSigMutating
ExtrainvalidatesMinerNodeSigMutating transaction, receipt, bloom, fee, reward, or base-fee fields invalidates the signature
Recomputing
MixDigestcannot restore validity after a signed-field mutationNon-monotonic SPoA timestamps are rejected
Out-of-bound SPoA gas limits are rejected
Root mutation remains rejected
Producer and verifier use the exact same domain
Chain ID and signature version prevent cross-domain replay
The central invariant is:
One validator signature must authenticate exactly one accepted consensus header.
Broader lessons
Signing a state root is not signing a block
A state root authenticates resulting state.
It does not authenticate every field defining the block identity and execution context.
Cryptographic correctness includes the message domain
ECDSA may be implemented correctly while the protocol signs the wrong payload.
A valid signature over an incomplete message does not authenticate the complete object.
A recomputable seal is not validator authorization
The non-PoW seal confirmed consistency with the modified SealHash.
It did not prove that the validator approved the modification.
That was the job of MinerNodeSig.
Impact analysis must follow accepted behavior
The problem was not only visible in SignBlock.
The real validation path accepted the modified headers.
Negative controls strengthen cryptographic findings
The rejected root mutation, failed SealHash-based recovery, and PoW controls established what the signature covered and what the SPoA path omitted.
Conclusion
WEMIX SPoA signed:
Keccak256(
blockNumber
||
header.Root
)
Honest validation accepted a larger consensus header containing fields outside that signature.
A peer could preserve Root and MinerNodeSig, mutate Time, GasLimit, or Extra, recompute MixDigest, and produce a different header that passed the real seal-enabled validation path.
The proof showed:
Same state root
Same validator signature
Different block hash
Different SealHash
Mutated consensus fields
Header accepted
Validator private key not required
The program classified the report as Low and paid $100.
![]()
The demonstrated impact was Major.
The proof did not claim permanent consensus failure, direct theft, or a completed live-network attack.
It demonstrated a narrower but serious failure:
A peer could construct a different header that the real consensus validation path accepted under a validator signature that never authenticated that complete header.
A block signature that does not bind the accepted block is not a Low-severity issue.
Top comments (0)