Skip to content

Commit e8aa037

Browse files
committed
fix: reject overflowing ABI offsets and contain decode panics in the evm log parser (F-2026-18804)
1 parent 5121913 commit e8aa037

2 files changed

Lines changed: 133 additions & 6 deletions

File tree

universalClient/chains/evm/event_parser.go

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,27 @@ const (
2929

3030
// ParseEvent parses a log into a store.Event based on the event type.
3131
// eventType should be one of: sendFunds, executeUniversalTx, revertUniversalTx.
32-
func ParseEvent(log *types.Log, eventType string, chainID string, logger zerolog.Logger) *store.Event {
32+
//
33+
// A panic in the decoders is contained here rather than allowed to unwind. Log
34+
// data is supplied by an RPC and the listener runs on a background goroutine, so
35+
// an unrecovered panic would take down every chain and the TSS node with it. A
36+
// log we cannot decode is skipped like any other undecodable one.
37+
func ParseEvent(log *types.Log, eventType string, chainID string, logger zerolog.Logger) (event *store.Event) {
38+
defer func() {
39+
if r := recover(); r != nil {
40+
event = nil
41+
logger.Error().
42+
Interface("panic", r).
43+
Str("event_type", eventType).
44+
Str("tx_hash", log.TxHash.Hex()).
45+
Uint("log_index", log.Index).
46+
Msg("panic while decoding log; skipping it")
47+
}
48+
}()
49+
return parseEvent(log, eventType, chainID, logger)
50+
}
51+
52+
func parseEvent(log *types.Log, eventType string, chainID string, logger zerolog.Logger) *store.Event {
3353
if len(log.Topics) == 0 {
3454
return nil
3555
}
@@ -175,17 +195,24 @@ func parseUniversalTxEvent(event *store.Event, log *types.Log, chainID string, l
175195
}
176196

177197
// readDynamicBytes decodes ABI-encoded dynamic bytes at the given absolute offset in data.
198+
//
199+
// Both absOff and the length word are attacker-controlled: they come from the
200+
// log data an RPC returns. Bounds are therefore checked by subtracting from the
201+
// buffer length rather than adding to the offset — absOff+32 and dataStart+byteLen
202+
// each wrap on a near-2^64 word and would pass an additive guard, then panic on
203+
// the slice.
178204
func readDynamicBytes(data []byte, absOff uint64) (string, bool) {
179-
if absOff+32 > uint64(len(data)) {
205+
n := uint64(len(data))
206+
if absOff > n || n-absOff < 32 {
180207
return "", false
181208
}
182209
byteLen := new(big.Int).SetBytes(data[absOff : absOff+32]).Uint64()
183-
dataStart := absOff + 32
184-
dataEnd := dataStart + byteLen
185-
if dataEnd > uint64(len(data)) {
210+
211+
dataStart := absOff + 32 // safe: absOff+32 <= n was just established
212+
if n-dataStart < byteLen {
186213
return "", false
187214
}
188-
return "0x" + hex.EncodeToString(data[dataStart:dataEnd]), true
215+
return "0x" + hex.EncodeToString(data[dataStart : dataStart+byteLen]), true
189216
}
190217

191218
// readWord returns the i-th 32-byte word from data, or nil if out of bounds.

universalClient/chains/evm/event_parser_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package evm
33
import (
44
"encoding/hex"
55
"encoding/json"
6+
"math"
67
"math/big"
78
"testing"
89

@@ -656,3 +657,102 @@ func TestFinalizeEvent(t *testing.T) {
656657
assert.Equal(t, "1000", decoded.Amount)
657658
})
658659
}
660+
661+
// abiWord returns a 32-byte big-endian word holding v, for building hostile log data.
662+
func abiWord(v *big.Int) []byte {
663+
w := make([]byte, 32)
664+
v.FillBytes(w)
665+
return w
666+
}
667+
668+
// Both the offset and the length word come from the RPC, so both can be chosen
669+
// to overflow uint64. Addition-based bounds wrap and pass, then the slice panics
670+
// — and the listener has no caller between here and the goroutine root, so that
671+
// panic would end the process.
672+
func TestReadDynamicBytes_OverflowIsRejectedNotPanicked(t *testing.T) {
673+
maxU64 := new(big.Int).SetUint64(math.MaxUint64)
674+
675+
t.Run("offset near 2^64 does not wrap past the bounds check", func(t *testing.T) {
676+
data := make([]byte, 128)
677+
for _, off := range []uint64{
678+
math.MaxUint64, // absOff + 32 wraps to 31
679+
math.MaxUint64 - 16, // wraps to 15
680+
math.MaxUint64 - 31, // wraps to 0
681+
math.MaxUint64 - 32, // wraps to exactly 0 after the +32
682+
} {
683+
_, ok := readDynamicBytes(data, off)
684+
assert.False(t, ok, "offset %d must be rejected", off)
685+
}
686+
})
687+
688+
t.Run("length near 2^64 does not wrap the end below the start", func(t *testing.T) {
689+
// Word at offset 0 is the length; make it enormous so dataStart+byteLen wraps.
690+
data := make([]byte, 128)
691+
copy(data[0:32], abiWord(maxU64))
692+
693+
_, ok := readDynamicBytes(data, 0)
694+
assert.False(t, ok, "a length that wraps the end must be rejected")
695+
})
696+
697+
t.Run("length just past the buffer is rejected without wrapping", func(t *testing.T) {
698+
data := make([]byte, 128)
699+
copy(data[0:32], abiWord(big.NewInt(97))) // 32 header + 97 > 128
700+
_, ok := readDynamicBytes(data, 0)
701+
assert.False(t, ok)
702+
})
703+
704+
t.Run("well formed input still decodes", func(t *testing.T) {
705+
data := make([]byte, 128)
706+
copy(data[0:32], abiWord(big.NewInt(4)))
707+
copy(data[32:36], []byte{0xDE, 0xAD, 0xBE, 0xEF})
708+
709+
got, ok := readDynamicBytes(data, 0)
710+
require.True(t, ok)
711+
assert.Equal(t, "0xdeadbeef", got)
712+
})
713+
714+
t.Run("zero length decodes to empty", func(t *testing.T) {
715+
data := make([]byte, 64)
716+
got, ok := readDynamicBytes(data, 0)
717+
require.True(t, ok)
718+
assert.Equal(t, "0x", got)
719+
})
720+
721+
t.Run("exactly filling the buffer decodes", func(t *testing.T) {
722+
data := make([]byte, 64)
723+
copy(data[0:32], abiWord(big.NewInt(32)))
724+
copy(data[32:64], abiWord(big.NewInt(1)))
725+
726+
_, ok := readDynamicBytes(data, 32+32-32) // offset 32 is past the end for a 64-byte buffer
727+
assert.False(t, ok)
728+
729+
got, ok := readDynamicBytes(data, 0)
730+
require.True(t, ok)
731+
assert.Len(t, got, 2+64)
732+
})
733+
}
734+
735+
// End to end: a log carrying an overflowing payload offset must be skipped, not
736+
// crash the listener goroutine.
737+
func TestParseEvent_HostileLogDoesNotPanic(t *testing.T) {
738+
// 5 words of data so the length guard passes, with word 2 (the payload
739+
// offset) set to a value that overflows when 32 is added to it.
740+
data := make([]byte, 32*5)
741+
copy(data[2*32:3*32], abiWord(new(big.Int).SetUint64(math.MaxUint64)))
742+
743+
log := &types.Log{
744+
Topics: []ethcommon.Hash{
745+
ethcommon.HexToHash("0x01"),
746+
ethcommon.HexToHash("0x02"),
747+
ethcommon.HexToHash("0x03"),
748+
},
749+
Data: data,
750+
TxHash: ethcommon.HexToHash("0xabc"),
751+
Index: 7,
752+
Address: ethcommon.HexToAddress("0xdead"),
753+
}
754+
755+
require.NotPanics(t, func() {
756+
ParseEvent(log, EventTypeSendFunds, "eip155:1", zerolog.Nop())
757+
})
758+
}

0 commit comments

Comments
 (0)