DEV Community

Daniel
Daniel

Posted on

How Unsigned Partner Metadata Could Make WEMIX Block Producers Build Invalid Blocks

A signed transaction should have exactly one sender.

That sender must come from the signature.

WEMIX introduced a TransactionsEx P2P packet that carried two pieces of data:

A signed transaction

A separate From address
Enter fullscreen mode Exit fullscreen mode

The From field was network metadata.

It was not part of the signed transaction bytes.

It did not change the transaction hash.

Yet when the packet came from a peer classified as a WEMIX partner, the client trusted that metadata and wrote it directly into the transaction sender cache:

if (trustIt) {
    i.Tx.from.Store(
        sigCache{
            signer: signer,
            from: i.From,
        },
    )
}
Enter fullscreen mode Exit fullscreen mode

That created a dangerous contradiction.

The transaction could be signed by the attacker while the cached sender was set to a victim.

Local execution would then treat the victim as the sender.

A fresh decode of the same signed transaction would recover the attacker.

The two executions produced different state roots.

If a block producer includes the poisoned transaction, it can compute a state root that honest nodes reject after recovering the sender from the signature.

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.

The proof did not show a harmless cache inconsistency.

It showed unauthenticated P2P metadata crossing into transaction execution, changing the sender used for gas and nonce accounting, and causing consensus-relevant state divergence.

The full public report and proof of concept are available in the GitHub Gist.

The sender must come from the signature

Ethereum-style transactions are signed objects.

The sender is recovered from the transaction signature under the applicable chain and signer rules:

V

R

S

Signing hash

Chain-specific signer rules
Enter fullscreen mode Exit fullscreen mode

A network peer may relay the transaction.

It must not define who signed it.

The invariant is simple:

A peer supplied field must never replace local cryptographic sender recovery.

WEMIX broke that invariant for partner peers.

The extra From field was not authenticated

The WEMIX transaction extension was:

type TransactionEx struct {
    Tx   *Transaction
    From common.Address `json:"from" rlp:"nil"`
}
Enter fullscreen mode Exit fullscreen mode

The signed transaction lived in:

TransactionEx.Tx
Enter fullscreen mode Exit fullscreen mode

The separate address lived in:

TransactionEx.From
Enter fullscreen mode Exit fullscreen mode

Those values were not cryptographically bound together.

A partner peer could provide:

Transaction signed by attacker

TransactionEx.From set to victim
Enter fullscreen mode Exit fullscreen mode

The transaction bytes remained unchanged.

The transaction hash remained unchanged.

Only the peer-controlled metadata claimed a different sender.

The real wire path preserved the forged metadata

TransactionEx.EncodeRLP serialized the transaction and then serialized the cached sender as the extra From field:

func (tx *TransactionEx) EncodeRLP(
    w io.Writer,
) error {
    if err := tx.Tx.EncodeRLP(w); err != nil {
        return err
    }

    var from common.Address

    if sc := tx.Tx.from.Load(); sc != nil {
        from =
            sc.(sigCache).from
    }

    return rlp.Encode(
        w,
        &from,
    )
}
Enter fullscreen mode Exit fullscreen mode

The receiver decoded the same structure:

func (tx *TransactionEx) DecodeRLP(
    s *rlp.Stream,
) error {
    tx.Tx =
        &Transaction{}

    if err := tx.Tx.DecodeRLP(s); err != nil {
        return err
    }

    return s.Decode(
        &tx.From,
    )
}
Enter fullscreen mode Exit fullscreen mode

The PoC used this real RLP path.

It created a transaction signed by the attacker, set the sender side cache to the victim solely to construct the extended wire metadata, and encoded a TransactionEx packet carrying:

From = victim
Enter fullscreen mode Exit fullscreen mode

After decoding:

Signed transaction bytes
Unchanged

Transaction hash
Unchanged

Decoded TransactionEx.From
Victim
Enter fullscreen mode Exit fullscreen mode

The metadata survived the wire path without becoming part of the signature.

Partner status activated the dangerous path

The P2P handler converted extended packets through:

txs :=
    types.TxExs2Txs(
        signer,
        txexs,
        wemixminer.IsPartner(
            peer.ID(),
        ),
    )
Enter fullscreen mode Exit fullscreen mode

The third argument was the trust decision:

trustIt = wemixminer.IsPartner(peer.ID())
Enter fullscreen mode Exit fullscreen mode

The report does not claim exploitation by any arbitrary internet peer.

The attacker model requires a peer that WEMIX treats as a partner.

That is a meaningful precondition.

It does not make the vulnerability Low.

Partner status can justify permission to connect or relay specialized packets.

It cannot safely grant permission to redefine the cryptographic sender of a signed transaction.

The sender cache accepted the victim without verification

The vulnerable converter was:

func TxExs2Txs(
    signer Signer,
    txs []*TransactionEx,
    trustIt bool,
) []*Transaction {
    var out []*Transaction

    for _, i := range txs {
        if trustIt {
            i.Tx.from.Store(
                sigCache{
                    signer: signer,
                    from: i.From,
                },
            )
        }

        out =
            append(
                out,
                i.Tx,
            )
    }

    return out
}
Enter fullscreen mode Exit fullscreen mode

When trustIt was true, the implementation did not call:

signer.Sender(i.Tx)
Enter fullscreen mode Exit fullscreen mode

It did not compare:

Recovered cryptographic sender

Peer-supplied From
Enter fullscreen mode Exit fullscreen mode

It simply stored the peer value as authoritative sender state.

That poisoned:

Transaction.from
Enter fullscreen mode Exit fullscreen mode

The cache bypassed signature recovery

The normal sender helper first checked the cache:

func Sender(
    signer Signer,
    tx *Transaction,
) (
    common.Address,
    error,
) {
    if sc := tx.from.Load(); sc != nil {
        sigCache :=
            sc.(sigCache)

        if sigCache.signer.Equal(
            signer,
        ) {
            return sigCache.from, nil
        }
    }

    addr, err :=
        signer.Sender(
            tx,
        )

    if err != nil {
        return common.Address{}, err
    }

    tx.from.Store(
        sigCache{
            signer: signer,
            from: addr,
        },
    )

    return addr, nil
}
Enter fullscreen mode Exit fullscreen mode

If the cached signer matched, types.Sender returned the cached address immediately.

There was no new signature recovery.

The PoC isolated this behavior with two controls:

trustIt = false
Sender recovered as attacker

trustIt = true
Sender returned as victim
Enter fullscreen mode Exit fullscreen mode

A fresh decode of the same signed transaction bytes returned:

Attacker
Enter fullscreen mode Exit fullscreen mode

The only difference was the poisoned local cache.

The poisoned value could propagate into txpool sender resolution

The sender resolver checked whether the transaction already carried a cached sender:

if addr :=
    types.GetSender(
        signer,
        tx,
    );
    addr != nil {
    s.tx2addr.Put(
        hash,
        *addr,
    )

    continue
}
Enter fullscreen mode Exit fullscreen mode

Once TransactionsEx had placed the victim into Transaction.from, this path could persist that address by transaction hash.

The transaction hash did not change, so the same signed transaction could remain locally associated with a sender who never signed it.

The PoC did not need to execute the complete TxPool.ResolveSenders pipeline. This section describes the downstream propagation visible in the production code. The execution divergence itself was directly demonstrated through core.ApplyTransaction.

The poisoned sender reached real execution

The PoC did not stop at demonstrating a bad helper return.

It reached:

core.ApplyTransaction
Enter fullscreen mode Exit fullscreen mode

The execution path converted the transaction into a message:

msg, err :=
    tx.AsMessage(
        types.MakeSigner(
            config,
            header.Number,
        ),
        header.BaseFee,
    )
Enter fullscreen mode Exit fullscreen mode

That conversion used sender recovery.

For the poisoned transaction, the cache returned the victim.

The state transition then used:

st.msg.From()
Enter fullscreen mode Exit fullscreen mode

for balance and gas handling:

if have, want :=
    st.state.GetBalance(
        st.msg.From(),
    ),
    balanceCheck;
    have.Cmp(want) < 0 {
    return fmt.Errorf(
        "%w: address %v have %v want %v",
        ErrInsufficientFunds,
        st.msg.From().Hex(),
        have,
        want,
    )
}

st.state.SubBalance(
    st.msg.From(),
    mgval,
)
Enter fullscreen mode Exit fullscreen mode

The peer-controlled metadata had reached the real transaction execution layer.

The same transaction executed as two different senders

The proof executed the exact same signed transaction bytes twice.

Poisoned partner execution

Cryptographic signer
Attacker

Cached sender
Victim

Gas charged to
Victim

Nonce incremented for
Victim
Enter fullscreen mode Exit fullscreen mode

Fresh honest execution

Cryptographic signer
Attacker

Recovered sender
Attacker

Gas charged to
Attacker

Nonce incremented for
Attacker
Enter fullscreen mode Exit fullscreen mode

Both executions were accepted locally.

They did not produce the same state.

The concrete PoC result

The transaction used:

Transaction type
DynamicFeeTx

Gas limit
21,000

Effective gas price
2 wei

Transaction value
0
Enter fullscreen mode Exit fullscreen mode

The direct debit was:

21,000 × 2 wei
=
42,000 wei
Enter fullscreen mode Exit fullscreen mode

In the poisoned execution:

Victim gas debit
42,000 wei

Victim nonce after execution
1

Attacker gas debit
0

Attacker nonce after execution
0
Enter fullscreen mode Exit fullscreen mode

In the fresh execution:

Attacker gas debit
42,000 wei

Attacker nonce after execution
1

Victim gas debit
0

Victim nonce after execution
0
Enter fullscreen mode Exit fullscreen mode

The PoC did not present that local victim debit as network accepted theft.

The important result was execution divergence.

The state roots were different

The poisoned execution produced:

0xda61d18fc59fe2eb48c73ebcd38d2540279eeae8317436a36eda976115dccdbe
Enter fullscreen mode Exit fullscreen mode

The fresh execution produced:

0xbe165c07292fb6d76231ecad028675c208201bd18c4ddabdd7a94ce6b385ed9e
Enter fullscreen mode Exit fullscreen mode

The roots differed because the sender-dependent state changes affected different accounts.

A target block producer using the poisoned cache could build a block using the first state root.

Honest nodes receiving the block transaction would not receive that producer’s in-memory sender cache.

They would decode the signed transaction, recover the attacker, compute the second state transition, and reject the block because the expected root did not match.

The complete attack sequence

The demonstrated and directly implied sequence was:

1. A malicious partner peer creates a valid transaction signed by the attacker

2. The peer supplies TransactionEx.From as a victim

3. The signed transaction bytes and hash remain unchanged

4. handleTransactionsEx treats the peer as a partner

5. TxExs2Txs runs with trustIt set to true

6. The victim is stored in Transaction.from without signature recovery

7. types.Sender returns the victim

8. ApplyTransaction executes the transaction locally with the victim as sender

9. Gas and nonce are applied to the victim state

10. A fresh decode of the same transaction recovers the attacker

11. Fresh execution applies gas and nonce to the attacker state

12. The two executions produce different state roots

13. A producer that commits the poisoned root builds a block honest nodes reject
Enter fullscreen mode Exit fullscreen mode

The PoC directly proved steps one through twelve with the real execution path. Step thirteen is the consensus consequence of committing a state root that honest nodes cannot reproduce from the canonical transaction bytes.

What the proof actually used

The Go proof used:

Real types.TransactionEx

Real TransactionEx RLP encoding

Real TransactionEx RLP decoding

Real types.TxExs2Txs

Real types.Sender

Real types.GetSender

Real DynamicFeeTx signature

Real core.ApplyTransaction

Real StateTransition.buyGas

The same transaction bytes in both executions
Enter fullscreen mode Exit fullscreen mode

The vulnerable logic was not reimplemented in a mock.

A small ChainContext boundary stub was used because ApplyTransaction requires that interface.

The bug did not depend on the stub.

The proof did not require:

Mainnet

Testnet

Public RPC

Governance

Administrator keys

External services
Enter fullscreen mode Exit fullscreen mode

It did require the partner-peer trust condition.

The test command was:

go test ./core \
    -run TestTxEx \
    -count=1 \
    -v
Enter fullscreen mode Exit fullscreen mode

The test passed.

The controls made the root cause unambiguous

A strong consensus PoC needs controls.

This one included several.

Fresh sender control

A fresh decode of the signed transaction recovered:

Attacker
Enter fullscreen mode Exit fullscreen mode

That proved the signature was valid and belonged to the attacker.

Untrusted conversion control

With:

trustIt = false
Enter fullscreen mode Exit fullscreen mode

the receiver recovered:

Attacker
Enter fullscreen mode Exit fullscreen mode

The metadata did not poison the sender.

Trusted conversion control

With:

trustIt = true
Enter fullscreen mode Exit fullscreen mode

the receiver returned:

Victim
Enter fullscreen mode Exit fullscreen mode

That isolated the vulnerable trust branch.

Same-bytes control

The poisoned and fresh executions used the same transaction bytes.

The From metadata did not change the signed transaction or its hash.

State-transition control

Both executions reached the real ApplyTransaction path.

One charged the victim.

The other charged the attacker.

The state roots differed.

What the report does not claim

The report does not claim:

Direct theft accepted by honest nodes

A permanent chain split

A total network shutdown

Permanent transaction confirmation failure

Exploitation by any arbitrary peer

A completed live-network attack
Enter fullscreen mode Exit fullscreen mode

Honest nodes reject the poisoned block.

That rejection prevents the local false sender state from becoming canonical.

It does not make the issue harmless.

The attacker can still target block producers and make them waste proposer opportunities by constructing blocks that the rest of the network rejects.

Why this is Major, not Low

The report was submitted under the impact category:

Blockchain transient consensus failure

Block producer denial of service
Enter fullscreen mode Exit fullscreen mode

The PoC demonstrated both sides of that classification.

It crosses a consensus critical trust boundary

A P2P peer influenced the sender used in transaction execution.

Sender identity is not optional metadata.

It determines:

Nonce

Gas payment

Balance checks

State transition

Final state root
Enter fullscreen mode Exit fullscreen mode

It causes real execution divergence

The same signed transaction bytes produced two accepted local executions with different senders.

This is not a logging or display problem.

It changes consensus-relevant state.

It creates the exact divergence required for invalid block production

The poisoned execution computes one root.

Fresh canonical decoding computes another.

The PoC did not assemble and gossip a full block. It proved the consensus critical prerequisite through the real state transition path: honest nodes cannot reproduce the poisoned root from the signed transaction bytes.

A producer that includes the poisoned transaction and commits that root constructs an invalid block.

It can waste a block production opportunity

A targeted producer can spend its opportunity executing, assembling, and broadcasting a block that honest nodes reject.

That is a block producer denial of service impact, even though honest rejection prevents the poisoned state from becoming canonical.

The attack requires a partner peer, not an administrator

The report has a meaningful privilege precondition.

The sender must be recognized by WEMIX as a partner peer.

But the attack does not require:

Governance control

Administrator keys

Validator private keys

Public RPC manipulation
Enter fullscreen mode Exit fullscreen mode

Partner status is a network privilege, not authority to override the sender authenticated by a transaction signature.

Low does not fit the demonstrated effect

Low would fit:

An incorrect cached value with no execution impact

A display-only sender mismatch

A packet rejected before state transition

A harmless performance optimization bug

A divergence that cannot reach block construction
Enter fullscreen mode Exit fullscreen mode

This report reached real transaction execution and produced different state roots.

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 proof:

Transaction signer
Attacker

Partner metadata
Victim

Transaction bytes
Unchanged

Transaction hash
Unchanged

Sender with trust false
Attacker

Sender with trust true
Victim

Poisoned victim debit
42,000 wei

Fresh attacker debit
42,000 wei

State roots
Different

Block producer consequence
Invalid block if the poisoned root is committed
Enter fullscreen mode Exit fullscreen mode

The strongest demonstrated impact was state-root divergence enabling targeted invalid block production through unauthenticated partner metadata.

That is not a Low severity cache bug.

It is a Major consensus and block production 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 backed by reproducible proofs and concrete protocol behavior were repeatedly assigned substantially lower final severities.

This report was submitted as Major.

The proof showed a partner peer making the target execute an attacker-signed transaction as though it came from a victim, producing the exact state-root divergence required for invalid block production.

It 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:

Peer metadata replacing cryptographic sender recovery

The victim receiving the local gas and nonce effects

The attacker receiving those effects after fresh decoding

The same transaction bytes producing different roots

A block producer building a block honest nodes reject

A partner peer being able to trigger the poisoned trust path
Enter fullscreen mode Exit fullscreen mode

Without addressing those facts, the Low classification does not match the proof.

The correct fix

The safest correction is to stop treating TransactionEx.From as authoritative.

The receiver must recover the sender locally from the signed transaction.

A compatibility-preserving patch can keep the field as advisory metadata:

func TxExs2Txs(
    signer Signer,
    txs []*TransactionEx,
    trustIt bool,
) []*Transaction {
    var out []*Transaction

    for _, i := range txs {
        if i == nil || i.Tx == nil {
            out =
                append(
                    out,
                    nil,
                )

            continue
        }

        if trustIt {
            from, err :=
                signer.Sender(
                    i.Tx,
                )

            if err == nil &&
                from == i.From {
                i.Tx.from.Store(
                    sigCache{
                        signer: signer,
                        from: from,
                    },
                )
            }
        }

        out =
            append(
                out,
                i.Tx,
            )
    }

    return out
}
Enter fullscreen mode Exit fullscreen mode

The essential rule is:

Recover first

Compare second

Cache only after a match
Enter fullscreen mode Exit fullscreen mode

A mismatched TransactionEx.From can then be:

Ignored

Rejected

Recorded as peer misbehavior

Used for P2P penalties
Enter fullscreen mode Exit fullscreen mode

It must never become transaction authority.

Why the fix closes the bug

The exploit requires this transition:

Peer-supplied victim
→
Transaction.from cache
→
types.Sender
→
ApplyTransaction
Enter fullscreen mode Exit fullscreen mode

The patch breaks the chain at the first step.

For a transaction signed by the attacker and carrying:

From = victim
Enter fullscreen mode Exit fullscreen mode

local recovery returns:

Attacker
Enter fullscreen mode Exit fullscreen mode

The values do not match.

The victim is not stored in the sender cache.

Fresh execution and partner-packet execution therefore resolve the same sender and produce the same state transition.

The packet format does not need to change.

The block format does not need to change.

Only the trust decision changes.

Regression tests

A complete correction should verify:

  1. Matching TransactionEx.From may populate the cache only after local cryptographic recovery

  2. Mismatched metadata never becomes Transaction.from

  3. trustIt = false and trustIt = true resolve the same sender for a mismatched packet

  4. Partner conversion and fresh decoding produce the same gas payer, nonce changes, and state root

  5. The txpool resolver never persists unverified peer metadata

  6. Nil or malformed extended transactions are handled safely

  7. A metadata mismatch may trigger the configured peer response without affecting transaction execution

The central invariant is:

Every sender used by txpool, block production, and state transition must be derived locally from the signed transaction.

Broader lessons

Trusted peers still provide untrusted data

A partner may be allowed to use specialized network messages.

Every consensus-relevant field must still be verified.

Caches can become security boundaries

A sender cache looks like a performance optimization.

Once downstream code treats it as authoritative, poisoning that cache changes execution.

Transaction hashes do not cover external metadata

The transaction hash remained the same because TransactionEx.From was outside the signed payload.

That made the mismatch easy to hide behind an otherwise valid transaction.

Honest rejection is still an impact

The network rejecting the invalid block prevents canonical corruption.

The targeted producer still loses its block.

Consensus safety and producer availability are related but distinct properties.

Local and fresh decoding must be equivalent

A transaction received through a special packet must execute identically to the same transaction decoded from canonical block bytes.

Any difference is a consensus warning.

Conclusion

The transaction was signed by the attacker.

The partner packet claimed the sender was a victim.

The claimed address was not signed.

It did not change the transaction bytes.

It did not change the transaction hash.

With:

trustIt = false
Enter fullscreen mode Exit fullscreen mode

WEMIX recovered the attacker.

With:

trustIt = true
Enter fullscreen mode Exit fullscreen mode

WEMIX returned the victim from the sender cache.

The poisoned execution charged gas and nonce to the victim.

A fresh execution of the same bytes charged gas and nonce to the attacker.

The state roots differed.

A target producer that committed the poisoned root could therefore build a block that honest nodes reject.

The program classified the report as Low and paid $100.

The demonstrated impact was Major.

The report did not claim network accepted theft, permanent chain split, or arbitrary-peer exploitation.

It demonstrated a narrower and directly proven failure:

A malicious partner peer could replace cryptographic sender recovery with unauthenticated metadata, creating state-root divergence that makes a producer’s block invalid.

A peer-controlled cache entry that changes the state root is not a Low severity issue.

Top comments (0)