Skip to content

lyb_write_size(): a 36-bit field is staged in a 32-bit variable #2563

Description

@tosanjay

Hello,
I found the following issue while auditing libyang with my tool. thanks.

Tested on 5.8.6 (master @ 47351e5). Still present on devel @ 0f41448 ("VERSION bump to version 6.2.1", 2026-08-24) — lyb_write_size() is byte-identical between master and devel. Not something already fixed in devel.

Summary & Root cause

In shrink mode, sizes ≥ 4096 are encoded as a 4-bit prefix followed by a 32-bit size — 36 bits total. That field is built in a 4-byte variable:

/* printer_lyb.c:491     */  uint32_t buf;                              /* FOUR bytes */
/* :519-522, else branch */  /* prefix 1110, encoded on 36 b */
                             buf = 0x7;
                             prefix_b = 4;
                             num_b = 32;
/* :526 */                   buf |= size << prefix_b;                   /* size is uint32_t */
/* :529 */                   buf = htole32(buf);
/* :531 */                   return lyb_write(&buf, prefix_b + num_b, lybctx);   /* 36 bits */

36 bits do not fit in 32. That single mistake produces two symptoms.

Symptom 1 — output that libyang cannot parse back (the one that bites)

size << prefix_b is evaluated in 32-bit arithmetic, so bits 28-31 of size are shifted out. Sizes of 268 435 456 bytes (2²⁸) or more are therefore encoded modulo 2²⁸. The parser reads exactly the 32 bits it expects (lyb_read_size(), case 4:lyb_read(size, 32, …)), gets the wrong length, and desynchronises.

Round-tripping a string leaf of length n with LYD_PRINT_SHRINK (harness/roundtrip_size_boundary.c, plain build, no sanitizer):

n=5000       parsed_len=5000       IDENTICAL
n=268435455  parsed_len=268435455  IDENTICAL          <- 2^28 - 1
n=268435456  parse FAILED (6)                         <- 2^28, LY_EINT
             Invalid context for LYB data parsing, module "ietf-inet-types@2025-12-22" not implemented.

lyd_print_all() returns LY_SUCCESS and produces a blob libyang then refuses to read. The error names a module unrelated to the data — a side effect of the parser desynchronising on the truncated length, not a context problem — so the failure is also misleading to diagnose.

Symptom 2 — a one-byte out-of-bounds stack read

Emitting 36 bits (4.5 bytes) from a 4-byte object makes the write paths step past it. Which path fires depends on lybctx->buf_bits, the printer's pending bit count when the size is emitted — so all three sinks below are reachable in ordinary printing, and every one of them reads the same address: offset 36 in lyb_write_size's frame, one byte past [32, 36) 'buf'.

/* :360 */  memcpy(buf2, buf, count_bytes);   /* count_bytes == 5 when (buf_bits + 4) / 8 == 1 */
/* :292 */  byte = byte_p[0];                 /* byte_p == &buf[4] */
/* :307 */  byte = byte_p[1];                 /* byte_p == &buf[3] */

:292 and :307 are in lyb_write_buffer_store(), reached from lyb_write() at :380; byte_p is computed at :287 as &((uint8_t *)buf)[(count_bits - count_bits_remainder) / 8].

Sweeping buf_bits by varying how many small values precede the long one (harness/sink_sweep.c — a top-level leaf preceded by pre leaf-list entries; ASan with -fsanitize-recover=address and halt_on_error=0 so every read is reported, not just the first — log asan/sink_sweep_buf_bits.log):

pre   errors   sink line(s) in printer_lyb.c
0     1        printer_lyb.c:307
1     2        printer_lyb.c:292 printer_lyb.c:360
2     1        printer_lyb.c:360
8     1        printer_lyb.c:292

A standalone reproducer for the :360 sink is harness/gen_shrink.c — one 4096-byte leaf inside a container, printed with LYD_PRINT_SHRINK. Its report, in asan/printer_shrink_4096_oob.log (abridged here: full paths and the two libc/interceptor memcpy frames elided, nothing else changed):

[gen] printing value_len=4096 shrink=YES
==275942==ERROR: AddressSanitizer: stack-buffer-overflow
READ of size 5 at 0x7fffdb441a74 thread T0
    #2 lyb_write            printer_lyb.c:360   (memcpy)
    #3 lyb_write_size       printer_lyb.c:531
    #4 lyb_print_value      printer_lyb.c:814
    #5 lyb_print_node_leaf  printer_lyb.c:1225
Address 0x7fffdb441a74 is located in stack of thread T0 at offset 36 in frame
    #0 lyb_write_size printer_lyb.c:489
  This frame has 1 object(s):
    [32, 36) 'buf' (line 491) <== Memory access at offset 36 overflows this variable

Two controls, both clean, both attached:

value length 4095, shrink     -> print returned 0    asan/printer_shrink_4095_clean.log
value length 4096, non-shrink -> print returned 0    asan/printer_noshrink_4096_clean.log

The boundary is exactly where the 36-bit encoding starts, and the defect is specific to LYD_PRINT_SHRINK. Over pre=0..7, lengths 4094 and 4095 give 0 ASan errors and 4096 and 4097 give 12 each.

What this byte does and does not do — we checked rather than assumed. The over-read byte is not an aliased local: prefix_b, num_b and byte_len sit at buf-3, buf-2 and buf-1, below buf; the byte read is the one immediately above it, which nothing in the function writes.

[instr] &buf=0x7ffd4292bfd4 &prefix_b=0x7ffd4292bfd1 &num_b=0x7ffd4292bfd2 &byte_len=0x7ffd4292bfd3 buf[4]@0x7ffd4292bfd8=0x00

We then tried to make it non-zero: the long leaf nested at nine different depths (moving that frame across ~2 KB of stack), after first printing a deeply nested tree in the same process so those regions had already been used, repeated with libyang built at -O0, -O1 and -O2. 30 observations, all 0x00harness/oob_byte_probe.c, with patch/instrument_oob_byte.patch applied to libyang so the byte is actually printed; full log in asan/oob_byte_observations.log.

So on this toolchain (gcc 11.4, x86-64) the byte is deterministically zero, and we make no claim of information disclosure — what it writes to the output is a constant zero. The read is still out of bounds and still UB; a different compiler, platform or inlining decision could place live data there, since the defect is spatial rather than value-dependent.

This also explains why symptom 1 only appears at 2²⁸: those four bits should carry bits 32-35 of the field, they read as zero, and zero is the correct value for every size below 2²⁸. The encoding is accidentally right until the size genuinely needs those bits.

Reproducing

  • unzip the attached artifacts.zip *

Dependency: libpcre2-dev, plus cmake and a C compiler. ENABLE_TESTS/ENABLE_VALGRIND_TESTS default to ON for Debug, so pass them off unless you want the CMocka suite built. No *_LINKER_FLAGS are needed — CMake passes CMAKE_C_FLAGS to the link line. Include paths must point into the build tree, since libyang.h and ly_config.h are generated at configure time.

# symptom 1 — a plain build is enough
cmake -S <libyang> -B build-plain -DCMAKE_BUILD_TYPE=Debug -DCMAKE_C_FLAGS="-g -O1" \
  -DENABLE_TESTS=OFF -DENABLE_VALGRIND_TESTS=OFF && cmake --build build-plain -j$(nproc)
gcc -g -O1 -I build-plain/libyang -I build-plain/compat \
    harness/roundtrip_size_boundary.c -o rt -L build-plain -lyang -Wl,-rpath,$PWD/build-plain
export LYB_MODULES_DIR=<libyang>/modules
./rt 268435455     # IDENTICAL
./rt 268435456     # parse fails

# symptom 2 — needs ASan; -fsanitize-recover=address to see every read, not just the first
cmake -S <libyang> -B build-asan -DCMAKE_BUILD_TYPE=Debug \
  -DCMAKE_C_FLAGS="-fsanitize=address,undefined -fsanitize-recover=address -fno-omit-frame-pointer -g -O1" \
  -DENABLE_TESTS=OFF -DENABLE_VALGRIND_TESTS=OFF && cmake --build build-asan -j$(nproc)
gcc -g -O1 -fsanitize=address,undefined -fsanitize-recover=address -fno-omit-frame-pointer \
    -I build-asan/libyang -I build-asan/compat \
    harness/gen_shrink.c -o gen_shrink -L build-asan -lyang -Wl,-rpath,$PWD/build-asan
./gen_shrink /dev/null 4096 shrink            # fires;  4095, or without "shrink", is clean

# the buf_bits sweep, and the byte observations (the latter needs the instrumentation patch)
gcc ... harness/sink_sweep.c -o sink_sweep -L build-asan -lyang -Wl,-rpath,$PWD/build-asan
ASAN_OPTIONS=halt_on_error=0 ./sink_sweep <preceding_values> 4096

./rt 268435456 allocates a 256 MB value, so it needs roughly 1 GB of RAM.

Suggested fix — patch attached

patch/0001-printer-lyb-widen-lyb_write_size-staging-buffer.patch, four lines against master @ 47351e5:

-    uint32_t buf;
+    uint64_t buf;
-        buf = htole32(size);
+        buf = htole64(size);
-    buf |= size << prefix_b;
+    buf |= (uint64_t)size << prefix_b;
-    buf = htole32(buf);
+    buf = htole64(buf);

The htole64 changes are required rather than cosmetic — leaving htole32 on a widened variable would truncate the value on a big-endian host.

Verified, transcript in patch/PATCH_VERIFICATION.log:

check result
the patch touches only lyb_write_size yes — lyb_write_count() is untouched
new compiler warnings none; the 3 pre-existing warnings are unchanged
ASan, 40 runs (5 lengths × 8 buf_bits states) 48 out-of-bounds reads across 32/40 runs → 0 across 0/40
round-trip at 2²⁸−1 IDENTICAL, before and after
round-trip at 2²⁸ parse fails before → IDENTICAL after
output below 2²⁸, shrink and non-shrink 36/36 byte-identical to the current implementation

That last row is the one worth checking: this is not a wire-format change. It makes the fifth byte hold the bits it was always meant to hold instead of adjacent stack — and since that stack byte reads as zero here, the bytes on the wire are the same ones.

Note lyb_write_count() has the same shape but is not affected — its widest encoding is 6 + 26 = 32 bits, which fits, and counts ≥ 2²⁶ are rejected explicitly with LY_EINT.

Who is affected

Applications that produce LYB with LYD_PRINT_SHRINK — caching, IPC, on-disk datastores. LYD_PRINT_SHRINK is a documented public flag; default LYB printing is unaffected. A value of 256 MB or more is written successfully and is unreadable afterwards, so the loss surfaces when the data is read back rather than when it is written.

Bundle

harness/ is what you compile and run, patch/ is what you apply to libyang, asan/ is the output we got.

file role
harness/roundtrip_size_boundary.c prints and re-parses a leaf of length nsymptom 1, the 2²⁸ boundary
harness/gen_shrink.c prints a leaf of a given length, with or without LYD_PRINT_SHRINKsymptom 2 and its two controls
harness/sink_sweep.c varies the values printed before the long one, sweeping buf_bits so each of the three sinks is reached
harness/oob_byte_probe.c samples the over-read byte across call depths; needs patch/instrument_oob_byte.patch
patch/0001-printer-lyb-widen-lyb_write_size-staging-buffer.patch the fix
patch/instrument_oob_byte.patch one fprintf in lyb_write_size that prints the stack layout and buf[4]for observation only, not for merge
patch/PATCH_VERIFICATION.log the six checks in the table above, before and after
asan/printer_shrink_4096_oob.log ASan stack-buffer-overflow at 4096
asan/printer_shrink_4095_clean.log control — 4095 prints cleanly
asan/printer_noshrink_4096_clean.log control — 4096 without LYD_PRINT_SHRINK prints cleanly
asan/sink_sweep_buf_bits.log all three sinks, the shared target address, and the 4094/4095/4096/4097 boundary
asan/oob_byte_observations.log the 30 byte observations and the stack layout around buf

The asan/printer_*.log files are raw program output. The other three logs are transcripts of the commands shown above; the only text not produced by those commands is the # header lines identifying the build.

artifacts.zip

Metadata

Metadata

Assignees

No one assigned

    Labels

    is:bugBug description.status:completedFrom the developer perspective, the issue was solved (bug fixed, question answered,...)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions