DEV Community

Daniel
Daniel

Posted on

How WEMIX Kept Accepting a Removed Validator as an Active Signer

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

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

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

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

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

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

and changed:

voter = Vb

reward = Rb
Enter fullscreen mode Exit fullscreen mode

The resulting mappings became:

stakerIdx[B] = active index

voterIdx[Vb] = active index

rewardIdx[Rb] = active index

voterIdx[B] = 0

rewardIdx[B] = 0
Enter fullscreen mode Exit fullscreen mode

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

At this point, the function knew:

The correct member index

The current voter address

The current reward address
Enter fullscreen mode Exit fullscreen mode

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

The voter path later used:

removeIdx =
    voterIdx[oldStaker];
Enter fullscreen mode Exit fullscreen mode

After the supported self-change, both lookups returned zero:

rewardIdx[oldStaker] = 0

voterIdx[oldStaker] = 0
Enter fullscreen mode Exit fullscreen mode

The correct keys were:

rewardIdx[oldReward]

voterIdx[oldVoter]
Enter fullscreen mode Exit fullscreen mode

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

At that moment, removeIdx still held zero from the voter lookup.

The code later changed the numeric variable:

removeIdx =
    nodeIdxFromMember[oldStaker];
Enter fullscreen mode Exit fullscreen mode

But updating removeIdx did not rebind the existing storage reference.

node still pointed to:

nodes[0]
Enter fullscreen mode Exit fullscreen mode

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

After corruption, the client could read:

Index 2 member
C

Index 2 reward
Rb

Index 2 enode
B_enode
Enter fullscreen mode Exit fullscreen mode

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

After B had been removed:

enodeExists(B_enode)
Enter fullscreen mode Exit fullscreen mode

did not return NotFound.

It returned the attacker-selected reward address:

Rb
Enter fullscreen mode Exit fullscreen mode

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

The corrupted index therefore produced:

Staker
C

Reward
Rb

Stake
LockedBalanceOf(C)
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

The test ran with:

gofmt -w wemix/drift_test.go

go test ./wemix \
    -run '^TestDrift$' \
    -count=1 \
    -v
Enter fullscreen mode Exit fullscreen mode

and passed:

--- PASS: TestDrift
PASS
ok github.com/ethereum/go-ethereum/wemix
Enter fullscreen mode Exit fullscreen mode

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

In the control:

B_enode was rejected

Rb received no reward

C remained correctly aligned
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

not:

rewardIdx[oldStaker]
Enter fullscreen mode Exit fullscreen mode

The voter entry should use:

voterIdx[oldVoter]
Enter fullscreen mode Exit fullscreen mode

not:

voterIdx[oldStaker]
Enter fullscreen mode Exit fullscreen mode

The node storage pointer must be created only after loading the correct node index.

The implementation should use separate variables:

removeStakerIdx

removeRewardIdx

removeVoterIdx

removeNodeIdx
Enter fullscreen mode Exit fullscreen mode

One mutable index should not be reused across four parallel structures.

Required regression tests

A complete fix should verify:

  1. Same-staker voter changes remain removable

  2. Same-staker reward changes remain removable

  3. Removing a non-final member keeps every array aligned

  4. The moved validator retains its own voter, reward, node, and stake identity

  5. The removed enode is rejected by enodeExists

  6. The removed reward address receives zero

  7. Every active index describes one logical validator

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

while leaving:

stakerIdx[oldStaker]
Enter fullscreen mode Exit fullscreen mode

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)