Consensus validation must be deterministic.
The same candidate header, evaluated against the same candidate parent and chain rules, should not switch between valid and invalid merely because a node currently considers another branch canonical.
WEMIX SPoA violated that invariant.
Its validator-authorization path resolved governance through a global ethclient.Client using only BlockNumber.
During side-branch or reorg validation, that number-scoped query returned governance from the node’s local canonical branch. It did not resolve governance from the candidate header’s own parent hash.
The proof validated the same candidate twice:
Same candidate header
Same candidate hash
Same parent hash
Same state root
Same coinbase
Same MinerNodeSig
Only the canonical governance response for block 100 changed.
The result changed with it:
Canonical snapshot A
Validator A authorized
Candidate signed by validator B
Candidate rejected
Canonical snapshot B
Validator B authorized
Same candidate accepted
Nothing inside the candidate changed.
The validation result changed because the node consulted governance from whichever branch was canonical locally.
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 impact.
This was not an informational RPC mismatch or a cache-efficiency concern. The real WEMIX validator-signature path rejected and accepted the same candidate depending only on the governance snapshot returned by the local canonical client.
That is a consensus-determinism and reorg-safety failure.
The complete public report and proof of concept are available in the GitHub Gist.
The invariant that candidate validation must preserve
Candidate validation should be determined by:
The candidate header
The candidate parent
The candidate branch state
The chain configuration
It must not depend on hidden mutable inputs such as:
Which competing branch is currently canonical
What a canonical RPC returns for the same height
Which branch populated a height-only cache first
During a reorg, multiple branches can contain different governance state at the same block number.
A node validating branch B must ask:
Is this candidate authorized by branch B’s parent state?
It must not ask:
Is this candidate authorized by whichever branch I currently consider canonical?
Those questions can produce different answers.
The PoC showed that WEMIX did exactly that.
Where WEMIX lost the branch identity
The primary problem appeared in getRegGovEnvContracts:
func (
ma *wemixAdmin
) getRegGovEnvContracts(
ctx context.Context,
height *big.Int,
) (
*gov.GovContracts,
error,
) {
if ctx == nil {
var cancel func()
ctx, cancel =
context.WithCancel(
context.Background(),
)
defer cancel()
}
opts :=
&bind.CallOpts{
Context: ctx,
BlockNumber:
height,
}
return gov
.GetGovContractsByOwner(
opts,
ma.cli,
ma.bootAccount,
)
}
The resolver received a block number.
It did not receive:
Candidate parent hash
Candidate state root
A branch-scoped state reader
The call used ma.cli, a global client.
A contract call through that client with BlockNumber = 100 asks for canonical state at height 100.
That behavior is normal for a canonical query.
It is unsafe when the result determines whether a side-branch validator is authorized.
Header validation did not propagate the candidate parent
The SPoA header path called:
if (
!wemixminer.IsPoW()
&&
!wemixminer.VerifyBlockSig(
header.Number,
header.Coinbase,
header.MinerNodeId,
header.Root,
header.MinerNodeSig,
chain.Config()
.IsPangyo(
header.Number,
),
)
) {
return consensus
.ErrUnauthorized
}
The verifier received:
Header number
Coinbase
Miner node ID
State root
Validator signature
Miner-limit flag
The candidate parent hash was absent.
Once that branch identity was discarded, downstream governance reads could not reliably select the state belonging to the candidate’s ancestry.
The verifier queried height minus one by number
Inside verifyBlockSig, WEMIX calculated:
num :=
new(big.Int)
.Sub(
height,
common.Big1,
)
contracts, err :=
admin
.getRegGovEnvContracts(
ctx,
num,
)
For a candidate at height 101, the verifier requested governance at height 100.
Using the parent height is conceptually correct.
The problem was the identifier.
The code asked for:
Canonical governance at block number 100
It needed:
Governance at the candidate parent hash at height 100
A block number does not uniquely identify state while competing branches exist.
Validator lookup inherited the same number-scoped state
The coinbase-to-enode mapping also used:
opts :=
&bind.CallOpts{
Context: ctx,
BlockNumber:
height,
}
Its cache was keyed by the modified governance block number.
It was not keyed by:
Candidate parent hash
Governance state root
Branch identity
If branches A and B both report a governance modification at block 100, a key containing only 100 cannot distinguish their validator maps.
The PoC’s direct reject-versus-accept result came from validator authorization through the real governance path. The cache design is an additional branch-scoping weakness visible in the same codebase.
Previous-miner history used canonical headers by number
Miner-limit logic included:
block, err :=
cli.HeaderByNumber(
ctx,
height,
)
HeaderByNumber follows the canonical chain.
It does not follow the ancestry of a side-branch candidate.
The associated cache was also keyed only by height.
This can produce another mismatch:
Candidate belongs to branch B
Previous-miner history is read from branch A
The published PoC did not separately reproduce an opposite decision through this helper. It demonstrated the validator-governance path directly.
The helper matters because the same missing branch identity appears in another consensus-relevant decision.
Block-build parameters showed the same design pattern
WEMIX also loaded governance-controlled block parameters by BlockNumber, including values related to:
Block interval
Gas limit
Maximum base fee
Base-fee change rate
Gas target percentage
Non-PoW base-fee calculation requested those parameters using the parent number without passing the parent hash.
Again, the report’s end-to-end proof focused on validator eligibility.
The broader audit lesson is that every consensus-critical governance read must be branch-scoped, not merely height-scoped.
The proof used two governance snapshots at the same height
The PoC created two deterministic governance snapshots for block 100.
Snapshot A authorized validator A:
0x8fd379246834eac74B8419FfdA202CF8051F7A03
Snapshot B authorized validator B:
0x88f9B82462f6C4bf4a0Fb15e5c3971559a316e7f
The unchanged candidate was:
Candidate parent number
100
Candidate child number
101
Candidate signer
Validator B
Validator B’s real signature was attached to the candidate.
Snapshot B recognized that signer.
Snapshot A did not.
Validation under canonical snapshot A
In the first run, the canonical governance response for block 100 was snapshot A.
WEMIX queried governance using BlockNumber.
The generated bindings returned validator A’s governance state.
The candidate was signed by validator B.
The real validation path returned:
Unauthorized block
The candidate was rejected against governance that did not belong to its intended branch snapshot.
Validation under canonical snapshot B
The PoC then changed only the canonical governance response.
It did not modify:
Candidate header
Candidate hash
Candidate parent hash
Candidate root
Candidate coinbase
Candidate MinerNodeSig
The same BlockNumber = 100 query now returned snapshot B.
Snapshot B authorized validator B.
The same candidate was accepted.
The proof therefore produced:
Same candidate
Canonical governance A
REJECTED
Canonical governance B
ACCEPTED
That is branch-dependent consensus validation.
The exact observed result
The PoC reported:
Candidate parent number
100
Candidate child number
101
Candidate signer
Validator B
Candidate header changed
NO
Candidate parent hash changed
NO
Candidate root changed
NO
Candidate coinbase changed
NO
Candidate MinerNodeSig changed
NO
Governance read used BlockNumber
YES
Governance read used candidate parent hash
NO
Validation under canonical A
REJECTED
Validation under canonical B
ACCEPTED
Direct balance movement demonstrated
NO
Consensus branch determinism affected
YES
The absence of direct balance movement is important.
The report did not turn a reorg-safety flaw into an unsupported theft claim.
Its impact was consensus determinism and branch validation.
Why the in-process RPC did not decide the result
The PoC used an in-process RPC boundary to return deterministic ABI-encoded governance contract responses.
That boundary did not return:
Accept
Reject
Valid block
Invalid block
The decision came from the real code path:
Ethash.VerifyHeader
wemixminer.VerifyBlockSig
wemix.verifyBlockSig
wemixAdmin.getRegGovEnvContracts
Generated governance bindings
coinbaseExists
The proof also used:
Real crypto.Sign
Real crypto.Ecrecover
An invalid-signature control under snapshot B was rejected.
That control confirms the verifier was not configured to accept every candidate.
The unchanged candidate passed only when the number-scoped governance response authorized its real signer.
The test command was:
go test ./wemix \
-run TestCanon \
-count=1 \
-v
The test passed through the real validation path.
The reorg failure scenario
The practical sequence is:
1. Branch A is canonical locally at height N
2. Branch B is a competing branch at height N
3. Governance differs between A and B
4. Candidate B at height N plus one is signed by a validator authorized on branch B
5. A node validates candidate B while A remains canonical locally
6. WEMIX requests governance using BlockNumber N
7. The global client returns branch A governance
8. Candidate B is rejected against branch A’s validator set
9. The same candidate is evaluated after branch B becomes canonical locally
10. The same number-scoped query now returns branch B governance
11. The candidate is accepted
This creates a circular dependency:
A branch must be validated before it can replace the canonical branch
But its candidate may be accepted only after local canonical governance already matches it
The PoC demonstrates the validation primitive behind that failure.
It does not claim that a completed live-network reorg attack was executed.
What the proof establishes
The proof demonstrates:
Governance is read by block number
The candidate parent hash is not used for that governance read
The candidate remains byte-for-byte unchanged between validations
The real validator-signature path rejects it under snapshot A
The same path accepts it under snapshot B
An invalid signature remains rejected
The boundary RPC does not inject the final decision
The proof does not demonstrate:
Direct fund loss
Unauthorized token minting
A permanent chain split
A permanent network shutdown
A completed live-network reorg attack
Permanent rejection of every side branch
Standalone exploitation requires a competing-branch or reorg condition in which governance differs at the same height.
Those limitations are why the report was submitted as Major rather than Critical.
Why this is Major, not Low
A Low classification may fit a local cache inefficiency, an informational RPC discrepancy, or a behavior that cannot change a consensus decision.
This report demonstrated the opposite.
The same candidate received opposite validation results
The candidate header, hash, parent, root, coinbase, and signature remained unchanged.
Only the canonical governance response changed.
One validation rejected the candidate.
The other accepted it.
Validator eligibility came from the wrong branch
Validator authorization is part of the SPoA consensus rule.
A candidate signed by a validator authorized in its branch snapshot can be rejected because the node consulted governance from another branch at the same number.
That is not peripheral application behavior.
The flaw affects reorg safety
Side-branch validation is precisely where number-only canonical reads become unsafe.
A reorg-aware validator must follow candidate ancestry.
A canonical query can obstruct validation of the branch being considered.
The production validation path made the decision
The proof did not stop at code inspection.
It exercised the real header entrypoint, WEMIX signature verifier, governance resolver, generated bindings, and cryptographic signing and recovery.
Local canonical state became a hidden consensus input
Two nodes with different canonical governance views could evaluate the same candidate differently.
The same node could also produce opposite results before and after its canonical view changed.
Consensus decisions should not depend on that undeclared input.
Major is proportionate
The proof did not show permanent consensus failure, direct theft, or total network shutdown.
Critical would overstate the evidence.
Low understates a branch-dependent validator-authorization decision in the production consensus path.
Major matches the demonstrated transient consensus and reorg-safety 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 demonstrated state transition:
Same candidate
Same candidate hash
Same parent hash
Same signature
Canonical snapshot A
Unauthorized block
Canonical snapshot B
Accepted
The strongest proven effect was consensus validation using governance from the wrong branch.
That is a Major protocol weakness, not a minor implementation detail.
Why I describe the WEMIX handling as systematic downgrading
Severity disagreements are normal in bug bounty programs.
My concern is the recurring outcome 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 showed that the same candidate was rejected and accepted depending only on the local canonical governance snapshot.
It was classified as Low.
I cannot infer intent from that decision.
I can document the repeated result and compare the final classification with the technical evidence.
Calling it systematic downgrading describes that recurring pattern across my WEMIX submissions without claiming a motive.
A technically convincing Low justification would need to explain why these effects are minor:
Validator eligibility read from the wrong branch
The same candidate producing opposite validation results
Candidate parent hash absent from governance reads
Height-only caches mixing branch-specific state
Reorg validation depending on the local canonical branch
The real consensus path making the final decision
Without addressing those facts, the Low classification does not match the proof.
The correct fix
Every consensus-critical governance read must be scoped to the candidate branch.
The primary validator path should propagate the candidate parent hash through:
verifyHeader
VerifyBlockSig
verifyBlockSig
Governance contract resolution
Validator lookup
Governance contract calls should execute against the candidate parent hash or against a state reader derived from that parent.
A global canonical call using only BlockNumber must not decide validator eligibility for a side-branch candidate.
Branch identity must also reach caches and history
Cache keys based only on height or governance modification number are insufficient.
A branch-safe key should include:
Candidate parent hash
Governance state root
Parent hash plus governance version
Previous-miner history must follow the candidate ancestry through parent hashes rather than canonical HeaderByNumber calls.
The same rule applies to other consensus-controlled parameters read from governance.
The migration should be fork-safe
Changing consensus-state resolution can affect historical behavior.
The reference correction therefore places the branch-scoped path behind a future fork height when compatibility requires it.
Before activation:
Historical blocks retain the legacy number-scoped rule
After activation:
Candidate validation resolves governance from candidate parent state
Historical blocks and known reorg cases should be tested before activation.
Regression tests
A complete correction should verify:
The same candidate always returns the same result for the same candidate parent state
Governance is resolved by candidate parent hash rather than canonical block number
A branch B validator is accepted when branch B parent governance authorizes it
The validator is rejected when the candidate’s own parent governance does not authorize it
Changing the local canonical head does not change validation of an unchanged candidate
Invalid signatures remain rejected
Validator caches cannot collide across branches at the same height
Previous-miner lookup follows candidate ancestry
Governance-controlled block parameters are branch-scoped
Historical pre-fork validation remains compatible
Reorg validation works when governance differs across competing branches
No consensus-critical helper silently falls back to canonical
HeaderByNumber
The central invariant is:
Candidate validation must depend on the candidate branch, not on whichever branch is canonical locally.
Broader lessons
A block number does not uniquely identify state during a reorg
Multiple branches can contain different state at the same height.
Consensus code needs a block hash or an explicit branch-state reference.
Canonical RPCs are unsafe inside side-branch validation
A global client naturally answers canonical questions.
Validating a competing branch is not a canonical query.
Cache keys are part of consensus correctness
A cache keyed only by height can merge state from different branches.
Caching can preserve a branch-scoping error long after the original lookup.
Smart-contract governance can become consensus state
When contract calls determine validator eligibility, miner limits, gas rules, rewards, or block parameters, those calls are no longer ordinary application reads.
They must follow consensus ancestry exactly.
Determinism requires eliminating hidden inputs
The local canonical head was an undeclared input to candidate validation.
The PoC made that dependency observable.
Conclusion
WEMIX SPoA resolved candidate governance through a global client using only BlockNumber.
For a candidate at height 101, it read canonical governance at height 100 instead of resolving governance from the candidate parent hash.
The proof kept the candidate unchanged:
Same header
Same hash
Same parent hash
Same root
Same coinbase
Same MinerNodeSig
Under canonical snapshot A, the candidate was rejected.
Under canonical snapshot B, the same candidate was accepted.
The program classified the report as Low and paid $100.
![]()
The demonstrated impact was Major.
The report did not claim direct theft, permanent chain divergence, or a completed live-network reorg attack.
It demonstrated a narrower but fundamental failure:
The real validation path changed its answer because local canonical governance changed, even though the candidate did not.
A consensus validation result that changes without the candidate or its parent changing is not a Low-severity issue.
Top comments (0)