Skip to content

Unbounded slice in Solana relay decoder (DecodeMessageRelay) → out-of-bounds panic → relayer process crash (DoS) #307

Description

@jiapy97

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.goDecodeMessageRelay (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

  1. 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.

  1. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions