Skip to content

multi: harden TLV decoders against unbounded-allocation DoS, add fuzzing#785

Open
Roasbeef wants to merge 10 commits into
mainfrom
fuzz-tlv-serialization
Open

multi: harden TLV decoders against unbounded-allocation DoS, add fuzzing#785
Roasbeef wants to merge 10 commits into
mainfrom
fuzz-tlv-serialization

Conversation

@Roasbeef

Copy link
Copy Markdown
Member

Motivation

Every TLV decoder in the client parses bytes that either cross the
client/server trust boundary or are replayed from a durable actor
mailbox on restart. A single malformed payload that reaches one of these
decoders can take the process down, and because durable messages are
replayed from disk on every restart, one poisoned mailbox entry becomes
a permanent crash loop.

The bug

lnd/tlv sizes its value buffers with make([]byte, declaredLength)
before reading a single value byte. This happens in two spots:

  • DVarBytes, used for every known []byte record, and
  • the unknown-record discard path in stream.go, which fires under
    DecodeWithParsedTypes.

Neither path on the non-P2P decode API caps the declared length. So
roughly ten attacker-controlled bytes that declare a record length near
2^63 panic the decoder with makeslice: cap out of range, and an
in-range-but-huge length drives the process to OOM. The unknown-record
path is the nasty part: even a message whose own fields are all
fixed-width scalars is vulnerable, because an injected unknown odd type
carries its own attacker-chosen length. We confirmed this is reachable
from essentially every decoder in the client.

The fix

Each package gets a bounded-decode guard:

  • decoders that use DecodeWithParsedTypes read the payload into a
    size-capped buffer and pre-validate the (type, length) framing,
    rejecting any record whose declared length exceeds the bytes
    physically present, before the real decoder ever allocates;
  • hand-rolled make([]T, count) / make([]byte, size) sites bound the
    count or length against the bytes remaining in the reader;
  • a couple of short-read-prone reader.Read calls become io.ReadFull.

The caps are per-package and generous (OOR, for instance, is sized for
many aggregated 64 KiB PSBT blobs, which DecodeP2P's fixed per-record
cap would wrongly reject), so well-formed payloads of any realistic size
still decode; only genuinely corrupt framing is rejected, with a clean
wrapped error.

Fuzzing

Each affected package gets a native Go fuzz harness over its decode
entry points. Every target seeds a valid encoding, the empty input, and
the canonical malicious-length payload, so the bound runs as a
deterministic regression on every go test. The crashers that surfaced
during development are kept as regression corpus.

Packages hardened

ledger, mailbox/conn, serverconn, db (tree codec), oor,
baselib/actor + internal/actortest, lib/types, lib/recovery
(+ a lib/bip322 signature fuzz harness), lib/tx/checkpoint,
lib/tx/oor, unroll, and unrollplan. indexer is encode-only and
needs no change.

Testing

  • go build and go vet clean across both modules.
  • Existing unit tests pass.
  • Every fuzz target runs clean (no panic, no OOM) after the fixes.

Commits are atomic per package.

Roasbeef added 10 commits June 23, 2026 16:35
The client ledger actor decodes TLV messages that cross the
client/server trust boundary and are replayed from the durable mailbox
on restart. The lnd/tlv stream sizes its value buffers with
make([]byte, declaredLength) before reading any value bytes -- both for
unknown records (stream.go, under DecodeWithParsedTypes) and known
[]byte records (DVarBytes) -- and never caps that length. A handful of
attacker-controlled bytes declaring a near-2^64 record length therefore
panic the decoder with "makeslice: cap out of range", and a
multi-gigabyte length drives the process to OOM.

Read each message into a size-capped buffer and pre-validate the (type,
length) framing before handing it to the decoder, rejecting any record
whose declared length exceeds the bytes physically present. This caps
every allocation at maxLedgerMessageSize and mirrors the server-side
ledger fix. A fuzz harness over all six message decoders exercises the
bound.
AckState and the wrapped-proto record decode bytes read back from the
durable checkpoint store, so a corrupt or adversarial blob is
attacker-reachable. As elsewhere, the lnd/tlv decoder allocates
make([]byte, declaredLength) from the wire length before reading any
bytes, so a tiny payload declaring a huge record length panics or OOMs
the process. Route AckState.Decode through a size-capped,
framing-validated reader and bound the wrapped-proto length against the
message cap before allocating. Fuzz harnesses cover both decoders.
The serverconn connector decodes event, RPC, and unary-query TLV
payloads that originate across the trust boundary and replay from the
durable mailbox. Two unbounded-allocation vectors existed: the lnd/tlv
decoder sizes value buffers from the declared record length before
reading any bytes, and the durable unary query codec read a
length-prefixed blob (and a blob-list count) straight from input into
make() without bounds -- a few bytes could request a multi-gigabyte
allocation.

Route every Decode through a size-capped, framing-validated reader, and
bound the blob length and list count against the bytes remaining in the
reader before allocating. Fuzz harnesses cover the event/RPC/unary and
list decoders.
The VTXO tree codec deserializes blobs read back from the database and
re-parsed, so a corrupt or adversarial row is attacker-reachable.
make([]byte, recordLength) in the lnd/tlv decoder and several
count-prefixed make([]T, count) sites (tree nodes, txouts, pubkeys)
could each turn a few bytes into a multi-gigabyte allocation
("makeslice" panic or OOM).

Route the tree and node decoders through a size-capped,
framing-validated reader and bound every decoded element count against
the bytes remaining before allocating, on top of the existing depth and
children-per-node limits. A fuzz harness over the tree deserializer
exercises the bounds.
The OOR durable actor messages, snapshots, and package artifacts decode
TLV payloads that cross the client/server trust boundary and persist in
the durable mailbox. The lnd/tlv decoder allocates
make([]byte, declaredLength) from the wire length before reading any
bytes, and the nested length-prefixed blob lists read a per-element
length (and list count) into make() without bounds -- both reachable
from a tiny hostile payload that panics or OOMs the process.

Add a shared size-capped, framing-validated bounded-decode choke point
(maxOORMessageSize is sized for the legitimate worst case of many 64 KiB
aggregated PSBT blobs, which DecodeP2P's per-record cap would wrongly
reject) and route every OOR DecodeWithParsedTypes through it; bound the
blob-list count and per-element length against remaining input. A fuzz
harness covers the durable-message, snapshot, and artifact decoders.
The actor envelope codec (MessageCodec) is the top-level decoder for
every durable mailbox message, and AskResponse/RestartMessage carry
[]byte fields; all decode bytes replayed from disk across upgrades. The
lnd/tlv decoder sizes make([]byte, declaredLength) from the wire length
before reading any bytes -- including the unknown-record path under
DecodeWithParsedTypes, which crashes even a scalar-only message -- so a
tiny hostile payload panics or OOMs the process on replay.

Bound the envelope payload length, and route the actor message decoders
through a size-capped, framing-validated reader. The counter test
messages get the same guard plus a fuzz harness, which surfaced that the
unknown-record path is reachable regardless of a message's own field
set.
DecodeJoinRoundAuthMessage and DeserializeTxProof parse bytes that cross
the client/server trust boundary. The lnd/tlv decoder sizes
make([]byte, declaredLength) from the wire length before reading any
bytes, so a tiny payload declaring a near-2^64 record length panics with
"makeslice: cap out of range" or OOMs the process.

Read each payload into a size-capped buffer and pre-validate the (type,
length) framing before decoding, rejecting any record whose declared
length exceeds the bytes present. The existing per-entry blob-list bound
is retained. Fuzz harnesses cover the auth message and tx-proof
decoders.
The recovery proof and session-state codecs decode persisted bytes that
can be corrupt or adversarial. The lnd/tlv decoder allocates
make([]byte, declaredLength) from the wire length before reading any
bytes, so a tiny hostile payload panics or OOMs the process.

Read each payload into a size-capped buffer (sized for the legitimate
worst case of MaxProofNodes) and pre-validate the (type, length) framing
before decoding. Fuzz harnesses cover the proof and session-state
decoders, plus the bip322 signature decoder (DecodeSig), which parses a
remote peer's BIP-322 signature through btcd's internally-bounded
wire.MsgTx deserializer.
DecodeTapTree and the OOR submit-package decoder parse bytes that cross
the trust boundary and persist durably. The lnd/tlv decoder sizes
make([]byte, declaredLength) from the wire length before reading any
bytes, the per-leaf taptree scripts and the package blob list read a
length/count straight from input into make() without bounds, and the
blob reader accepted short reads -- so a tiny hostile payload panics,
OOMs, or silently decodes truncated data.

Route the taptree and package decoders through a size-capped,
framing-validated reader, bound every blob count and length against the
bytes remaining, and use io.ReadFull where the buffer must be filled.
Fuzz harnesses cover the taptree and submit-package decoders.
The unroll mailbox messages, deferred-checkpoint codec, and unrollplan
state codec decode bytes that persist in the durable mailbox and replay
across upgrades. The lnd/tlv decoder allocates
make([]byte, declaredLength) from the wire length before reading any
bytes, the deferred-checkpoint codec read a count and per-entry length
into make() without bounds and used a short-read-prone reader.Read, and
the state codec decoded length-prefixed lists unbounded -- each
reachable from a tiny hostile payload that panics or OOMs the process.

Route every decoder through a size-capped, framing-validated reader,
bound counts and lengths against the bytes remaining before allocating,
and switch the entry reader to io.ReadFull. Fuzz harnesses cover the
unroll messages, deferred checkpoints, and unrollplan state.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request hardens the TLV (Type-Length-Value) decoding paths across multiple packages to prevent unbounded-allocation DoS vulnerabilities, panics (such as 'makeslice: cap out of range'), and OOM errors. It introduces pre-validation helpers (e.g., safeTLVReader, validateTLVRecordLengths) that verify declared record lengths against the actual remaining bytes in the payload before any memory allocation occurs. Additionally, the PR adds a comprehensive suite of fuzz tests to ensure robust, panic-free decoding of untrusted inputs across all affected modules. No review comments were provided, so there is no feedback to address.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de1abf970c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/types/tlv_bounds.go
// BIP-322-signed join intent; TxProof is a boarding SPV proof the server
// verifies) and TxProof additionally persists durably, so the bound is
// load-bearing on both the live and replay path.
const maxTypesMessageSize = 16 << 20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align join-auth cap with existing list limits

This 16 MiB cap is lower than this package's own accepted join-auth limits: decodeBoundedBlobList still allows each of the boarding/VTXO/forfeit/leave lists to contain up to 1024 entries of up to 64 KiB each. A signed JoinRoundAuth with a single list a little over 16 MiB (for example 1024 entries around 17 KiB, all below the per-entry limit) now fails in DecodeJoinRoundAuthMessage before the existing per-list validation runs, even though it was within the advertised limits. Please derive the total cap from the existing list limits or lower those limits consistently so large but valid join requests are not rejected.

Useful? React with 👍 / 👎.

@Roasbeef
Roasbeef force-pushed the fuzz-tlv-serialization branch from 99eaf34 to de1abf9 Compare June 24, 2026 00:18
@levmi levmi added bug Something isn't working encoding mailbox Durable mailbox transport oor P0 Priority 0 — blocker security Security hardening and access control labels Jul 6, 2026
@lightninglabs-deploy

Copy link
Copy Markdown
Collaborator

@Roasbeef, remember to re-request review from reviewers when ready

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working encoding mailbox Durable mailbox transport oor P0 Priority 0 — blocker security Security hardening and access control

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants