From 981cee2d3c2848695f45b3d7bde653c2e025c477 Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 21 Aug 2026 12:33:34 +0530 Subject: [PATCH 1/4] fix: reject overflowing ABI offsets and contain decode panics in the evm log parser (F-2026-18804) --- universalClient/chains/evm/event_parser.go | 39 +++++-- .../chains/evm/event_parser_test.go | 100 ++++++++++++++++++ 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/universalClient/chains/evm/event_parser.go b/universalClient/chains/evm/event_parser.go index 3cde0c7e..75574aae 100644 --- a/universalClient/chains/evm/event_parser.go +++ b/universalClient/chains/evm/event_parser.go @@ -29,7 +29,27 @@ const ( // ParseEvent parses a log into a store.Event based on the event type. // eventType should be one of: sendFunds, executeUniversalTx, revertUniversalTx. -func ParseEvent(log *types.Log, eventType string, chainID string, logger zerolog.Logger) *store.Event { +// +// A panic in the decoders is contained here rather than allowed to unwind. Log +// data is supplied by an RPC and the listener runs on a background goroutine, so +// an unrecovered panic would take down every chain and the TSS node with it. A +// log we cannot decode is skipped like any other undecodable one. +func ParseEvent(log *types.Log, eventType string, chainID string, logger zerolog.Logger) (event *store.Event) { + defer func() { + if r := recover(); r != nil { + event = nil + logger.Error(). + Interface("panic", r). + Str("event_type", eventType). + Str("tx_hash", log.TxHash.Hex()). + Uint("log_index", log.Index). + Msg("panic while decoding log; skipping it") + } + }() + return parseEvent(log, eventType, chainID, logger) +} + +func parseEvent(log *types.Log, eventType string, chainID string, logger zerolog.Logger) *store.Event { if len(log.Topics) == 0 { return nil } @@ -175,17 +195,24 @@ func parseUniversalTxEvent(event *store.Event, log *types.Log, chainID string, l } // readDynamicBytes decodes ABI-encoded dynamic bytes at the given absolute offset in data. +// +// Both absOff and the length word are attacker-controlled: they come from the +// log data an RPC returns. Bounds are therefore checked by subtracting from the +// buffer length rather than adding to the offset — absOff+32 and dataStart+byteLen +// each wrap on a near-2^64 word and would pass an additive guard, then panic on +// the slice. func readDynamicBytes(data []byte, absOff uint64) (string, bool) { - if absOff+32 > uint64(len(data)) { + n := uint64(len(data)) + if absOff > n || n-absOff < 32 { return "", false } byteLen := new(big.Int).SetBytes(data[absOff : absOff+32]).Uint64() - dataStart := absOff + 32 - dataEnd := dataStart + byteLen - if dataEnd > uint64(len(data)) { + + dataStart := absOff + 32 // safe: absOff+32 <= n was just established + if n-dataStart < byteLen { return "", false } - return "0x" + hex.EncodeToString(data[dataStart:dataEnd]), true + return "0x" + hex.EncodeToString(data[dataStart : dataStart+byteLen]), true } // readWord returns the i-th 32-byte word from data, or nil if out of bounds. diff --git a/universalClient/chains/evm/event_parser_test.go b/universalClient/chains/evm/event_parser_test.go index 1d211cc5..d3a71e79 100644 --- a/universalClient/chains/evm/event_parser_test.go +++ b/universalClient/chains/evm/event_parser_test.go @@ -3,6 +3,7 @@ package evm import ( "encoding/hex" "encoding/json" + "math" "math/big" "testing" @@ -656,3 +657,102 @@ func TestFinalizeEvent(t *testing.T) { assert.Equal(t, "1000", decoded.Amount) }) } + +// abiWord returns a 32-byte big-endian word holding v, for building hostile log data. +func abiWord(v *big.Int) []byte { + w := make([]byte, 32) + v.FillBytes(w) + return w +} + +// Both the offset and the length word come from the RPC, so both can be chosen +// to overflow uint64. Addition-based bounds wrap and pass, then the slice panics +// — and the listener has no caller between here and the goroutine root, so that +// panic would end the process. +func TestReadDynamicBytes_OverflowIsRejectedNotPanicked(t *testing.T) { + maxU64 := new(big.Int).SetUint64(math.MaxUint64) + + t.Run("offset near 2^64 does not wrap past the bounds check", func(t *testing.T) { + data := make([]byte, 128) + for _, off := range []uint64{ + math.MaxUint64, // absOff + 32 wraps to 31 + math.MaxUint64 - 16, // wraps to 15 + math.MaxUint64 - 31, // wraps to 0 + math.MaxUint64 - 32, // wraps to exactly 0 after the +32 + } { + _, ok := readDynamicBytes(data, off) + assert.False(t, ok, "offset %d must be rejected", off) + } + }) + + t.Run("length near 2^64 does not wrap the end below the start", func(t *testing.T) { + // Word at offset 0 is the length; make it enormous so dataStart+byteLen wraps. + data := make([]byte, 128) + copy(data[0:32], abiWord(maxU64)) + + _, ok := readDynamicBytes(data, 0) + assert.False(t, ok, "a length that wraps the end must be rejected") + }) + + t.Run("length just past the buffer is rejected without wrapping", func(t *testing.T) { + data := make([]byte, 128) + copy(data[0:32], abiWord(big.NewInt(97))) // 32 header + 97 > 128 + _, ok := readDynamicBytes(data, 0) + assert.False(t, ok) + }) + + t.Run("well formed input still decodes", func(t *testing.T) { + data := make([]byte, 128) + copy(data[0:32], abiWord(big.NewInt(4))) + copy(data[32:36], []byte{0xDE, 0xAD, 0xBE, 0xEF}) + + got, ok := readDynamicBytes(data, 0) + require.True(t, ok) + assert.Equal(t, "0xdeadbeef", got) + }) + + t.Run("zero length decodes to empty", func(t *testing.T) { + data := make([]byte, 64) + got, ok := readDynamicBytes(data, 0) + require.True(t, ok) + assert.Equal(t, "0x", got) + }) + + t.Run("exactly filling the buffer decodes", func(t *testing.T) { + data := make([]byte, 64) + copy(data[0:32], abiWord(big.NewInt(32))) + copy(data[32:64], abiWord(big.NewInt(1))) + + _, ok := readDynamicBytes(data, 32+32-32) // offset 32 is past the end for a 64-byte buffer + assert.False(t, ok) + + got, ok := readDynamicBytes(data, 0) + require.True(t, ok) + assert.Len(t, got, 2+64) + }) +} + +// End to end: a log carrying an overflowing payload offset must be skipped, not +// crash the listener goroutine. +func TestParseEvent_HostileLogDoesNotPanic(t *testing.T) { + // 5 words of data so the length guard passes, with word 2 (the payload + // offset) set to a value that overflows when 32 is added to it. + data := make([]byte, 32*5) + copy(data[2*32:3*32], abiWord(new(big.Int).SetUint64(math.MaxUint64))) + + log := &types.Log{ + Topics: []ethcommon.Hash{ + ethcommon.HexToHash("0x01"), + ethcommon.HexToHash("0x02"), + ethcommon.HexToHash("0x03"), + }, + Data: data, + TxHash: ethcommon.HexToHash("0xabc"), + Index: 7, + Address: ethcommon.HexToAddress("0xdead"), + } + + require.NotPanics(t, func() { + ParseEvent(log, EventTypeSendFunds, "eip155:1", zerolog.Nop()) + }) +} From d87b60b660ea5149ef3fbff19abad9b5f51db7ad Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 21 Aug 2026 12:34:47 +0530 Subject: [PATCH 2/4] chore: gofmt evm event parser --- universalClient/chains/evm/event_parser.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/universalClient/chains/evm/event_parser.go b/universalClient/chains/evm/event_parser.go index 75574aae..2c338232 100644 --- a/universalClient/chains/evm/event_parser.go +++ b/universalClient/chains/evm/event_parser.go @@ -212,7 +212,7 @@ func readDynamicBytes(data []byte, absOff uint64) (string, bool) { if n-dataStart < byteLen { return "", false } - return "0x" + hex.EncodeToString(data[dataStart : dataStart+byteLen]), true + return "0x" + hex.EncodeToString(data[dataStart:dataStart+byteLen]), true } // readWord returns the i-th 32-byte word from data, or nil if out of bounds. @@ -305,5 +305,3 @@ func parseUniversalTx(event *store.Event, log *types.Log, dataOffset uint64, pay finalizeEvent(event, payload, logger) } - - From 0eaf1a981316f44c5d1d15a8ea21d495bb7d6524 Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 21 Aug 2026 13:01:23 +0530 Subject: [PATCH 3/4] fix: contain decode panics in the svm log parser too (F-2026-18804) --- universalClient/chains/svm/event_parser.go | 21 +++++++- .../chains/svm/event_parser_test.go | 49 +++++++++++++------ 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/universalClient/chains/svm/event_parser.go b/universalClient/chains/svm/event_parser.go index 4c25625d..e94a37f7 100644 --- a/universalClient/chains/svm/event_parser.go +++ b/universalClient/chains/svm/event_parser.go @@ -43,7 +43,26 @@ func base58ToHex(base58Str string) (string, error) { // ParseEvent parses a log into a store.Event based on the event type. // eventType should be one of: "send_funds", "executeUniversalTx", "revertUniversalTx" -func ParseEvent(log string, signature string, slot uint64, logIndex uint, eventType string, chainID string, logger zerolog.Logger) *store.Event { +// A panic in the decoders is contained here rather than allowed to unwind. Log +// data is supplied by an RPC and the listener runs on a background goroutine, so +// an unrecovered panic would take down every chain and the TSS node with it. A +// log we cannot decode is skipped like any other undecodable one. +func ParseEvent(log string, signature string, slot uint64, logIndex uint, eventType string, chainID string, logger zerolog.Logger) (event *store.Event) { + defer func() { + if r := recover(); r != nil { + event = nil + logger.Error(). + Interface("panic", r). + Str("event_type", eventType). + Str("signature", signature). + Uint("log_index", logIndex). + Msg("panic while decoding log; skipping it") + } + }() + return parseEvent(log, signature, slot, logIndex, eventType, chainID, logger) +} + +func parseEvent(log string, signature string, slot uint64, logIndex uint, eventType string, chainID string, logger zerolog.Logger) *store.Event { switch eventType { case EventTypeSendFunds: return parseSendFundsEvent(log, signature, slot, logIndex, chainID, logger) diff --git a/universalClient/chains/svm/event_parser_test.go b/universalClient/chains/svm/event_parser_test.go index da11ce0c..0bc591e3 100644 --- a/universalClient/chains/svm/event_parser_test.go +++ b/universalClient/chains/svm/event_parser_test.go @@ -23,18 +23,19 @@ func nopLogger() zerolog.Logger { // parseSendFundsEvent / decodeUniversalTxEvent call. // // Layout (Borsh): -// discriminator 8 bytes -// sender 32 bytes (Pubkey) -// recipient 20 bytes (byte20) -// bridge_token 32 bytes (Pubkey) -// bridge_amount 8 bytes (u64 LE) -// data_len 4 bytes (u32 LE) -// data variable -// revert_recip 32 bytes (Pubkey) -// tx_type 1 byte -// sig_len 4 bytes (u32 LE) -// sig_data variable -// fromCEA 1 byte +// +// discriminator 8 bytes +// sender 32 bytes (Pubkey) +// recipient 20 bytes (byte20) +// bridge_token 32 bytes (Pubkey) +// bridge_amount 8 bytes (u64 LE) +// data_len 4 bytes (u32 LE) +// data variable +// revert_recip 32 bytes (Pubkey) +// tx_type 1 byte +// sig_len 4 bytes (u32 LE) +// sig_data variable +// fromCEA 1 byte func buildSendFundsPayload( sender [32]byte, recipient [20]byte, @@ -116,12 +117,12 @@ func TestBase58ToHex(t *testing.T) { }, { name: "known base58 value", - input: "1", // base58 "1" decodes to a single 0x00 byte + input: "1", // base58 "1" decodes to a single 0x00 byte want: "0x00", }, { name: "known base58 multi-byte", - input: "2g", // base58 "2g" decodes to 0x61 + input: "2g", // base58 "2g" decodes to 0x61 want: "0x61", }, { @@ -621,3 +622,23 @@ func TestDecodeUniversalTxEvent_PartialData(t *testing.T) { assert.Equal(t, uint(0), result.TxType) }) } + +// Same containment as the EVM parser: log data comes from an RPC and the +// listener has no caller between here and the goroutine root, so a decode panic +// would end the process rather than skip one log. +func TestParseEvent_PanicIsContained(t *testing.T) { + // A gateway-shaped payload truncated mid-field: enough to pass the length + // guard for one event type, short enough to be malformed for the decoder. + payload := make([]byte, 97) + encoded := "Program data: " + base64.StdEncoding.EncodeToString(payload) + + require.NotPanics(t, func() { + ParseEvent(encoded, "sig", 100, 0, EventTypeSendFunds, "solana:test", zerolog.Nop()) + }) + require.NotPanics(t, func() { + ParseEvent(encoded, "sig", 100, 0, EventTypeFinalizeUniversalTx, "solana:test", zerolog.Nop()) + }) + require.NotPanics(t, func() { + ParseEvent("Program data: !!!not-base64!!!", "sig", 100, 0, EventTypeSendFunds, "solana:test", zerolog.Nop()) + }) +} From 86c63c113209eb516b7c267331abf92cbc31e725 Mon Sep 17 00:00:00 2001 From: aman035 Date: Fri, 21 Aug 2026 13:20:31 +0530 Subject: [PATCH 4/4] Revert "fix: contain decode panics in the svm log parser too (F-2026-18804)" This reverts commit 0eaf1a981316f44c5d1d15a8ea21d495bb7d6524. --- universalClient/chains/svm/event_parser.go | 21 +------- .../chains/svm/event_parser_test.go | 49 ++++++------------- 2 files changed, 15 insertions(+), 55 deletions(-) diff --git a/universalClient/chains/svm/event_parser.go b/universalClient/chains/svm/event_parser.go index e94a37f7..4c25625d 100644 --- a/universalClient/chains/svm/event_parser.go +++ b/universalClient/chains/svm/event_parser.go @@ -43,26 +43,7 @@ func base58ToHex(base58Str string) (string, error) { // ParseEvent parses a log into a store.Event based on the event type. // eventType should be one of: "send_funds", "executeUniversalTx", "revertUniversalTx" -// A panic in the decoders is contained here rather than allowed to unwind. Log -// data is supplied by an RPC and the listener runs on a background goroutine, so -// an unrecovered panic would take down every chain and the TSS node with it. A -// log we cannot decode is skipped like any other undecodable one. -func ParseEvent(log string, signature string, slot uint64, logIndex uint, eventType string, chainID string, logger zerolog.Logger) (event *store.Event) { - defer func() { - if r := recover(); r != nil { - event = nil - logger.Error(). - Interface("panic", r). - Str("event_type", eventType). - Str("signature", signature). - Uint("log_index", logIndex). - Msg("panic while decoding log; skipping it") - } - }() - return parseEvent(log, signature, slot, logIndex, eventType, chainID, logger) -} - -func parseEvent(log string, signature string, slot uint64, logIndex uint, eventType string, chainID string, logger zerolog.Logger) *store.Event { +func ParseEvent(log string, signature string, slot uint64, logIndex uint, eventType string, chainID string, logger zerolog.Logger) *store.Event { switch eventType { case EventTypeSendFunds: return parseSendFundsEvent(log, signature, slot, logIndex, chainID, logger) diff --git a/universalClient/chains/svm/event_parser_test.go b/universalClient/chains/svm/event_parser_test.go index 0bc591e3..da11ce0c 100644 --- a/universalClient/chains/svm/event_parser_test.go +++ b/universalClient/chains/svm/event_parser_test.go @@ -23,19 +23,18 @@ func nopLogger() zerolog.Logger { // parseSendFundsEvent / decodeUniversalTxEvent call. // // Layout (Borsh): -// -// discriminator 8 bytes -// sender 32 bytes (Pubkey) -// recipient 20 bytes (byte20) -// bridge_token 32 bytes (Pubkey) -// bridge_amount 8 bytes (u64 LE) -// data_len 4 bytes (u32 LE) -// data variable -// revert_recip 32 bytes (Pubkey) -// tx_type 1 byte -// sig_len 4 bytes (u32 LE) -// sig_data variable -// fromCEA 1 byte +// discriminator 8 bytes +// sender 32 bytes (Pubkey) +// recipient 20 bytes (byte20) +// bridge_token 32 bytes (Pubkey) +// bridge_amount 8 bytes (u64 LE) +// data_len 4 bytes (u32 LE) +// data variable +// revert_recip 32 bytes (Pubkey) +// tx_type 1 byte +// sig_len 4 bytes (u32 LE) +// sig_data variable +// fromCEA 1 byte func buildSendFundsPayload( sender [32]byte, recipient [20]byte, @@ -117,12 +116,12 @@ func TestBase58ToHex(t *testing.T) { }, { name: "known base58 value", - input: "1", // base58 "1" decodes to a single 0x00 byte + input: "1", // base58 "1" decodes to a single 0x00 byte want: "0x00", }, { name: "known base58 multi-byte", - input: "2g", // base58 "2g" decodes to 0x61 + input: "2g", // base58 "2g" decodes to 0x61 want: "0x61", }, { @@ -622,23 +621,3 @@ func TestDecodeUniversalTxEvent_PartialData(t *testing.T) { assert.Equal(t, uint(0), result.TxType) }) } - -// Same containment as the EVM parser: log data comes from an RPC and the -// listener has no caller between here and the goroutine root, so a decode panic -// would end the process rather than skip one log. -func TestParseEvent_PanicIsContained(t *testing.T) { - // A gateway-shaped payload truncated mid-field: enough to pass the length - // guard for one event type, short enough to be malformed for the decoder. - payload := make([]byte, 97) - encoded := "Program data: " + base64.StdEncoding.EncodeToString(payload) - - require.NotPanics(t, func() { - ParseEvent(encoded, "sig", 100, 0, EventTypeSendFunds, "solana:test", zerolog.Nop()) - }) - require.NotPanics(t, func() { - ParseEvent(encoded, "sig", 100, 0, EventTypeFinalizeUniversalTx, "solana:test", zerolog.Nop()) - }) - require.NotPanics(t, func() { - ParseEvent("Program data: !!!not-base64!!!", "sig", 100, 0, EventTypeSendFunds, "solana:test", zerolog.Nop()) - }) -}