Skip to content
Open
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
10 changes: 9 additions & 1 deletion baselib/actor/ask_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,15 @@ func (m *AskResponse) Decode(r io.Reader) error {
return err
}

if _, err := stream.DecodeWithParsedTypes(r); err != nil {
// Bound the framing before decoding so a crafted durable payload cannot
// drive an unbounded make() inside the tlv decoder (ResultBlob is a
// []byte record sized from its declared length).
safe, err := safeActorTLVReader(r)
if err != nil {
return err
}

if _, err := stream.DecodeWithParsedTypes(safe); err != nil {
return err
}

Expand Down
126 changes: 126 additions & 0 deletions baselib/actor/decode_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package actor

import (
"bytes"
"testing"
"time"

"github.com/lightningnetwork/lnd/fn/v2"
)

// These fuzz tests target the actor-framework TLV decoders, which run on
// attacker-controlled bytes replayed from durable mailboxes across rolling
// upgrades. The contract is uniform: a decoder MUST return an error (never
// panic, OOM, or slice-OOB) on malformed input, and any value it accepts
// MUST round-trip back through its encoder.

// FuzzAskResponseDecode exercises the durable AskResponse decoder. Its
// ResultBlob is a []byte record sized from a declared length, the unbounded
// make() sink the framing pre-validation now bounds.
func FuzzAskResponseDecode(f *testing.F) {
var buf bytes.Buffer
seed := AskResponse{
CorrelationID: "corr-1",
ResultBlob: []byte{1, 2, 3, 4},
ErrorText: "boom",
}
if err := seed.Encode(&buf); err == nil {
f.Add(buf.Bytes())
}
f.Add([]byte{})

// A []byte record (type 2) declaring a near-2^64 length in a tiny
// envelope: the historical makeslice crasher.
f.Add([]byte{
0x02, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
})

f.Fuzz(func(t *testing.T, data []byte) {
var v AskResponse
if err := v.Decode(bytes.NewReader(data)); err != nil {
return
}

var out bytes.Buffer
if err := v.Encode(&out); err != nil {
t.Fatalf("re-encode: %v", err)
}

var v2 AskResponse
if err := v2.Decode(bytes.NewReader(out.Bytes())); err != nil {
t.Fatalf("re-decode: %v", err)
}
})
}

// FuzzRestartMessageDecode exercises the RestartMessage checkpoint decoder.
// Its actorID/stateType/stateData are []byte records sized from declared
// lengths.
func FuzzRestartMessageDecode(f *testing.F) {
var buf bytes.Buffer
seed := &RestartMessage{
Checkpoint: fn.Some(Checkpoint{
ActorID: "actor-1",
StateType: "state",
StateData: []byte{9, 8, 7},
Version: 3,
UpdatedAt: time.Unix(1000, 0),
}),
}
if err := seed.Encode(&buf); err == nil {
f.Add(buf.Bytes())
}
f.Add([]byte{})
f.Add([]byte{
0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
})

f.Fuzz(func(t *testing.T, data []byte) {
var v RestartMessage
if err := v.Decode(bytes.NewReader(data)); err != nil {
return
}

var out bytes.Buffer
if err := v.Encode(&out); err != nil {
t.Fatalf("re-encode: %v", err)
}

var v2 RestartMessage
if err := v2.Decode(bytes.NewReader(out.Bytes())); err != nil {
t.Fatalf("re-decode: %v", err)
}
})
}

// FuzzMessageCodecDecode exercises the top-level envelope decoder that every
// durable mailbox message flows through. Its payload is read into
// make([]byte, payloadLen) sized from a declared length, the most reachable
// unbounded-alloc sink in the actor framework.
func FuzzMessageCodecDecode(f *testing.F) {
codec := NewMessageCodec()
codec.MustRegister(AskResponseMsgType, func() TLVMessage {
return &AskResponse{}
})

resp := &AskResponse{
CorrelationID: "c",
ResultBlob: []byte{1, 2},
}
if raw, err := codec.Encode(resp); err == nil {
f.Add(raw)
}
f.Add([]byte{})

// type=AskResponseMsgType varint, then a near-2^64 payload length in a
// tiny envelope: the historical makeslice crasher at the codec layer.
f.Add([]byte{
0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
})

f.Fuzz(func(t *testing.T, data []byte) {
// The codec round-trip is not byte-exact across all registered
// types, so assert only the no-panic decode contract here.
_, _ = codec.Decode(data)
})
}
11 changes: 10 additions & 1 deletion baselib/actor/restart.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,17 @@ func (m *RestartMessage) Decode(r io.Reader) error {
return err
}

// Bound the framing before decoding so a crafted durable checkpoint
// payload cannot drive an unbounded make() inside the tlv decoder
// (actorID/stateType/stateData are []byte records sized from their
// declared lengths).
safe, err := safeActorTLVReader(r)
if err != nil {
return err
}

// Decode and get typeMap showing which fields were present.
typeMap, err := stream.DecodeWithParsedTypes(r)
typeMap, err := stream.DecodeWithParsedTypes(safe)
if err != nil {
return err
}
Expand Down
87 changes: 87 additions & 0 deletions baselib/actor/tlv_bounds.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package actor

import (
"bytes"
"fmt"
"io"

"github.com/lightningnetwork/lnd/tlv"
)

// maxActorMessageSize bounds the total wire size of an untrusted actor TLV
// message (the codec envelope and every message payload decoded from a
// durable mailbox) before any individual record length is honored. Actor
// payloads can embed domain snapshots and PSBT-bearing blobs, so the cap is
// generous at 16 MiB while still rejecting the unbounded-allocation DoS
// where a tiny payload declares a multi-gigabyte (or near-2^64) length.
// Durable-mailbox bytes are replayed from disk across rolling upgrades and
// can cross the cross-actor trust boundary, so the bound is load-bearing on
// both the live and replay paths.
const maxActorMessageSize = 16 << 20

// validateTLVRecordLengths walks the (type, length, value) framing of a
// buffered TLV stream and rejects any record whose declared length exceeds
// the bytes remaining in the buffer. It deliberately does not interpret
// record contents; it only ensures the framing cannot drive an over-sized
// make([]byte, declaredLength) in the real tlv decoder before any value
// bytes are read.
//
// The tlv library sizes its value buffers from the declared length before
// reading any value bytes (stream.go's unknown-record path and
// primitive.go's DVarBytes), and the non-P2P Decode path applies no length
// bound. A producer-declared length near 2^64 panics the decoder with
// "makeslice: cap out of range" and a multi-gigabyte length drives an OOM.
func validateTLVRecordLengths(buf []byte) error {
var scratch [8]byte
br := bytes.NewReader(buf)

for br.Len() > 0 {
if _, err := tlv.ReadVarInt(br, &scratch); err != nil {
return fmt.Errorf("read tlv type: %w", err)
}

length, err := tlv.ReadVarInt(br, &scratch)
if err != nil {
return fmt.Errorf("read tlv length: %w", err)
}

// A record can never carry more value bytes than remain in the
// buffer; reject before the decoder allocates.
if length > uint64(br.Len()) {
return fmt.Errorf("tlv record length %d exceeds %d "+
"remaining bytes", length, br.Len())
}

if _, err := br.Seek(int64(length), io.SeekCurrent); err != nil {
return fmt.Errorf("skip tlv value: %w", err)
}
}

return nil
}

// safeActorTLVReader reads an untrusted actor TLV payload into a size-capped
// buffer and pre-validates its framing so the downstream
// tlv.Stream.DecodeWithParsedTypes can never be handed a record length
// larger than the bytes physically present. See validateTLVRecordLengths
// for why this is required.
func safeActorTLVReader(r io.Reader) (*bytes.Reader, error) {
// Read at most maxActorMessageSize+1 so an over-cap message is detected
// without buffering an unbounded amount.
limited := io.LimitReader(r, maxActorMessageSize+1)
buf, err := io.ReadAll(limited)
if err != nil {
return nil, fmt.Errorf("read actor message: %w", err)
}

if len(buf) > maxActorMessageSize {
return nil, fmt.Errorf("actor message %d bytes exceeds max %d",
len(buf), maxActorMessageSize)
}

if err := validateTLVRecordLengths(buf); err != nil {
return nil, err
}

return bytes.NewReader(buf), nil
}
10 changes: 10 additions & 0 deletions baselib/actor/tlv_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,16 @@ func (c *MessageCodec) Decode(data []byte) (TLVMessage, error) {
return nil, fmt.Errorf("read payload length: %w", err)
}

// Reject a declared payload length that cannot be backed by the bytes
// physically present before allocating make([]byte, payloadLen). The
// envelope is sourced from the durable mailbox, so a corrupt or
// malicious length near 2^64 would otherwise panic with "makeslice:
// cap out of range" or drive an OOM here, before io.ReadFull ever runs.
if payloadLen > uint64(r.Len()) {
return nil, fmt.Errorf("payload length %d exceeds %d remaining "+
"bytes", payloadLen, r.Len())
}

// Read the payload.
payload := make([]byte, payloadLen)
if _, err := io.ReadFull(r, payload); err != nil {
Expand Down
Loading
Loading