DEV Community

Daniel
Daniel

Posted on

How WEMIX SPoA Accepted Mutated Block Headers Under an Unchanged Validator Signature

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
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

WEMIX instead used:

Producer signs
Number and Root

Verifier checks
Number and Root
Enter fullscreen mode Exit fullscreen mode

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,
    )
Enter fullscreen mode Exit fullscreen mode

The underlying signing path calculated:

crypto.Keccak256(
    append(
        height.Bytes(),
        hash.Bytes()...,
    ),
)
Enter fullscreen mode Exit fullscreen mode

Because header.Root was passed as hash, the normal SPoA signing digest was:

Keccak256(
    blockNumber
    ||
    header.Root
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The PoC focused on three fields whose mutations were accepted:

Time

GasLimit

Extra
Enter fullscreen mode Exit fullscreen mode

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,
        ),
)
Enter fullscreen mode Exit fullscreen mode

The hash argument was again:

header.Root
Enter fullscreen mode Exit fullscreen mode

It was not:

SealHash(header)

header.Hash()

A dedicated SPoA header-signature hash
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Changing Time, GasLimit, or Extra changed:

SealHash

Block hash
Enter fullscreen mode Exit fullscreen mode

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(),
    )
Enter fullscreen mode Exit fullscreen mode

After each mutation, the PoC recomputed MixDigest for the modified SealHash using the header nonce.

It then invoked:

verifyHeader(
    ...,
    seal = true,
    ...,
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The original child timestamp was:

1,000,001
Enter fullscreen mode Exit fullscreen mode

The peer changed the child timestamp to:

1,000,000
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The peer changed it to:

GasLimit
1
Enter fullscreen mode Exit fullscreen mode

That reduced nominal transaction capacity by:

29,999,999 gas
Enter fullscreen mode Exit fullscreen mode

The gas-limit validator returned immediately in non-PoW mode:

if !wemixminer.IsPoW() {
    return nil
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The test passed:

--- PASS: TestSpoa
PASS
ok github.com/ethereum/go-ethereum/consensus/ethash
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

After activation:

New blocks use a versioned SPoA header-signature domain
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Circular seal and signature fields should remain excluded:

MinerNodeSig

Nonce

MixDigest
Enter fullscreen mode Exit fullscreen mode

A dedicated function such as:

SpoaHeaderSigHashV2(
    header,
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Any historical compatibility issue should be handled by applying the stricter rules only after activation.

Regression tests

A complete patch should verify:

  1. Historical pre-fork headers remain valid

  2. Post-fork headers use the complete versioned signing domain

  3. Mutating Time invalidates MinerNodeSig

  4. Mutating GasLimit invalidates MinerNodeSig

  5. Mutating Extra invalidates MinerNodeSig

  6. Mutating transaction, receipt, bloom, fee, reward, or base-fee fields invalidates the signature

  7. Recomputing MixDigest cannot restore validity after a signed-field mutation

  8. Non-monotonic SPoA timestamps are rejected

  9. Out-of-bound SPoA gas limits are rejected

  10. Root mutation remains rejected

  11. Producer and verifier use the exact same domain

  12. 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
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)