--- license: apache-2.0 tags: - security - poc - vulnerability - dos - fastavro - avro --- # fastavro: unbounded native recursion decoding a recursive-schema Avro container file causes C-stack overflow (SIGSEGV / DoS) **Target:** [`fastavro`](https://pypi.org/project/fastavro/) — fast Avro implementation for Python **Affected version:** fastavro **1.12.2** (latest release at time of report) **Component:** compiled Cython reader — `fastavro/_read.pyx` → `_read.cpython-313-x86_64-linux-gnu.so` (the default `fastavro.reader()` API) **Class:** CWE-674 Uncontrolled Recursion → C-stack exhaustion → SIGSEGV (Denial of Service) **Impact:** An attacker-supplied `.avro` object-container file (~5 KB) crashes the Python interpreter (SIGSEGV) of any application that decodes it with the standard `fastavro.reader()`. No reader-side schema cooperation is required — the malicious schema travels inside the file header. --- ## Root cause An Avro **object-container file** embeds its own writer schema inside its header metadata under the `avro.schema` key. fastavro reads that schema out of the file and uses it to decode the data blocks. This means an attacker fully controls the decoding schema. fastavro decodes `record` and `union` values by mutual recursion, with **no depth limit**. In the mirror pure-Python source (`fastavro/_read_py.py`, same logic as the compiled `_read.pyx`): - `read_union` (line ~406) selects a branch and calls `read_data(...)` on it. - `read_record` (line ~496) iterates the record's fields and calls `read_data(...)` for each field's type. So for a self-recursive record schema the decode call chain is: ``` read_record -> _read_data -> read_union -> _read_data -> read_record -> ... ``` one native frame per nesting level. The compiled Cython extension does **not** participate in Python's `sys.setrecursionlimit` accounting, so instead of a catchable `RecursionError` the **native C call stack is exhausted**, crashing the whole interpreter with **SIGSEGV**. ### Attacker-controlled recursive schema (embedded in the file header) ```json {"type":"record","name":"N","fields":[{"name":"next","type":["null","N"]}]} ``` Each nesting level is encoded as a single union-index byte `0x02` (branch index 1 = recurse into `N`); the chain is terminated by one `0x00` byte (branch index 0 = `null`). A file with `D` copies of `0x02` forces `D` levels of native recursion. --- ## PoC `gen.py` hand-builds a valid Avro object-container file with no dependency on fastavro's writer: - magic `Obj\x01` - metadata map `{avro.schema: , avro.codec: "null"}` - 16-byte sync marker - one data block: `object_count=1`, a bytes-framed payload of `D` × `0x02` followed by `0x00` - trailing sync marker ``` $ ./venv/bin/python gen.py crash.avro 50000 # depth D = 50000 $ ./venv/bin/python -c "import fastavro; [None for _ in fastavro.reader(open('crash.avro','rb'))]" ``` `crash.avro` is 50,157 bytes. The minimal trigger is ~5 KB (`D ≈ 5000`). --- ## Captured evidence (verbatim, fastavro 1.12.2, CPython 3.13) ``` $ ./venv/bin/python -X faulthandler -c "import fastavro; [None for _ in fastavro.reader(open('crash.avro','rb'))]" Fatal Python error: Segmentation fault Current thread 0x00007fcb169d1200 (most recent call first): File "", line 1 in Extension modules: fastavro._logical_readers, fastavro._schema, fastavro._read, fastavro._logical_writers, fastavro._validation, fastavro._write (total: 6) ``` Process exits with signal 11 (shell exit code 139). ### Threshold / signal (isolated subprocesses) ``` D=3000 exit=0 (clean decode) D=5000 exit=139 SIGSEGV D=8000 exit=139 SIGSEGV D=12000 exit=139 SIGSEGV D=20000 exit=139 SIGSEGV D=50000 exit=139 SIGSEGV ``` ### Negative control ``` shallow.avro (D=5) -> decodes cleanly, exit 0 ``` ### Pure-Python contrast (isolates the defect to the compiled extension) Running the **same** `crash.avro` through the pure-Python fallback reader raises a **catchable** `RecursionError` instead of segfaulting: ``` $ ./venv/bin/python -c "from fastavro import _read_py; [None for _ in _read_py.reader(open('crash.avro','rb'))]" RecursionError (catchable) ``` This confirms the root cause is the compiled C extension (`_read`), which is what `fastavro.reader()` uses by default; the pure-Python path is protected by Python's recursion limit, the native path is not. Verified compiled reader on disk: `venv/lib/python3.13/site-packages/fastavro/_read.cpython-313-x86_64-linux-gnu.so`. --- ## Impact Any service that ingests untrusted Avro container files with the standard, documented `fastavro.reader()` / `fastavro.reader(fo)` API — data pipelines, message consumers, file upload processors, ML feature stores — can be crashed (interpreter SIGSEGV, hard process kill) by a single ~5 KB file. The attack needs no reader-side schema; the recursive schema is carried inside the file. This is a reliable, low-cost, unauthenticated Denial of Service. ## Suggested remediation Enforce a configurable maximum decode-recursion depth in the Cython reader (mirroring what Python's recursion limit gives the pure-Python path), raising a catchable exception when exceeded — analogous to defenses used by other schema/serialization decoders against self-referential type definitions. ## Dedup / prior work No known CVE for this specific stack-overflow-via-recursive-schema in fastavro at time of report. Distinct from the codec/decompression memory-amplification bug class (a separate finding, "avro-decompression-bomb") and from TFRecord/Avro tooling findings — those concern memory amplification, not native call-stack exhaustion via schema recursion.