A node that asks one peer for a cluster response should not accept the first answer broadcast by somebody else.
WEMIX etcdJoin did exactly that.
The function selected a specific peer, sent admin_requestEtcdAddMember for that peer’s ID, and then waited for a response through a global feed:
ch := make(
chan string,
16,
)
sub :=
wemixapi.SubscribeToEtcdCluster(
ch,
)
That feed carried only:
Cluster string
It did not carry:
Sending peer ID
Request ID
Pending join identity
Meanwhile, every accepted partner EtcdCluster packet was published into the same feed:
go wemixapi.GotEtcdCluster(
cluster,
)
As a result, another WEMIX partner peer could send an unsolicited cluster string before the legitimate response arrived.
The target would consume that value as though it answered the pending join.
The proof of concept demonstrated the real etcdJoin requesting:
legit-id
and then consuming an attacker value first.
The join returned:
not found
before the legitimate response was released.
A separate control showed that an attacker-influenced cluster containing the local node endpoint passed etcdFixCluster and became a valid InitialCluster candidate.
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 a harmless unsolicited packet.
It was request confusion in a privileged P2P control plane used for mining coordination.
The complete public report and proof of concept are available in the GitHub Gist.
The security invariant
WEMIX uses etcd as part of the coordination layer for mining nodes.
A node joining that cluster needs an InitialCluster configuration describing the expected members and endpoints.
The request-response invariant should be simple:
A join request sent for peer A must be completed only by a response correlated with peer A and that pending request.
Partner status alone is not enough.
A peer may be authorized to participate in the partner protocol without being authorized to answer every join request currently pending on the target.
EtcdJoin selected a legitimate peer
The request side knew which peer it wanted:
func (
ma *wemixAdmin,
) etcdJoin(
name string,
) error {
var node *wemixNode
ma.lock.Lock()
for _, i :=
range ma.nodes {
if i.Name == name ||
i.Enode == name ||
i.Id == name ||
i.Ip == name {
node = i
break
}
}
ma.lock.Unlock()
if node == nil {
return ethereum.NotFound
}
The function later issued:
err :=
admin.rpcCli.CallContext(
ctx,
nil,
"admin_requestEtcdAddMember",
&node.Id,
)
In the PoC, the selected peer was:
Name
legit
ID
legit-id
The RPC boundary confirmed that etcdJoin requested the correct ID.
The failure was not incorrect peer selection.
The failure was losing that identity while waiting for the response.
The response path discarded correlation
The subscription API exposed a process-wide feed of strings:
func SubscribeToEtcdCluster(
ch chan string,
) event.Subscription {
return etcdClusterFeed.Subscribe(
ch,
)
}
func GotEtcdCluster(
cluster string,
) {
etcdClusterFeed.Send(
cluster,
)
}
The consumer received no origin information.
Once a value entered the feed, etcdJoin could not determine:
Which peer sent it
Which request it answered
Whether it was solicited
Whether it arrived for another concurrent operation
This is the root cause.
A broadcast notification channel was used as though it were a correlated response channel.
Any accepted partner packet entered that feed
The P2P handler checked whether both the local node and the sending peer were WEMIX partners:
func handleEtcdCluster(
backend Backend,
msg Decoder,
peer *Peer,
) error {
if !wemixminer.AmPartner() ||
!wemixminer.IsPartner(
peer.ID(),
) {
return nil
}
var cluster string
if err :=
msg.Decode(
&cluster,
);
err != nil {
return fmt.Errorf(
"%w: message %v: %v",
errDecode,
msg,
err,
)
}
go wemixapi.GotEtcdCluster(
cluster,
)
return nil
}
The partner check is a real exploitation precondition.
The report does not claim that an arbitrary public peer can reach this path.
But after the check, peer.ID() was discarded.
Only the cluster string survived.
The code transformed:
A message from partner X
into:
An originless global event
That allowed one partner to satisfy a request intended for another.
First response wins
The relevant response branch inside etcdJoin was:
case cluster :=
<-ch:
cluster, err :=
ma.etcdFixCluster(
cluster,
)
if err != nil {
log.Error(
"etcd failed to join",
"error",
err,
)
return err
}
cfg :=
ma.etcdNewConfig(
false,
)
cfg.InitialCluster =
cluster
etcd, err :=
embed.StartEtcd(
cfg,
)
The first delivered value controlled the attempt.
If etcdFixCluster rejected it, etcdJoin returned immediately.
It did not ignore the unrelated packet and continue waiting for the expected peer.
If the value passed parsing, the result became:
cfg.InitialCluster =
cluster
The code never compared the response against:
node.Id
Expected partner
Pending request ID
The parser could reject or preserve attacker input
etcdFixCluster required the local node to appear in the candidate cluster.
If the local entry was absent, it returned:
ethereum.NotFound
If the local endpoint was present, the function could normalize that entry while preserving other supplied members.
The PoC used two attacker strings.
Deterministic stall input
evil=https://203.0.113.10:2380
This omitted the local node.
The real parser rejected it.
Because etcdJoin treated the packet as the awaited response, the whole join attempt returned before the legitimate response arrived.
Redirect candidate input
evil=https://203.0.113.10:2380,=https://127.0.0.1:50001
This included:
An attacker-controlled member
The expected local endpoint
The parser accepted it and returned a fixed cluster candidate.
The proof deliberately did not start a persistent etcd process with that value.
That kept the test deterministic and bounded.
The directly demonstrated result is therefore:
The wrong peer can terminate the real join
and:
An attacker-influenced redirect candidate can pass the real parser
The article does not claim that the PoC maintained a live malicious etcd cluster.
The complete stalled-join sequence
The proof executed this flow:
1. The target called etcdJoin("legit")
2. EtcdJoin selected legit-id
3. It subscribed to the global EtcdCluster feed
4. It sent admin_requestEtcdAddMember for legit-id
5. The legitimate response was delayed
6. The attacker cluster was emitted first through GotEtcdCluster
7. The global feed delivered that value to the real etcdJoin
8. EtcdFixCluster returned not found
9. EtcdJoin returned before the legitimate response was released
10. The later legitimate response could not complete the finished attempt
The recorded result was:
RPC requested peer ID
legit-id
Malicious cluster emitted first
True
EtcdJoin consumed attacker cluster first
True
EtcdJoin returned before legitimate response
True
Join error
not found
That is deterministic request confusion.
The legitimate response arrived too late
The in-process RPC boundary delayed the legitimate cluster until after the attacker value had already caused etcdJoin to return.
The test then released:
wemixapi.GotEtcdCluster(
r.legit,
)
The legitimate cluster itself was valid.
The real parser accepted it in the direct control.
But the request had already been completed by the wrong value.
The bug was therefore not:
The legitimate peer returned malformed data
It was:
The consumer accepted an unrelated response first
The protocol already recognized request IDs
The P2P layer contained request-tracking machinery:
func (
p *Peer,
) RequestEtcdAddMember() error {
id :=
rand.Uint64()
requestTracker.Track(
p.id,
p.version,
EtcdAddMemberMsg,
EtcdClusterMsg,
id,
)
return p2p.Send(
p.rw,
EtcdAddMemberMsg,
common.Big1,
)
}
The eth66 response shape also contained:
type EtcdClusterPacket66 struct {
RequestId uint64
EtcdClusterPacket
}
The problem was not the complete absence of a correlation concept in the codebase.
The problem was that etcdJoin ultimately consumed only:
chan string
By the time the cluster reached the waiting join, peer and request context were gone.
What the PoC used
The proof used the real:
wemixAdmin.etcdJoin
SubscribeToEtcdCluster
GotEtcdCluster
etcdFixCluster
Node-selection logic
First-response consumption behavior
An in-process RPC boundary was used only to:
Record admin_requestEtcdAddMember
Confirm legit-id was requested
Delay the legitimate response
It did not publish the malicious value.
It did not decide the final error.
The attacker value entered through the same GotEtcdCluster sink used by the real P2P handler after partner validation.
The real etcdJoin consumed it.
The real etcdFixCluster returned ethereum.NotFound.
The command was:
go test ./wemix \
-run TestEtcd \
-count=1 \
-v
The test passed.
The controls isolated the issue
The PoC verified:
Real EtcdJoin used
True
Real EtcdFixCluster used
True
Real global feed used
True
Requested peer
legit-id
Feed contains peer identity
False
Feed contains request ID
False
Join consumed attacker first
True
Join returned before legitimate response
True
Legitimate cluster passes parser
True
Invalid attacker cluster fails parser
True
Attacker redirect candidate passes parser
True
Persistent etcd process started in primary path
False
These controls matter because they separate three questions:
Was the correct peer requested?
Yes
Did the consumer know who answered?
No
Could the attacker value control the result?
Yes
What the proof establishes
The proof demonstrates:
EtcdJoin selected a specific legitimate peer
The RPC request contained legit-id
The response feed had no peer binding
The response feed had no request binding
An unsolicited attacker value arrived first
The real join consumed it
The real join returned before the legitimate response
A malformed attacker value caused deterministic join failure
An attacker-influenced cluster passed the real parser
The proof does not demonstrate:
Direct fund theft
A completed live-network exploit
Total network shutdown
Permanent consensus failure
A persistent attacker-controlled etcd process
Exploitation by a non-partner peer
Those limits are why the report was submitted as Major rather than Critical.
Why this is Major, not Low
The impact is a privileged P2P control-plane failure affecting mining coordination.
The request was completed by the wrong peer
The target requested legit-id.
The consumer accepted an originless value that could have come from another partner.
That is a failure of request-response integrity.
The join failed through the real path
The attacker value was not rejected as unrelated.
It reached etcdFixCluster.
The function returned not found.
The real etcdJoin terminated before the legitimate response.
The attacker could influence the cluster candidate
The redirect control passed the real parser while preserving the attacker-controlled member.
The PoC did not start etcd with that candidate, but it proved the value crossed the validation step that feeds InitialCluster.
The affected path coordinates mining nodes
This was not a UI or monitoring endpoint.
The function joins the etcd cluster used by mining nodes.
A failed or attacker-influenced join can prevent the target from coordinating as intended and can contribute to missed production opportunities.
The attack is privileged but not administrative
A real exploit requires a WEMIX partner peer.
It does not require:
Governance control
Administrator credentials
Validator private keys
Public RPC manipulation
Partner membership should permit protocol participation.
It should not permit one peer to impersonate another peer’s response.
The failure can be raced again
A failed attempt requires another join.
Without correlation, the same class of unsolicited partner packet can race the next attempt as well.
Low does not fit the demonstrated result
Low would fit:
An unsolicited packet ignored before use
A stale status update
A harmless parser discrepancy
A message with no effect on a pending operation
A UI-only failure
This report showed the real mining-coordination join being terminated by the wrong response.
That is Major.
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:
Requested peer
legit-id
Consumed response
Attacker cluster
Peer identity in feed
None
Request ID in feed
None
Join returned before legitimate response
True
Join result
not found
Attacker-influenced redirect candidate accepted
True
The strongest directly demonstrated impact was a malicious partner controlling the result of another peer’s pending mining-coordination join.
That is a Major control-plane and liveness issue.
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 the real etcdJoin requesting legit-id, consuming an unsolicited attacker value, and returning before the legitimate response.
It also showed that the consumer received neither peer identity nor request ID and that an attacker-influenced redirect candidate passed etcdFixCluster.
The report was classified as Low.
I cannot infer intent from that decision.
I can document the repeated outcome 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:
One partner satisfying another peer’s pending request
No peer binding
No request-ID binding
The real join consuming the attacker value
The join returning before the legitimate response
Attacker-controlled membership surviving parser validation
Mining coordination for the target being stalled or influenced
Without addressing those facts, the Low classification does not match the proof.
The correct fix
The response consumed by etcdJoin must preserve correlation context.
A structured response can carry:
type EtcdClusterResponse struct {
PeerID string
RequestID uint64
Cluster string
}
The pending join should record:
Expected peer ID
Expected request ID
The handler should preserve:
Actual sending peer ID
Received request ID
Cluster string
The waiting join must ignore any response where:
PeerID does not match
or:
RequestID does not match
The existing global string feed may remain for telemetry or legacy observers.
It must not control a correlated join operation.
Safer lifecycle
The corrected flow should be:
1. EtcdJoin selects legit-id
2. The request receives a unique request ID
3. The pending operation stores both values
4. A partner response arrives
5. The handler preserves peer and request identity
6. EtcdJoin rejects unrelated peers
7. EtcdJoin rejects mismatched request IDs
8. Only the matching cluster reaches etcdFixCluster
9. Only the validated matching result becomes InitialCluster
An unsolicited packet can then be logged or penalized without completing the join.
Regression tests
A complete correction should verify:
The expected peer with the correct request ID completes the join
Another partner’s response is ignored
A mismatched request ID is ignored
An unsolicited packet arriving first cannot terminate the operation
An unrelated malformed cluster cannot make
etcdJoinreturnAn attacker-influenced cluster from the wrong peer cannot become
InitialClusterConcurrent joins cannot consume each other’s responses
Timeout behavior remains bounded when no matching response arrives
The central invariant is:
Only a response matching both the intended peer and the pending request may complete an etcd join.
Broader lessons
Partner status is not response correlation
A partner can be authorized to use a privileged protocol without being authorized to answer every pending request.
Global feeds are wrong for correlated responses
Broadcast feeds are useful for notifications.
They are unsafe when exactly one response must complete exactly one request.
First response wins is not authentication
Timing decided which cluster value etcdJoin trusted.
A peer that arrived first controlled the attempt.
Control-plane messages need context
A cluster string is not enough.
The consumer also needs:
Who sent it
Which request it answers
Targeted liveness failures still matter
A vulnerability does not need to stop the entire network to affect blockchain operations.
Disrupting one mining node’s coordination path can still cost production opportunities and degrade liveness for that target.
Conclusion
The target selected:
Peer
legit
Peer ID
legit-id
But it waited on a global feed carrying only:
Cluster string
A malicious partner emitted an unsolicited value first.
The real etcdJoin consumed it.
The feed contained no peer identity.
It contained no request ID.
The join returned not found before the legitimate response arrived.
A separate control proved that an attacker-influenced cluster containing the local endpoint passed etcdFixCluster.
The primary PoC did not start a persistent malicious etcd process.
It proved the underlying defect:
The first accepted partner cluster string could complete a pending join regardless of which peer or request it belonged to.
The program classified the report as Low and paid $100.
![]()
The demonstrated impact was Major.
The report did not claim theft, total network shutdown, or permanent consensus failure.
It demonstrated a narrower and directly proven failure:
A malicious partner peer could terminate another node’s mining-coordination join and could supply an attacker-influenced cluster candidate by winning an uncorrelated response race.
A privileged P2P response-confusion bug that controls a mining node’s join attempt is not a Low-severity issue.
Top comments (0)