Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 33 additions & 8 deletions universalClient/chains/evm/event_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -278,5 +305,3 @@ func parseUniversalTx(event *store.Event, log *types.Log, dataOffset uint64, pay

finalizeEvent(event, payload, logger)
}


100 changes: 100 additions & 0 deletions universalClient/chains/evm/event_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package evm
import (
"encoding/hex"
"encoding/json"
"math"
"math/big"
"testing"

Expand Down Expand Up @@ -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())
})
}
Loading