diff --git a/universalClient/chains/evm/event_parser.go b/universalClient/chains/evm/event_parser.go index 3cde0c7e..2c338232 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. @@ -278,5 +305,3 @@ func parseUniversalTx(event *store.Event, log *types.Log, dataOffset uint64, pay finalizeEvent(event, payload, logger) } - - 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()) + }) +}