Summary
chains/sol/event.go:DecodeMessageRelay parses the inbound cross-chain message payload using length fields taken from the payload itself, and slices the payload with those lengths without any len(payload) bounds check. A message whose declared length fields are inconsistent with the actual payload length causes a slice bounds out of range panic. There is no recover() anywhere in the repository, so the panic terminates the whole relayer process (not an isolated goroutine).
- Component: off-chain Compass relayer (
github.com/mapprotocol/compass)
- File/function:
chains/sol/event.go → DecodeMessageRelay (parsing block, ~lines 97–157)
- Path: MAP relay chain → Solana
- Type: Denial of Service (CWE-125 out-of-bounds read / unchecked length → panic)
- Severity: Medium–High (relayer process crash; halts the MAP→Solana relay route)
- Status: present on
main at the time of writing (latest HEAD)
Vulnerable code
chains/sol/event.go:
payload := messageRelay.Payload // attacker-influenced: inner cross-chain message bytes (ABI `bytes`, arbitrary length)
parseBigInt := func(start, length int) *big.Int {
substr := payload[start : start+length] // (1) no check that start+length <= len(payload)
return new(big.Int).SetBytes(substr)
}
version := parseBigInt(0, 1)
messageType := parseBigInt(1, 1)
tokenLen := parseBigInt(2, 1) // 0..255, taken from payload
mosLen := parseBigInt(3, 1)
fromLen := parseBigInt(4, 1)
toLen := parseBigInt(5, 1)
payloadLen := parseBigInt(6, 2) // 0..65535, taken from payload
tokenAmount := parseBigInt(16, 16) // implicitly requires len(payload) >= 32, not checked
start := 32
end := start + int(tokenLen.Int64())
tokenAddress := payload[start:end] // (2) end derived from attacker length, no bound check
start = end
end = start + int(mosLen.Int64())
// ...
start = end
end = start + int(fromLen.Int64())
from := common.BytesToAddress(payload[start:end])
start = end
end = start + int(toLen.Int64())
to := payload[start:end]
start = end
end = start + int(payloadLen.Int64())
swapData := payload[start:end] // (3) end derived from attacker length, no bound check
if len(swapData) != 0 {
s := Swap{
ToToken: swapData[2:34], // (4) only guards != 0, not >= 98
Receiver: swapData[34:66],
MinAmount: big.NewInt(0).SetBytes(swapData[66:98]),
}
// ...
}
Any of (1)–(4) panics when the declared lengths exceed the actual payload size. A short/empty payload trips (1) immediately (e.g. parseBigInt(0,1) on an empty payload).
Reachability
The decoder is on the live MAP→Solana relay path and is reached before destination-side signature verification:
source MessageRelay event
→ chains/ethereum/chain.go:173 assembleProof(...) // packs the raw log into a SwapSolProof message
→ core Router.Send(...)
→ chains/sol/writer.go:69 exeMcs(...) → DecodeMessageRelay(log.Topics, hex(log.Data))
messageRelay.Payload is ABI-decoded from the event Data (UnpackMessageRelay), i.e. the inner cross-chain message content, which is user-supplied. No length validation is performed before the slicing above.
The one hardened sibling, internal/chain/event.go:decodeNonEVMMessageRelayTokens, does bound-check its parse — but it parses a different byte layout at different offsets (its tokenLen is data[3], header 33) and only validates tokenLen, so it does not cover the mosLen/fromLen/toLen/payloadLen/swapData slices in chains/sol/event.go.
No recover() exists in the repository, so a panic in the writer goroutine (started via network.go: go peggyContract... style dispatch) crashes the entire process rather than dropping a single message.
Reproduction (local)
poc/compass-decode-panic/main.go faithfully replicates the parsing block and feeds malformed payloads:
[empty payload] → slice bounds out of range [:1] with capacity 0
[5-byte payload] → [:6] with capacity 5
[32B header, tokenLen=255]→ [:287] with capacity 32
[40B, payloadLen=65535] → [:65567] with capacity 40
(The reproduction operates on the decoder logic only; it does not send anything to any network.)
Impact
- A single malformed inbound message on the MAP→Solana route crashes the relayer process.
- Because there is no
recover(), this is not isolated to one message; the process exits. If the supervisor restarts and re-scans the same (still-present) event, it can crash-loop, stalling the route until manual intervention.
- No fund theft is implied; this is a liveness/availability defect.
Suggested fix
- Validate
len(payload) before every read/slice. A minimal, robust form is a bounds-checked reader, e.g.:
func readAt(payload []byte, start, length int) ([]byte, error) {
if start < 0 || length < 0 || start+length > len(payload) {
return nil, fmt.Errorf("payload out of bounds: need [%d:%d], have %d", start, start+length, len(payload))
}
return payload[start : start+length], nil
}
Use it for every parseBigInt/dynamic slice, and require len(swapData) >= 98 before the swapData[2:34]/[34:66]/[66:98] reads. Return an error (so the message is skipped/logged) instead of panicking.
- Defense in depth: add a top-level
defer recover() in the relayer/writer goroutine so a decoding panic degrades to a dropped message rather than a process crash.
Credit
Found via targeted static analysis for the "attacker-controlled length field used as an unchecked slice bound" class (the same class as the Sygma ERC-20 deposit-handler issue). Reported for coordinated disclosure.
Summary
chains/sol/event.go:DecodeMessageRelayparses the inbound cross-chain messagepayloadusing length fields taken from the payload itself, and slices the payload with those lengths without anylen(payload)bounds check. A message whose declared length fields are inconsistent with the actual payload length causes aslice bounds out of rangepanic. There is norecover()anywhere in the repository, so the panic terminates the whole relayer process (not an isolated goroutine).github.com/mapprotocol/compass)chains/sol/event.go→DecodeMessageRelay(parsing block, ~lines 97–157)mainat the time of writing (latest HEAD)Vulnerable code
chains/sol/event.go:Any of (1)–(4) panics when the declared lengths exceed the actual payload size. A short/empty payload trips (1) immediately (e.g.
parseBigInt(0,1)on an empty payload).Reachability
The decoder is on the live MAP→Solana relay path and is reached before destination-side signature verification:
messageRelay.Payloadis ABI-decoded from the eventData(UnpackMessageRelay), i.e. the inner cross-chain message content, which is user-supplied. No length validation is performed before the slicing above.The one hardened sibling,
internal/chain/event.go:decodeNonEVMMessageRelayTokens, does bound-check its parse — but it parses a different byte layout at different offsets (itstokenLenisdata[3], header 33) and only validatestokenLen, so it does not cover themosLen/fromLen/toLen/payloadLen/swapDataslices inchains/sol/event.go.No
recover()exists in the repository, so a panic in the writer goroutine (started vianetwork.go: go peggyContract...style dispatch) crashes the entire process rather than dropping a single message.Reproduction (local)
poc/compass-decode-panic/main.gofaithfully replicates the parsing block and feeds malformed payloads:(The reproduction operates on the decoder logic only; it does not send anything to any network.)
Impact
recover(), this is not isolated to one message; the process exits. If the supervisor restarts and re-scans the same (still-present) event, it can crash-loop, stalling the route until manual intervention.Suggested fix
len(payload)before every read/slice. A minimal, robust form is a bounds-checked reader, e.g.:Use it for every
parseBigInt/dynamic slice, and requirelen(swapData) >= 98before theswapData[2:34]/[34:66]/[66:98]reads. Return an error (so the message is skipped/logged) instead of panicking.defer recover()in the relayer/writer goroutine so a decoding panic degrades to a dropped message rather than a process crash.Credit
Found via targeted static analysis for the "attacker-controlled length field used as an unchecked slice bound" class (the same class as the Sygma ERC-20 deposit-handler issue). Reported for coordinated disclosure.