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 0x00 — harness/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 n — symptom 1, the 2²⁸ boundary |
harness/gen_shrink.c |
prints a leaf of a given length, with or without LYD_PRINT_SHRINK — symptom 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
Hello,
I found the following issue while auditing libyang with my tool. thanks.
Tested on 5.8.6 (
master@47351e5). Still present ondevel@0f41448("VERSION bump to version 6.2.1", 2026-08-24) —lyb_write_size()is byte-identical betweenmasteranddevel. Not something already fixed indevel.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:
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_bis evaluated in 32-bit arithmetic, so bits 28-31 ofsizeare 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):lyd_print_all()returnsLY_SUCCESSand 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 inlyb_write_size's frame, one byte past[32, 36) 'buf'.:292and:307are inlyb_write_buffer_store(), reached fromlyb_write()at:380;byte_pis computed at:287as&((uint8_t *)buf)[(count_bits - count_bits_remainder) / 8].Sweeping
buf_bitsby varying how many small values precede the long one (harness/sink_sweep.c— a top-level leaf preceded by preleaf-listentries; ASan with-fsanitize-recover=addressandhalt_on_error=0so every read is reported, not just the first — logasan/sink_sweep_buf_bits.log):A standalone reproducer for the
:360sink isharness/gen_shrink.c— one 4096-byte leaf inside a container, printed withLYD_PRINT_SHRINK. Its report, inasan/printer_shrink_4096_oob.log(abridged here: full paths and the two libc/interceptormemcpyframes elided, nothing else changed):Two controls, both clean, both attached:
The boundary is exactly where the 36-bit encoding starts, and the defect is specific to
LYD_PRINT_SHRINK. Overpre=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_bandbyte_lensit atbuf-3,buf-2andbuf-1, belowbuf; the byte read is the one immediately above it, which nothing in the function writes.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,-O1and-O2. 30 observations, all0x00—harness/oob_byte_probe.c, withpatch/instrument_oob_byte.patchapplied to libyang so the byte is actually printed; full log inasan/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
Dependency:
libpcre2-dev, plus cmake and a C compiler.ENABLE_TESTS/ENABLE_VALGRIND_TESTSdefault toONforDebug, so pass them off unless you want the CMocka suite built. No*_LINKER_FLAGSare needed — CMake passesCMAKE_C_FLAGSto the link line. Include paths must point into the build tree, sincelibyang.handly_config.hare generated at configure time../rt 268435456allocates 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 againstmaster@47351e5:The
htole64changes are required rather than cosmetic — leavinghtole32on a widened variable would truncate the value on a big-endian host.Verified, transcript in
patch/PATCH_VERIFICATION.log:lyb_write_sizelyb_write_count()is untouchedbuf_bitsstates)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 withLY_EINT.Who is affected
Applications that produce LYB with
LYD_PRINT_SHRINK— caching, IPC, on-disk datastores.LYD_PRINT_SHRINKis 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.harness/roundtrip_size_boundary.charness/gen_shrink.cLYD_PRINT_SHRINK— symptom 2 and its two controlsharness/sink_sweep.cbuf_bitsso each of the three sinks is reachedharness/oob_byte_probe.cpatch/instrument_oob_byte.patchpatch/0001-printer-lyb-widen-lyb_write_size-staging-buffer.patchpatch/instrument_oob_byte.patchfprintfinlyb_write_sizethat prints the stack layout andbuf[4]— for observation only, not for mergepatch/PATCH_VERIFICATION.logasan/printer_shrink_4096_oob.logasan/printer_shrink_4095_clean.logasan/printer_noshrink_4096_clean.logLYD_PRINT_SHRINKprints cleanlyasan/sink_sweep_buf_bits.logasan/oob_byte_observations.logbufThe
asan/printer_*.logfiles 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