[GH-ISSUE #496] Heap oob read in lxw_styles_write_string_fragment on empty style string #386

Closed
opened 2026-05-05 12:14:15 -06:00 by gitea-mirror · 1 comment
Owner

Originally created by @hgarrereyn on GitHub (Dec 18, 2025).
Original GitHub issue: https://github.com/jmcnamara/libxlsxwriter/issues/496

Hi, there is a potential bug in lxw_styles_write_string_fragment reachable when invoked with an empty string.

This bug was reproduced on f0647157ba.

Description

What crashes and where:

  • The crash occurs in styles.c at lxw_styles_write_string_fragment when adding an attribute to preserve whitespace:
    if (isspace((unsigned char)string[0]) || isspace((unsigned char)string[strlen(string) - 1]))
  • For an empty string (length 0), the expression string[strlen(string) - 1] reads one byte before the buffer, causing an out-of-bounds read. AddressSanitizer reports a heap/global buffer overflow depending on how the empty string storage is provided.

Suggested fix in the library:

  • Guard the trailing-character access with a length check. For example:
    size_t len = strlen(string);
    if (len > 0 && (isspace((unsigned char)string[0]) || isspace((unsigned char)string[len - 1]))) {
    LXW_PUSH_ATTRIBUTES_STR("xml:space", "preserve");
    }

POC

The following testcase demonstrates the bug:

testcase.cpp

#include <cstdio>
extern "C" {
#include "/fuzz/install/include/xlsxwriter/styles.h"
}
int main(){
    lxw_styles* s = lxw_styles_new();
    if (!s) return 0;
    FILE* fp = fopen("/tmp/out.xml", "wb+");
    if (!fp) return 0;
    s->file = fp;                 // satisfy FILE* precondition
    const char* str = "";        // empty string triggers bug
    // Triggers OOB read at styles.c:98: string[strlen(string)-1] with len==0
    lxw_styles_write_string_fragment(s, str);
    fclose(fp);
    return 0;
}

stdout


stderr

=================================================================
==1==ERROR: AddressSanitizer: global-buffer-overflow on address 0x559409043bdf at pc 0x559409025bf3 bp 0x7fff45e17010 sp 0x7fff45e17008
READ of size 1 at 0x559409043bdf thread T0
    #0 0x559409025bf2 in lxw_styles_write_string_fragment (/fuzz/test+0x10bbf2) (BuildId: e622a699f6a8197fcee2f648da01711d43627a92)
    #1 0x5594090254b7 in main /fuzz/testcase.cpp:13:5
    #2 0x7fade6150d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #3 0x7fade6150e3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #4 0x559408f4a304 in _start (/fuzz/test+0x30304) (BuildId: e622a699f6a8197fcee2f648da01711d43627a92)

0x559409043bdf is located 1 bytes before global variable '.str.2' defined in '/fuzz/testcase.cpp:11' (0x559409043be0) of size 1
  '.str.2' is ascii string ''
0x559409043bdf is located 27 bytes after global variable '.str.1' defined in '/fuzz/testcase.cpp:8' (0x559409043bc0) of size 4
  '.str.1' is ascii string 'wb+'
SUMMARY: AddressSanitizer: global-buffer-overflow (/fuzz/test+0x10bbf2) (BuildId: e622a699f6a8197fcee2f648da01711d43627a92) in lxw_styles_write_string_fragment
Shadow bytes around the buggy address:
  0x559409043900: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x559409043980: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x559409043a00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x559409043a80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x559409043b00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x559409043b80: 00 00 00 00 00 05 f9 f9 04 f9 f9[f9]01 f9 f9 f9
  0x559409043c00: 00 00 00 00 00 00 00 00 00 00 00 00 00 03 f9 f9
  0x559409043c80: f9 f9 f9 f9 00 00 07 f9 f9 f9 f9 f9 00 02 f9 f9
  0x559409043d00: 00 01 f9 f9 02 f9 f9 f9 00 03 f9 f9 04 f9 f9 f9
  0x559409043d80: 05 f9 f9 f9 02 f9 f9 f9 02 f9 f9 f9 07 f9 f9 f9
  0x559409043e00: 00 f9 f9 f9 07 f9 f9 f9 00 04 f9 f9 00 02 f9 f9
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07 
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==1==ABORTING

Steps to Reproduce

The crash was triaged with the following Dockerfile:

Dockerfile

# Ubuntu 22.04 with some packages pre-installed
FROM hgarrereyn/stitch_repro_base@sha256:3ae94cdb7bf2660f4941dc523fe48cd2555049f6fb7d17577f5efd32a40fdd2c

RUN git clone https://github.com/jmcnamara/libxlsxwriter.git /fuzz/src && \
    cd /fuzz/src && \
    git checkout f0647157ba70d3b9372e5eef05ed489c73107b62 && \
    git submodule update --init --remote --recursive

ENV LD_LIBRARY_PATH=/fuzz/install/lib
ENV ASAN_OPTIONS=hard_rss_limit_mb=1024:detect_leaks=0

RUN echo '#!/bin/bash\nexec clang-17 -fsanitize=address -O0 "$@"' > /usr/local/bin/clang_wrapper && \
    chmod +x /usr/local/bin/clang_wrapper && \
    echo '#!/bin/bash\nexec clang++-17 -fsanitize=address -O0 "$@"' > /usr/local/bin/clang_wrapper++ && \
    chmod +x /usr/local/bin/clang_wrapper++

RUN apt-get update && apt-get install -y --no-install-recommends \
    cmake \
    ninja-build \
    make \
    pkg-config \
    zlib1g-dev \
 && rm -rf /var/lib/apt/lists/*

ENV CC=clang_wrapper \
    CXX=clang_wrapper++

WORKDIR /fuzz/src

RUN cmake -S . -B build -G Ninja \
    -DCMAKE_BUILD_TYPE=Release \
    -DBUILD_SHARED_LIBS=OFF \
    -DBUILD_TESTS=OFF \
    -DBUILD_EXAMPLES=OFF \
    -DCMAKE_INSTALL_PREFIX=/fuzz/install \
 && cmake --build build --config Release \
 && cmake --install build --config Release

Build Command

clang++-17 -fsanitize=address -g -O0 -o /fuzz/test /fuzz/testcase.cpp -I/fuzz/install/include -L/fuzz/install/lib -lxlsxwriter -lz && /fuzz/test

Reproduce

  1. Copy Dockerfile and testcase.cpp into a local folder.
  2. Build the repro image:
docker build . -t repro --platform=linux/amd64
  1. Compile and run the testcase in the image:
docker run \
    -it --rm \
    --platform linux/amd64 \
    --mount type=bind,source="$(pwd)/testcase.cpp",target=/fuzz/testcase.cpp \
    repro \
    bash -c "clang++-17 -fsanitize=address -g -O0 -o /fuzz/test /fuzz/testcase.cpp -I/fuzz/install/include -L/fuzz/install/lib -lxlsxwriter -lz && /fuzz/test"


Additional Info

This testcase was discovered by STITCH, an autonomous fuzzing system. All reports are reviewed manually (by a human) before submission.

Originally created by @hgarrereyn on GitHub (Dec 18, 2025). Original GitHub issue: https://github.com/jmcnamara/libxlsxwriter/issues/496 Hi, there is a potential bug in `lxw_styles_write_string_fragment` reachable when invoked with an empty string. This bug was reproduced on https://github.com/jmcnamara/libxlsxwriter/commit/f0647157ba70d3b9372e5eef05ed489c73107b62. ### Description What crashes and where: - The crash occurs in styles.c at lxw_styles_write_string_fragment when adding an attribute to preserve whitespace: if (isspace((unsigned char)string[0]) || isspace((unsigned char)string[strlen(string) - 1])) - For an empty string (length 0), the expression string[strlen(string) - 1] reads one byte before the buffer, causing an out-of-bounds read. AddressSanitizer reports a heap/global buffer overflow depending on how the empty string storage is provided. Suggested fix in the library: - Guard the trailing-character access with a length check. For example: size_t len = strlen(string); if (len > 0 && (isspace((unsigned char)string[0]) || isspace((unsigned char)string[len - 1]))) { LXW_PUSH_ATTRIBUTES_STR("xml:space", "preserve"); } ### POC The following testcase demonstrates the bug: **testcase.cpp** ```cpp #include <cstdio> extern "C" { #include "/fuzz/install/include/xlsxwriter/styles.h" } int main(){ lxw_styles* s = lxw_styles_new(); if (!s) return 0; FILE* fp = fopen("/tmp/out.xml", "wb+"); if (!fp) return 0; s->file = fp; // satisfy FILE* precondition const char* str = ""; // empty string triggers bug // Triggers OOB read at styles.c:98: string[strlen(string)-1] with len==0 lxw_styles_write_string_fragment(s, str); fclose(fp); return 0; } ``` **stdout** ```text ``` **stderr** ```text ================================================================= ==1==ERROR: AddressSanitizer: global-buffer-overflow on address 0x559409043bdf at pc 0x559409025bf3 bp 0x7fff45e17010 sp 0x7fff45e17008 READ of size 1 at 0x559409043bdf thread T0 #0 0x559409025bf2 in lxw_styles_write_string_fragment (/fuzz/test+0x10bbf2) (BuildId: e622a699f6a8197fcee2f648da01711d43627a92) #1 0x5594090254b7 in main /fuzz/testcase.cpp:13:5 #2 0x7fade6150d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16 #3 0x7fade6150e3f in __libc_start_main csu/../csu/libc-start.c:392:3 #4 0x559408f4a304 in _start (/fuzz/test+0x30304) (BuildId: e622a699f6a8197fcee2f648da01711d43627a92) 0x559409043bdf is located 1 bytes before global variable '.str.2' defined in '/fuzz/testcase.cpp:11' (0x559409043be0) of size 1 '.str.2' is ascii string '' 0x559409043bdf is located 27 bytes after global variable '.str.1' defined in '/fuzz/testcase.cpp:8' (0x559409043bc0) of size 4 '.str.1' is ascii string 'wb+' SUMMARY: AddressSanitizer: global-buffer-overflow (/fuzz/test+0x10bbf2) (BuildId: e622a699f6a8197fcee2f648da01711d43627a92) in lxw_styles_write_string_fragment Shadow bytes around the buggy address: 0x559409043900: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x559409043980: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x559409043a00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x559409043a80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x559409043b00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 =>0x559409043b80: 00 00 00 00 00 05 f9 f9 04 f9 f9[f9]01 f9 f9 f9 0x559409043c00: 00 00 00 00 00 00 00 00 00 00 00 00 00 03 f9 f9 0x559409043c80: f9 f9 f9 f9 00 00 07 f9 f9 f9 f9 f9 00 02 f9 f9 0x559409043d00: 00 01 f9 f9 02 f9 f9 f9 00 03 f9 f9 04 f9 f9 f9 0x559409043d80: 05 f9 f9 f9 02 f9 f9 f9 02 f9 f9 f9 07 f9 f9 f9 0x559409043e00: 00 f9 f9 f9 07 f9 f9 f9 00 04 f9 f9 00 02 f9 f9 Shadow byte legend (one shadow byte represents 8 application bytes): Addressable: 00 Partially addressable: 01 02 03 04 05 06 07 Heap left redzone: fa Freed heap region: fd Stack left redzone: f1 Stack mid redzone: f2 Stack right redzone: f3 Stack after return: f5 Stack use after scope: f8 Global redzone: f9 Global init order: f6 Poisoned by user: f7 Container overflow: fc Array cookie: ac Intra object redzone: bb ASan internal: fe Left alloca redzone: ca Right alloca redzone: cb ==1==ABORTING ``` <details><summary>Steps to Reproduce</summary> <p> The crash was triaged with the following Dockerfile: **Dockerfile** ```dockerfile # Ubuntu 22.04 with some packages pre-installed FROM hgarrereyn/stitch_repro_base@sha256:3ae94cdb7bf2660f4941dc523fe48cd2555049f6fb7d17577f5efd32a40fdd2c RUN git clone https://github.com/jmcnamara/libxlsxwriter.git /fuzz/src && \ cd /fuzz/src && \ git checkout f0647157ba70d3b9372e5eef05ed489c73107b62 && \ git submodule update --init --remote --recursive ENV LD_LIBRARY_PATH=/fuzz/install/lib ENV ASAN_OPTIONS=hard_rss_limit_mb=1024:detect_leaks=0 RUN echo '#!/bin/bash\nexec clang-17 -fsanitize=address -O0 "$@"' > /usr/local/bin/clang_wrapper && \ chmod +x /usr/local/bin/clang_wrapper && \ echo '#!/bin/bash\nexec clang++-17 -fsanitize=address -O0 "$@"' > /usr/local/bin/clang_wrapper++ && \ chmod +x /usr/local/bin/clang_wrapper++ RUN apt-get update && apt-get install -y --no-install-recommends \ cmake \ ninja-build \ make \ pkg-config \ zlib1g-dev \ && rm -rf /var/lib/apt/lists/* ENV CC=clang_wrapper \ CXX=clang_wrapper++ WORKDIR /fuzz/src RUN cmake -S . -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DBUILD_SHARED_LIBS=OFF \ -DBUILD_TESTS=OFF \ -DBUILD_EXAMPLES=OFF \ -DCMAKE_INSTALL_PREFIX=/fuzz/install \ && cmake --build build --config Release \ && cmake --install build --config Release ``` **Build Command** ```bash clang++-17 -fsanitize=address -g -O0 -o /fuzz/test /fuzz/testcase.cpp -I/fuzz/install/include -L/fuzz/install/lib -lxlsxwriter -lz && /fuzz/test ``` **Reproduce** 1. Copy `Dockerfile` and `testcase.cpp` into a local folder. 2. Build the repro image: ```bash docker build . -t repro --platform=linux/amd64 ``` 3. Compile and run the testcase in the image: ```bash docker run \ -it --rm \ --platform linux/amd64 \ --mount type=bind,source="$(pwd)/testcase.cpp",target=/fuzz/testcase.cpp \ repro \ bash -c "clang++-17 -fsanitize=address -g -O0 -o /fuzz/test /fuzz/testcase.cpp -I/fuzz/install/include -L/fuzz/install/lib -lxlsxwriter -lz && /fuzz/test" ``` </p> </details> --- ### Additional Info This testcase was discovered by `STITCH`, an autonomous fuzzing system. All reports are reviewed manually (by a human) before submission.
Author
Owner

@jmcnamara commented on GitHub (Dec 18, 2025):

A couple of things:

  • lxw_styles_write_string_fragment isn't an API that a user would call. It is an internal function and only "public" because of C's limited function privacy rules.
  • In the function where lxw_styles_write_string_fragment is called there is a check for empty strings before the function is called.

Closing.

<!-- gh-comment-id:3669271134 --> @jmcnamara commented on GitHub (Dec 18, 2025): A couple of things: - `lxw_styles_write_string_fragment` isn't an API that a user would call. It is an internal function and only "public" because of C's limited function privacy rules. - In the function where `lxw_styles_write_string_fragment` is called there is a check for empty strings before the function is called. Closing.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: github-starred/libxlsxwriter#386
No description provided.