Removing a validator is a revocation operation.
After governance removes a validator, the network must stop recognizing its signing identity, stop associating it with an active reward address, and stop treating it as part of the validator set.
A WEMIX GovImp removal could fail at all three boundaries.
A validator could first use the contract-supported self-change flow to keep the same staker while replacing its voter and reward addresses. If governance later removed that validator through the normal vote process, index corruption across stakers, voters, rewards, and nodes could leave the removed enode inside the active range consumed by go-wemix.
The real client-side validation path enodeExists still recognized that enode as active.
The reward path also paired another active validator’s stake with the reward address chosen by the removed validator.
I submitted this report as Critical through the WEMIX bug bounty program hosted on CertiK Skynet.
The program classified it as Medium and paid a $500 bounty.
That classification does not match the demonstrated impact.
This was not a stale getter, an isolated reward mismatch, or an administrative inconvenience. It was a failed validator revocation that reached the client-side signer authorization path.
The full public report and proof of concept are available in the GitHub Gist.
The invariant governance must preserve
WEMIX stored validator information in parallel structures:
stakers[i]
voters[i]
rewards[i]
nodes[i]
Every active index was expected to describe one logical validator.
For any active index i, these values should remain aligned:
getMember(i)
getVoter(i)
getReward(i)
getNode(i)
The Go client relied on that alignment when it built the active enode cache and reward parameters.
The essential invariant was:
Removing a validator must atomically revoke every signing, voting, staking, node, and reward identity attached to that validator.
After the vulnerable sequence, one active index could instead contain:
Member
Moved validator C
Voter
Removed validator B’s chosen voter Vb
Reward
Removed validator B’s chosen reward Rb
Node
Removed validator B’s enode
The index still looked active.
The client still trusted it.
Step one: a supported validator self-change
GovImp allowed an existing staker to update its own validator information when:
msg.sender == oldStaker
&&
oldStaker == newInfo.staker
The staker address remained unchanged, while voter and reward addresses could change.
The contract finalized this same-staker path without a normal governance vote:
if (
msg.sender == oldStaker
&&
oldStaker == newInfo.staker
) {
require(
unlockAmount == 0
&&
slashing == 0,
"Invalid proposal"
);
}
...
if (
msg.sender == oldStaker
&&
oldStaker == newInfo.staker
) {
(, , uint256 duration) =
getBallotPeriod(ballotIdx);
startBallot(
ballotIdx,
block.timestamp,
block.timestamp + duration
);
finalizeVote(
ballotIdx,
uint256(
BallotTypes.MemberChange
),
true,
true
);
}
This was a normal contract-supported operation.
Assume validator B initially used the same address for staker, voter, and reward.
It then kept:
staker = B
and changed:
voter = Vb
reward = Rb
The resulting mappings became:
stakerIdx[B] = active index
voterIdx[Vb] = active index
rewardIdx[Rb] = active index
voterIdx[B] = 0
rewardIdx[B] = 0
The contract itself created the state needed for the later corruption.
Step two: honest governance removes the validator
Other governance members later approved a normal proposal to remove B.
The exploit did not require governance compromise.
removeMember began with the correct staker index and loaded the current voter and reward addresses:
uint256 removeIdx =
stakerIdx[oldStaker];
address oldVoter =
voters[removeIdx];
address oldReward =
rewards[removeIdx];
At this point, the function knew:
The correct member index
The current voter address
The current reward address
But the later removal logic did not use those current addresses as lookup keys.
Reward and voter removal used the wrong identity
The reward path reassigned removeIdx through:
removeIdx =
rewardIdx[oldStaker];
The voter path later used:
removeIdx =
voterIdx[oldStaker];
After the supported self-change, both lookups returned zero:
rewardIdx[oldStaker] = 0
voterIdx[oldStaker] = 0
The correct keys were:
rewardIdx[oldReward]
voterIdx[oldVoter]
Because the function used oldStaker, it moved unrelated tail entries into index zero while leaving Rb and Vb inside the active getter range.
The staker array was still updated through the correct staker index.
The parallel arrays stopped describing the same validator.
The node path retained the removed enode
The node-removal logic had a separate index bug.
It created a storage pointer before loading the correct node index:
Node storage node =
nodes[removeIdx];
At that moment, removeIdx still held zero from the voter lookup.
The code later changed the numeric variable:
removeIdx =
nodeIdxFromMember[oldStaker];
But updating removeIdx did not rebind the existing storage reference.
node still pointed to:
nodes[0]
The function copied the last node into the wrong storage slot.
The removed validator’s enode remained inside the active node range.
The corrupted state reached the real client
The impact did not stop at Solidity storage.
go-wemix built its active enode cache by iterating over the active node range and reading GetReward(i) together with GetNode(i):
for i := int64(1);
i <= count.Int64();
i++ {
ix := big.NewInt(i)
addr, err =
gov.GetReward(
opts,
ix,
)
output, err :=
gov.GetNode(
opts,
ix,
)
e.nodes = append(
e.nodes,
&wemixNode{
Name:
string(
output.Name,
),
Enode:
string(
output.Enode,
),
Addr:
addr,
},
)
e.enode2index[
string(
output.Enode,
)
] =
int(i)
}
After corruption, the client could read:
Index 2 member
C
Index 2 reward
Rb
Index 2 enode
B_enode
The signer lookup then used that cache:
func enodeExists(
ctx context.Context,
height *big.Int,
gov *gov.GovImp,
enode []byte,
) (
common.Address,
error,
) {
e, err :=
getCoinbaseEnodeCache(
ctx,
new(big.Int).Sub(
height,
common.Big1,
),
gov,
)
ix, ok :=
e.enode2index[
string(enode)
]
if !ok {
return common.Address{},
ethereum.NotFound
}
return e.nodes[ix-1].Addr,
nil
}
After B had been removed:
enodeExists(B_enode)
did not return NotFound.
It returned the attacker-selected reward address:
Rb
That is the central security failure.
Governance revoked the validator.
The client-side active-signer path still accepted its enode.
Reward calculation used another validator’s stake
The reward parameter builder read, at the same active index:
getMember(i)
getReward(i)
LockedBalanceOf(member)
The corrupted index therefore produced:
Staker
C
Reward
Rb
Stake
LockedBalanceOf(C)
The reward-distribution path later credited the configured reward address.
The proof demonstrated:
Removed B locked stake
0
Moved C locked stake
1500000000000000000000000
Removed reward recipient delta
+250000000000000000
The removed validator was no longer a member and had no locked stake.
Its selected reward address still received a positive calculated reward based on another active validator’s stake.
The unauthorized reward calculation was not the primary issue.
It was additional evidence that the index corruption reached security-critical client logic.
The complete attack path
The sequence was:
1. Governance contains validators A, B, and C
2. B occupies active index 2
3. C occupies the final active index 3
4. B keeps staker B but changes voter to Vb and reward to Rb
5. GovImp accepts and finalizes the self-change
6. voterIdx[B] and rewardIdx[B] become zero
7. stakerIdx[B] remains index 2
8. Honest governance members vote to remove B
9. removeMember moves C into staker index 2
10. Reward and voter removal look up oldStaker instead of oldReward and oldVoter
11. Rb and Vb remain at active index 2
12. The node pointer remains bound to nodes[0]
13. B_enode remains at active index 2
14. go-wemix builds its active cache from the corrupted arrays
15. enodeExists accepts B_enode and returns Rb
16. Reward calculation uses C’s stake while paying Rb
The attacker did not forge governance approval.
The system failed after honest governance explicitly revoked the attacker.
What the proof of concept used
The Go proof used:
A real in-memory EVM backend
The real WEMIX governance contracts
Real GovImp initialization
Real staking bindings
The real same-staker member-change path
A real member-removal proposal
Real governance votes from non-attacker members
Real governance getter reads
The real getCoinbaseEnodeCache path
The real enodeExists path
The real reward-parameter builder
The real reward calculation path
It did not use:
Direct storage writes
A fake governance contract
External RPC control
Mainnet-specific state
Testnet-specific state
Governance compromise
Compromise of other validator operators
The test ran with:
gofmt -w wemix/drift_test.go
go test ./wemix \
-run '^TestDrift$' \
-count=1 \
-v
and passed:
--- PASS: TestDrift
PASS
ok github.com/ethereum/go-ethereum/wemix
The local SimulatedBackend used the PoW consensus setting only to prevent the test harness from invoking the global PoA reward hook before the WEMIX admin singleton was initialized.
The proof still called the real WEMIX governance, signer-cache, enode-validation, staking, and reward-calculation functions directly.
The clean control isolated the cause
The proof also included a control scenario:
Remove B without the prior same-staker self-change
In the control:
B_enode was rejected
Rb received no reward
C remained correctly aligned
That eliminated several alternative explanations.
The vulnerable result was not caused by ordinary member removal, the simulated backend, or generic reward behavior.
The differentiator was the supported self-change followed by removal.
Why this is Critical, not Medium
A Medium classification would be defensible if the issue were limited to:
A stale getter
A display inconsistency
A temporary reward-accounting mismatch
An inactive node that no security path accepts
A bug requiring governance compromise
The proof demonstrated a different class of impact.
Validator revocation failed
Governance removed B.
The active-signer lookup still accepted B_enode.
The security purpose of validator removal is to revoke validator authority.
When the client gate that recognizes active enodes continues accepting the removed identity, revocation has failed.
The corrupted state reached consensus-relevant authorization
The report did not stop at mismatched arrays.
It called the real getCoinbaseEnodeCache and enodeExists paths.
The removed enode remained in the client’s active cache and resolved successfully.
This does not claim that the PoC produced a malicious block, achieved majority control, or completed a chain takeover.
It proves the prerequisite authorization failure: a validator that governance removed remained accepted by the path responsible for recognizing active enodes.
That is already a compromise of the validator-set boundary.
The attack survived honest governance
The later removal was approved by non-attacker validators through the normal process.
The attacker did not bypass a vote.
The bug defeated the result of that vote.
A validator being previously authorized is not a reason to reduce severity.
Removed validators are precisely the actors that revocation logic must stop trusting.
The proof was end to end across contract and client
The PoC deployed the actual governance contracts and reached the actual Go functions that consume their state.
It demonstrated:
Legitimate self-change
Legitimate removal
Corrupted active index
Removed enode accepted by enodeExists
Unauthorized reward attribution
This is stronger than a theoretical storage-corruption argument.
Reward redirection confirms economic impact
The reward address chosen by the removed validator received a positive calculated delta while the removed validator had zero stake.
The reward path used another validator’s stake.
Even if the signer-authorization impact were ignored, the same bug produced unauthorized economic benefit.
The preconditions are normal validator lifecycle events
The attacker must:
Already be a validator
Occupy a non-final active index
Change voter or reward while keeping the same staker
Later be removed by governance
These conditions narrow exploitability.
They do not reduce the impact to Medium.
The vulnerability activates at the exact moment the protocol attempts to revoke a potentially malicious validator.
Why the $500 classification understates the issue
The final Medium decision produced a $500 bounty.
That records the program’s outcome.
It does not change what the proof demonstrated:
The removal vote succeeded
The removed validator lost membership
The removed validator had zero locked stake
Its enode remained inside the active range
The real client accepted the enode
Its chosen reward address received a positive calculated reward
Severity should follow the strongest proven security impact.
The central question is not how large the local reward delta was.
It is:
What authority remained after governance explicitly revoked the validator?
The answer was active-enode recognition by the client path.
Under the impact criteria used in the report, that matches Critical consensus and transaction-manipulation risk through validator-set corruption.
Why I describe the WEMIX handling as systematic downgrading
Severity disagreements happen in every bug bounty program.
My concern with WEMIX is the repeated pattern across my own valid submissions: reports supported by reproducible proofs and concrete protocol impact were repeatedly assigned substantially lower final severities.
This report is the clearest example.
The proof crossed the full boundary from governance-contract corruption to real client-side acceptance and reward calculation.
It was still reduced from Critical to Medium.
I cannot infer motive from a severity decision.
I can document the pattern and compare the final classification with the demonstrated behavior.
Calling this systematic downgrading describes that repeated outcome across my WEMIX submissions. It does not depend on assuming intent.
A technically adequate Medium justification would need to explain why a removed validator remaining accepted by enodeExists is not a validator-set authorization failure.
Without addressing that evidence, the lower classification does not resolve the report’s primary impact.
Correcting the removal logic
The reward entry should be removed through:
rewardIdx[oldReward]
not:
rewardIdx[oldStaker]
The voter entry should use:
voterIdx[oldVoter]
not:
voterIdx[oldStaker]
The node storage pointer must be created only after loading the correct node index.
The implementation should use separate variables:
removeStakerIdx
removeRewardIdx
removeVoterIdx
removeNodeIdx
One mutable index should not be reused across four parallel structures.
Required regression tests
A complete fix should verify:
Same-staker voter changes remain removable
Same-staker reward changes remain removable
Removing a non-final member keeps every array aligned
The moved validator retains its own voter, reward, node, and stake identity
The removed enode is rejected by
enodeExistsThe removed reward address receives zero
Every active index describes one logical validator
Clean removal and removal after self-change produce equivalent revocation
The core postcondition is:
After removal, no contract getter or client path may recognize the removed validator’s enode or route rewards to an address retained from that validator.
Broader lessons
Parallel arrays form one logical record
When several arrays share an index, their entries must move atomically.
Different lookup keys create identity corruption.
Revocation is more security-critical than enrollment
Adding a validator grants authority.
Removing one must invalidate every representation of that authority.
Contract corruption can become consensus corruption
A Solidity indexing bug may look local until the blockchain client consumes it for signer authorization.
Impact analysis must follow the data to its final security consumer.
Existing validators remain adversarial
A validator is trusted only while authorized.
The removal path must assume the validator may have prepared state specifically to survive revocation.
Controls strengthen causal proof
The clean-removal scenario showed that ordinary removal worked and that the same-staker change created the divergence.
That is evidence a severity decision must address directly.
Conclusion
A WEMIX validator could keep the same staker while changing its voter and reward addresses through a supported self-change.
That operation cleared:
voterIdx[oldStaker]
rewardIdx[oldStaker]
while leaving:
stakerIdx[oldStaker]
active.
When honest governance later removed the validator, removeMember used the correct staker index but the wrong voter and reward lookup keys.
The node-removal path also retained a storage pointer bound to the wrong index.
The active arrays became misaligned.
go-wemix consumed that corrupted state.
The removed enode remained in the active cache.
enodeExists accepted it and returned the attacker-selected reward address.
The reward path used another validator’s stake while assigning a positive reward to that removed validator’s chosen address.
The program classified the report as Medium and paid $500.

The demonstrated impact was Critical.
Governance did not merely intend to remove the validator.
The removal transaction completed.
But the client still recognized the removed signing identity.
A validator that remains accepted after revocation has not been securely removed.
Top comments (0)