Ismaël Mejía created AVRO-4302:
----------------------------------

             Summary: Bound decode recursion depth to prevent stack overflow 
from deeply nested data
                 Key: AVRO-4302
                 URL: https://issues.apache.org/jira/browse/AVRO-4302
             Project: Apache Avro
          Issue Type: Improvement
          Components: csharp, javascript, perl, c, php, ruby, c++, java, python
            Reporter: Ismaël Mejía
            Assignee: Ismaël Mejía


h2. Summary

Avro binary decoders across the language SDKs do not bound the *recursion depth*
of decoded data. A recursive schema lets a malicious or malformed input drive
arbitrarily deep nesting during decoding, exhausting the call stack and crashing
the process ({{StackOverflowError}} in Java/C#, segfault in C/C++, thread abort
in the others) with only a tiny payload. This is a denial-of-service gap
distinct from, but in the same family as, the collection/allocation limits added
under AVRO-4292 and the decompression limits under AVRO-4283.

h2. The threat

Consider a self-referencing ("recursive") schema, e.g. a linked list:

{code:json}
{"type":"record","name":"Node","fields":[
  {"name":"next","type":["null","Node"]}
]}
{code}

Each level of nesting is encoded as a single union-branch byte (select the
{{Node}} branch) before recursing into the next {{Node}}. An attacker can
therefore encode ~1 nesting level per byte: roughly *1 MB of input yields ~1
million levels of recursion*. Because every SDK decodes nested records/arrays/
maps/unions with a recursive call chain
({{read -> readRecord -> read -> ...}}), the call stack grows in lockstep with
the data nesting and overflows long before any other limit is reached (typically
at tens of thousands of frames), taking down the decoding thread/process.

Tree-shaped recursive schemas (a record with an {{array}} of itself, a {{map}}
of itself, deeply nested unions, etc.) are equally exploitable.

h2. Why it is not currently guarded

* There is *no decoder-level structural event for records*. Arrays, maps and
  unions have {{arrayStart}}/{{mapStart}}/{{readIndex}} hooks, but a record is
  just a sequence of fields, so the decoder cannot observe record nesting -- 
only
  the reader (which drives the recursive descent) can.
* *Bounding the parser/symbol-stack size is incorrect*: for the grammar-based
  decoders the stack size conflates width and depth (a record with 100k fields
  pushes 100k symbols with zero nesting), so a size cap would falsely reject
  wide-but-shallow data. Genuine depth tracking (increment on structural
  descent, decrement on ascent) is required.
* Most SDKs have *more than one reader path* that must be guarded. In Java, for
  example: {{GenericDatumReader}} (and its Specific/Reflect subclasses) via
  {{ResolvingDecoder}}, *and* the separate {{FastReaderBuilder}} path (which
  reads from a plain decoder with pre-compiled field readers). Both recurse
  independently.

h2. Proposed approach

* Introduce a configurable maximum decode recursion depth, enforced by every
  binary decoder/reader, incremented when descending into a record/array/map/
  union value and decremented on exit, throwing a clear, bounded Avro error
  (not a {{StackOverflowError}}/segfault) once the limit is exceeded.
* Default the limit to a safe value that comfortably exceeds any realistic
  schema nesting (e.g. ~1000) while preventing stack exhaustion; make it tunable
  per SDK, and document it alongside {{AVRO_MAX_COLLECTION_ITEMS}} and the
  decompression limit.
* Cover *all* reader implementations in each SDK (e.g. Java: both the resolving/
  validating datum readers and the fast reader).
* Be careful with state: a per-instance depth counter can be corrupted by
  concurrent reuse of a reader; prefer threading the depth through the recursive
  call or another decode-scoped mechanism.
* Reject too-deep input with a distinct error type/message, separate from
  "malformed" or "collection too large".

h3. Suggested per-SDK audit points
* *Java*: {{GenericDatumReader.read/readRecord}}, {{FastReaderBuilder}} field
  readers, and the resolving/validating decoders.
* *C*: {{value-read.c}} ({{read_value}} / {{read_record_value}} etc.).
* *C++*: {{Generic.cc}} ({{GenericReader::read}}), the parsing decoders.
* *C#*: {{GenericReader.Read}} and the fast/preresolving readers.
* *Python*: {{io.py}} {{read_data}}/{{read_record}}.
* *Ruby*: {{io.rb}} {{read_data}}/{{read_record}}.
* *PHP*: {{AvroIODatumReader}} {{readData}}/{{readRecord}}.
* *Perl*: {{BinaryDecoder.pm}} {{decode}}/{{decode_record}}.
* *JavaScript*: {{schemas.js}} {{RecordType._read}} and friends.
* *Rust* (github.com/apache/avro-rs): {{decode.rs}} recursive {{decode}}.

h2. Testing

Each SDK should add a regression test that decodes a deeply nested payload for a
recursive schema (e.g. a ~1 MB linked list) and asserts a bounded, well-defined
error is raised rather than a crash / stack overflow, plus a test confirming a
legitimately (moderately) deep value within the limit still decodes.

h2. Relationship to other issues

* AVRO-4292 -- collection block-count / allocation limits (the "available bytes"
  work). Related decoder-hardening umbrella; recursion depth is deliberately
  scoped separately because it is more invasive (touches the core recursive read
  dispatch, multiple reader paths per SDK) and warrants its own design review.
* AVRO-4283 -- maximum decompressed block size. Sibling DoS-hardening effort for
  the container-file codecs.

Recommend implementing as an umbrella with one subtask per language SDK,
mirroring AVRO-4292/AVRO-4283, once a common approach and default limit are
agreed.




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to