Skip to content

Double Free in msgpack_zbuffer_init() via zero-size allocation #1198

Open

Description

@AmirWilayah

Summary

Calling msgpack_zbuffer_init(zbuf, level, 0) — passing init_size == 0 — followed by any write via msgpack_zbuffer_write() leads to a double-free (confirmed) or a hang/infinite loop (observed via fuzzing, allocator-dependent), because msgpack_zbuffer_init() stores init_size verbatim with no substitution of a sane default, unlike its sibling msgpack_vrefbuffer_init(), which does substitute defaults for zero arguments.

  • File: include/msgpack/zbuffer.h
  • Functions: msgpack_zbuffer_init (lines 66–76), msgpack_zbuffer_expand (lines 102–119), msgpack_zbuffer_destroy (lines 78–82)
  • Affected version: c_master branch, HEAD 82a6cc03
  • Bug class: Double-free (CWE-415) / potential infinite loop (CWE-835), rooted in missing input validation (CWE-20) and an inconsistent API convention across sibling buffer types

Root cause

static inline bool msgpack_zbuffer_init(msgpack_zbuffer* zbuf,
        int level, size_t init_size)
{
    memset(zbuf, 0, sizeof(msgpack_zbuffer));
    zbuf->init_size = init_size;   // <-- stored verbatim; 0 is NOT replaced with a default
    if(deflateInit(&zbuf->stream, level) != Z_OK) {
        free(zbuf->data);
        return false;
    }
    return true;
}

Compare with the sibling msgpack_vrefbuffer_init() (src/vrefbuffer.c), which explicitly substitutes MSGPACK_VREFBUFFER_REF_SIZE / MSGPACK_VREFBUFFER_CHUNK_SIZE whenever its size arguments are 0 — establishing an API convention (across this codebase) that 0 conventionally means "use the default." msgpack_zbuffer_init() breaks that convention silently: it provides no validation or substitution, so a caller reasonably assuming 0 means "use the default" (as it does for the sibling type) instead gets exact, literal zero stored into init_size.

static inline bool msgpack_zbuffer_expand(msgpack_zbuffer* zbuf)
{
    size_t used = (size_t)((char *)(zbuf->stream.next_out) - zbuf->data);
    size_t csize = used + zbuf->stream.avail_out;

    size_t nsize = (csize == 0) ? zbuf->init_size : csize * 2;  // nsize = 0 when init_size=0 and csize=0

    char* tmp = (char*)realloc(zbuf->data, nsize);   // realloc(ptr, 0) — implementation-defined
    if(tmp == NULL) {
        return false;   // zbuf->data is NOT reset to NULL here
    }

    zbuf->data = tmp;
    zbuf->stream.next_out  = (Bytef*)(tmp + used);
    zbuf->stream.avail_out = (uInt)(nsize - used);

    return true;
}

static inline void msgpack_zbuffer_destroy(msgpack_zbuffer* zbuf)
{
    deflateEnd(&zbuf->stream);
    free(zbuf->data);   // double-free site when zbuf->data is stale
}

Before any output has been produced, zbuf->data and zbuf->stream.next_out are both NULL (from the initial memset), so used == 0. With csize == 0, nsize becomes zbuf->init_size — literally 0 when the caller passed 0. This produces realloc(zbuf->data, 0), whose behavior is implementation-defined:

  • glibc's realloc(ptr, 0) frees ptr and returns NULL — msgpack_zbuffer_expand then returns false without resetting zbuf->data to NULL, leaving a dangling pointer to already-freed memory. The subsequent msgpack_zbuffer_destroy() (or a second msgpack_zbuffer_write() call) then operates on/frees that stale pointer → double-free.
  • On allocators where realloc(ptr, 0) instead returns a new, valid zero-size pointer each time without avail_out ever growing past MSGPACK_ZBUFFER_RESERVE_SIZE (512): msgpack_zbuffer_write()'s while(avail_in > 0) { if (avail_out < RESERVE) expand(); deflate(...); } loop can spin calling expand() repeatedly with the exit condition never satisfied → hang.

Confirmed no in-tree caller passes init_size == 0 (the only in-tree call, test/buffer_c.cpp:28, explicitly uses MSGPACK_ZBUFFER_INIT_SIZE), so this requires an out-of-tree caller to pass 0 — but the function provides no validation/rejection of 0, and the sibling vrefbuffer API's own convention makes 0 a very plausible value for a caller to pass expecting default behavior.

Trigger / reproduction

Minimal, clean, 3-call reproducer using only the public API:

#include <msgpack/zbuffer.h>
#include <stdio.h>

int main(void) {
    msgpack_zbuffer z;
    msgpack_zbuffer_init(&z, -1, 0);        /* Z_DEFAULT_COMPRESSION, init_size = 0 */
    msgpack_zbuffer_write(&z, "ZZZ", 3);
    msgpack_zbuffer_write(&z, "ABC", 3);
    msgpack_zbuffer_destroy(&z);            /* double-free here */
    return 0;
}
clang -fsanitize=address,undefined -g -O0 -I include -o poc poc.c -lz
./poc

Observed ASan output (exact run, this audit):

=================================================================
==131690==ERROR: AddressSanitizer: attempting double-free on 0x502000000010 in thread T0:
    #0 ... in free
    #1 ... in msgpack_zbuffer_destroy include/msgpack/zbuffer.h:81:5
    #2 ... in main poc.c:11:5

0x502000000010 is located 0 bytes inside of 1-byte region [0x502000000010,0x502000000011)
freed by thread T0 here:
    #0 ... in realloc
    #1 ... in msgpack_zbuffer_expand include/msgpack/zbuffer.h:109:24
    #2 ... in msgpack_zbuffer_write include/msgpack/zbuffer.h:133:17
    #3 ... in main poc.c:9:15

previously allocated by thread T0 here:
    #0 ... in realloc
    #1 ... in msgpack_zbuffer_expand include/msgpack/zbuffer.h:109:24
    #2 ... in msgpack_zbuffer_write include/msgpack/zbuffer.h:133:17
    #3 ... in main poc.c:7:15

SUMMARY: AddressSanitizer: double-free ... in free
==131690==ABORTING

Negative control (same two-write sequence, using the documented default MSGPACK_ZBUFFER_INIT_SIZE instead of 0) runs clean:

init ok, data=(nil) avail_out=0
write1 rc=0 data=0x525000000100 avail_out=8190
write2 rc=0 data=0x525000000100 avail_out=8190
destroy ok - no corruption

This isolates the precondition to exactly init_size == 0, not something inherent to msgpack_zbuffer_write.

The bug reproduces regardless of the level argument to deflateInit (any valid -1..9 / Z_DEFAULT_COMPRESSION value) — the trigger is entirely a function of init_size, independent of compression level.

Fuzzing evidence

Found independently via AFL++ fuzzing of a harness exercising the public msgpack_vrefbuffer/msgpack_zbuffer API directly: 13 double-free crashes and 2 hangs in a ~6-minute campaign, all tracing back to this one root cause via identical ASan stack traces (freed-by/previously-allocated-by both pointing at msgpack_zbuffer_expand's realloc at zbuffer.h:109, final double-free in msgpack_zbuffer_destroy's free(zbuf->data) at zbuffer.h:81).

Impact

Double-free (heap corruption, exploitable for further memory-corruption impact depending on allocator state at the time of the second free) or an unbounded hang/DoS, triggered purely by passing 0 as init_size to a public, documented API function — no malicious/attacker-controlled input is required beyond the caller's own choice of constructor argument, though a library that forwards a size value from configuration or user input into this parameter would allow external triggering. This is arguably easier to hit than the other two findings I report — no size-imbalance crafting needed, just an ordinary constructor call with 0 followed by two ordinary writes.

Suggested fix

Mirror msgpack_vrefbuffer_init()'s own convention — substitute a documented default when 0 is passed:

 static inline bool msgpack_zbuffer_init(msgpack_zbuffer* zbuf,
         int level, size_t init_size)
 {
     memset(zbuf, 0, sizeof(msgpack_zbuffer));
-    zbuf->init_size = init_size;
+    zbuf->init_size = (init_size == 0) ? MSGPACK_ZBUFFER_INIT_SIZE : init_size;
     if(deflateInit(&zbuf->stream, level) != Z_OK) {
         free(zbuf->data);
         return false;
     }
     return true;
 }

As defense in depth, msgpack_zbuffer_expand() should also reset zbuf->data = NULL; before returning false on realloc failure, so that any future double-free path (from an unrelated cause) degrades to a NULL-pointer free (a no-op) rather than a double-free.

Full ASan trace

init ok, data=(nil) avail_out=0
write1 rc=-1 data=0x502000000010 avail_out=0
write2 rc=-1 data=0x502000000010 avail_out=0
=================================================================
==194594==ERROR: AddressSanitizer: attempting double-free on 0x502000000010 in thread T0:
    #0 0x56035aa8cf3a in free (/home/mir/projects/msgpack-c/poc-finding3/poc+0xcaf3a) (BuildId: 8eb2a83163fa7b0534bfd8e69de66be93de4de34)
    #1 0x56035aacc346 in msgpack_zbuffer_destroy /home/mir/projects/msgpack-c/poc-finding3/../include/msgpack/zbuffer.h:81:5
    #2 0x56035aacbab3 in main /home/mir/projects/msgpack-c/poc-finding3/zbuffer_double_free_poc.c:11:5
    #3 0x7d59db82a1c9  (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9) (BuildId: a4a7992a8e66555c8141ab2a08a8465ff6e0ea65)
    #4 0x7d59db82a28a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a) (BuildId: a4a7992a8e66555c8141ab2a08a8465ff6e0ea65)
    #5 0x56035a9f2384 in _start (/home/mir/projects/msgpack-c/poc-finding3/poc+0x30384) (BuildId: 8eb2a83163fa7b0534bfd8e69de66be93de4de34)

0x502000000010 is located 0 bytes inside of 1-byte region [0x502000000010,0x502000000011)
freed by thread T0 here:
    #0 0x56035aa8d5f0 in realloc (/home/mir/projects/msgpack-c/poc-finding3/poc+0xcb5f0) (BuildId: 8eb2a83163fa7b0534bfd8e69de66be93de4de34)
    #1 0x56035aacc740 in msgpack_zbuffer_expand /home/mir/projects/msgpack-c/poc-finding3/../include/msgpack/zbuffer.h:109:24
    #2 0x56035aacc1bb in msgpack_zbuffer_write /home/mir/projects/msgpack-c/poc-finding3/../include/msgpack/zbuffer.h:133:17
    #3 0x56035aacba11 in main /home/mir/projects/msgpack-c/poc-finding3/zbuffer_double_free_poc.c:9:15
    #4 0x7d59db82a1c9  (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9) (BuildId: a4a7992a8e66555c8141ab2a08a8465ff6e0ea65)
    #5 0x7d59db82a28a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a) (BuildId: a4a7992a8e66555c8141ab2a08a8465ff6e0ea65)
    #6 0x56035a9f2384 in _start (/home/mir/projects/msgpack-c/poc-finding3/poc+0x30384) (BuildId: 8eb2a83163fa7b0534bfd8e69de66be93de4de34)

previously allocated by thread T0 here:
    #0 0x56035aa8d5f0 in realloc (/home/mir/projects/msgpack-c/poc-finding3/poc+0xcb5f0) (BuildId: 8eb2a83163fa7b0534bfd8e69de66be93de4de34)
    #1 0x56035aacc740 in msgpack_zbuffer_expand /home/mir/projects/msgpack-c/poc-finding3/../include/msgpack/zbuffer.h:109:24
    #2 0x56035aacc1bb in msgpack_zbuffer_write /home/mir/projects/msgpack-c/poc-finding3/../include/msgpack/zbuffer.h:133:17
    #3 0x56035aacb963 in main /home/mir/projects/msgpack-c/poc-finding3/zbuffer_double_free_poc.c:7:15
    #4 0x7d59db82a1c9  (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9) (BuildId: a4a7992a8e66555c8141ab2a08a8465ff6e0ea65)
    #5 0x7d59db82a28a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a) (BuildId: a4a7992a8e66555c8141ab2a08a8465ff6e0ea65)
    #6 0x56035a9f2384 in _start (/home/mir/projects/msgpack-c/poc-finding3/poc+0x30384) (BuildId: 8eb2a83163fa7b0534bfd8e69de66be93de4de34)

SUMMARY: AddressSanitizer: double-free (/home/mir/projects/msgpack-c/poc-finding3/poc+0xcaf3a) (BuildId: 8eb2a83163fa7b0534bfd8e69de66be93de4de34) in free
==194594==ABORTING
Image

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