DEBUG_VERBOSE interleavedWrites print reads uninitialized Result::endRemaining for point writes (uninitialised/invalid read, ASan heap-use-after-free) #86

Closed
opened 2026-10-18 10:12:18 +00:00 by weaselbot · 0 comments
Member

File: ConflictSet.cpp:4984 (the #if DEBUG_VERBOSE && !defined(NDEBUG) block at the top of Phase 2 of weaselab::ConflictSet::Impl::interleavedWrites), together with ConflictSet.cpp:3905-3907 (interleaved_insert::Result's 2-argument constructor). Confirmed on main @ ba781b8.

What the code does

interleaved_insert::Result has two constructors:

// ConflictSet.cpp:3904
struct Result {
  Result() = default;
  Result(Node *insertionPoint, TrivialSpan remaining)
      : insertionPoint(insertionPoint), remaining(remaining),
        endInsertionPoint(nullptr) {}          // <-- endRemaining NOT initialized
  Result(Node *insertionPoint, TrivialSpan remaining, Node *endInsertionPoint,
         TrivialSpan endRemaining)
      : insertionPoint(insertionPoint), remaining(remaining),
        endInsertionPoint(endInsertionPoint), endRemaining(endRemaining) {}

  Node *insertionPoint;
  TrivialSpan remaining;

  Node *endInsertionPoint;  // Range write only
  TrivialSpan endRemaining; // Range write only
  ...
};

Point writes are recorded with the 2-argument form (*result = {n, remaining};, e.g. ConflictSet.cpp:4403), so for point writes endRemaining is left indeterminate. TrivialSpan's default constructor deliberately leaves begin/len uninitialized, so endRemaining is a garbage pointer/length pair.

The debug block in Phase 2 prints it unconditionally, even when endInsertionPoint == nullptr:

// ConflictSet.cpp:4966
#if DEBUG_VERBOSE && !defined(NDEBUG)
      {
        Node *b = context.results[i].insertionPoint;
        Node *e = context.results[i].endInsertionPoint;
        while (b->releaseDeferred) { b = b->forwardTo; }
        if (e != nullptr) {
          while (e->releaseDeferred) { e = e->forwardTo; }
        }
        fprintf(stderr, "search path: %s, begin: %s\n",
                getSearchPathPrintable(b).c_str(),
                printable(context.results[i].remaining).c_str());
        fprintf(stderr, "search path: %s, end: %s\n",
                getSearchPathPrintable(e).c_str(),
                printable(context.results[i].endRemaining).c_str());   // <-- 4984
      }
#endif

For a point write e == nullptr (that is why getSearchPathPrintable(e) prints <end>), yet printable(context.results[i].endRemaining) still dereferences the indeterminate span with for (uint8_t c : key) (Internal.h:582). The intent was clearly to print the end search path/spans only for range writes.

Production code is fine: the only other read of endRemaining is ConflictSet.cpp:5010, guarded by if (context.results[i].endInsertionPoint == nullptr) { ... } else { ... }. So this is confined to the debug print, but that print is the whole point of DEBUG_VERBOSE, which the file guards with && !defined(NDEBUG) (i.e. it is meant to be used in exactly the builds where this fires).

Impact

Any developer who flips DEBUG_VERBOSE to 1 (the documented local-debugging switch in Internal.h:25; e.g. #82 was fixed in this very block) and runs a Debug/assertion-enabled build gets:

  • UB (read of an indeterminate pointer and length),
  • garbage/"end:" output for every point write,
  • wild-pointer reads: valgrind Invalid read, or a hard crash (ASan heap-use-after-free / SEGV), depending on the stack garbage — i.e. the debug print that is supposed to help find memory bugs becomes the thing that trips the sanitizer, hiding real bugs.

DEBUG_VERBOSE 0 (the committed value) is unaffected, so release/CI behaviour is unchanged.

Reproduction A — shipped harness, deterministic under valgrind

sed -i 's/#define DEBUG_VERBOSE 0/#define DEBUG_VERBOSE 1/' Internal.h
clang++ -std=c++20 -O1 -g -UNDEBUG -I include -I . -isystem third_party/valgrind \
    ConflictSet.cpp FuzzTestDriver.cpp -DENABLE_FUZZ -o fuzz_driver_dbg
valgrind --error-exitcode=9 ./fuzz_driver_dbg corpus/003fdafe6e5f359e1043927b266e6ed6193562bc

Observed (reproducible, exit=9, 26 errors):

==3117257== Conditional jump or move depends on uninitialised value(s)
==3117257==    at 0x414BA8: printable[abi:cxx11](std::basic_string_view<...>) (Internal.h:582)
==3117257==    by 0x43771F: printable (Internal.h:595)
==3117257==    by 0x43771F: weaselab::ConflictSet::Impl::interleavedWrites(...) (???:4984)
==3117257==    by 0x40E873: weaselab::ConflictSet::Impl::addWrites(...) (ConflictSet.cpp:5125)
...
==3117257== Use of uninitialised value of size 8
==3117257==    at 0x414C04: printable[abi:cxx11](std::basic_string_view<...>) (Internal.h:0)
...
==3117257== Invalid read of size 1
==3117257==    at 0x414C04: printable[abi:cxx11](std::basic_string_view<...>) (Internal.h:0)
==3117257==    by 0x43771F: weaselab::ConflictSet::Impl::interleavedWrites(...) (???:4984)

With DEBUG_VERBOSE 0 the same command reports ERROR SUMMARY: 0 errors from 0 contexts and exits 0, so the errors come from the debug block, not from the insertion machinery it is tracing.

With -fsanitize=address and DEBUG_VERBOSE 1, running the same driver over a batch of corpus files turns the garbage span into a hard failure:

==3117352==ERROR: AddressSanitizer: heap-use-after-free on address 0xfe5f6e2e2b3d
READ of size 1 at 0xfe5f6e2e2b3d thread T0
    #0 ... printable(std::basic_string_view<char, ...>)                    Internal.h:582
    #1 ... printable(TrivialSpan)                                          Internal.h:595
    #2 ... weaselab::ConflictSet::Impl::interleavedWrites(...)             ConflictSet.cpp:4984
    #3 ... weaselab::ConflictSet::Impl::addWrites(...)
SUMMARY: AddressSanitizer: heap-use-after-free

Reproduction B — minimal standalone program

#include "ConflictSet.h"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <vector>
using namespace weaselab;

static ConflictSet::WriteRange *makeRanges(std::vector<char *> &bufs,
                                           const char *a, const char *b,
                                           const char *c, const char *d) {
  static ConflictSet::WriteRange w[2];
  const char *keys[4] = {a, b, c, d};
  bufs.clear();
  for (int i = 0; i < 4; ++i) {
    size_t n = strlen(keys[i]);
    char *p = (char *)malloc(n);
    memcpy(p, keys[i], n);
    bufs.push_back(p);
  }
  w[0].begin.p = (const uint8_t *)bufs[0]; w[0].begin.len = (int)strlen(a);
  w[0].end.p = (const uint8_t *)bufs[1];   w[0].end.len = (int)strlen(b);
  w[1].begin.p = (const uint8_t *)bufs[2]; w[1].begin.len = (int)strlen(c);
  w[1].end.p = (const uint8_t *)bufs[3];   w[1].end.len = (int)strlen(d);
  return w;
}

int main() {
  ConflictSet cs{0};
  {
    std::vector<char *> bufs;
    // Step 1: two range writes. interleavedWrites() stores endRemaining spans
    // pointing into these heap buffers.
    auto *w = makeRanges(bufs, "aaaa", "aaab", "bbbb", "bbbc");
    cs.addWrites(w, 2, 1);
    for (auto b : bufs) free(b);
  }
  {
    // Step 2: two point writes. Same stack frame -> same stackResults array,
    // but Result's 2-arg constructor leaves endRemaining alone.
    char k1[] = "aaa";
    char k2[] = "bbb";
    ConflictSet::WriteRange w[2];
    w[0].begin.p = (const uint8_t *)k1; w[0].begin.len = 3; w[0].end.len = 0;
    w[1].begin.p = (const uint8_t *)k2; w[1].begin.len = 3; w[1].end.len = 0;
    cs.addWrites(w, 2, 2);
  }
  printf("done\n");
  return 0;
}

Built with DEBUG_VERBOSE 1 + assertions, this program reports, deterministically and on every run, 26 errors from 4 contexts including Conditional jump or move depends on uninitialised value(s) and Invalid read of size 1 at ConflictSet.cpp:4984; with DEBUG_VERBOSE 0 it is valgrind-clean. Its stderr also shows the bug directly:

search path: x61x61x61, begin: 
search path: <end>, end:            <- point write: "end:" printed from uninitialized memory

Suggested fix

Either (or both):

  • initialize the range-only members in the 2-argument constructor (and nextRangeWrite), e.g. endRemaining{}, nextRangeWrite(nullptr); or
  • only print the end span for range writes, mirroring the if (e != nullptr) check two lines above:
if (e != nullptr) {
  fprintf(stderr, "search path: %s, end: %s\n", getSearchPathPrintable(e).c_str(),
          printable(context.results[i].endRemaining).c_str());
} else {
  fprintf(stderr, "search path: <end>, end: <point write>\n");
}

Relation to existing issues

This is not #82 (already fixed): #82 was the e = b->forwardTo; typo in the same block, fixed in 9a2c64d. That fix is present and correct here; this defect is a different root cause (an uninitialized Result member) in the same print and survives the #82 fix. No open issue covers it (#85, #38, #37, #33, #28, #19 are unrelated).

**File:** `ConflictSet.cpp:4984` (the `#if DEBUG_VERBOSE && !defined(NDEBUG)` block at the top of Phase 2 of `weaselab::ConflictSet::Impl::interleavedWrites`), together with `ConflictSet.cpp:3905-3907` (`interleaved_insert::Result`'s 2-argument constructor). Confirmed on `main` @ `ba781b8`. ## What the code does `interleaved_insert::Result` has two constructors: ```cpp // ConflictSet.cpp:3904 struct Result { Result() = default; Result(Node *insertionPoint, TrivialSpan remaining) : insertionPoint(insertionPoint), remaining(remaining), endInsertionPoint(nullptr) {} // <-- endRemaining NOT initialized Result(Node *insertionPoint, TrivialSpan remaining, Node *endInsertionPoint, TrivialSpan endRemaining) : insertionPoint(insertionPoint), remaining(remaining), endInsertionPoint(endInsertionPoint), endRemaining(endRemaining) {} Node *insertionPoint; TrivialSpan remaining; Node *endInsertionPoint; // Range write only TrivialSpan endRemaining; // Range write only ... }; ``` Point writes are recorded with the 2-argument form (`*result = {n, remaining};`, e.g. `ConflictSet.cpp:4403`), so for point writes `endRemaining` is left indeterminate. `TrivialSpan`'s default constructor deliberately leaves `begin`/`len` uninitialized, so `endRemaining` is a garbage pointer/length pair. The debug block in Phase 2 prints it unconditionally, even when `endInsertionPoint == nullptr`: ```cpp // ConflictSet.cpp:4966 #if DEBUG_VERBOSE && !defined(NDEBUG) { Node *b = context.results[i].insertionPoint; Node *e = context.results[i].endInsertionPoint; while (b->releaseDeferred) { b = b->forwardTo; } if (e != nullptr) { while (e->releaseDeferred) { e = e->forwardTo; } } fprintf(stderr, "search path: %s, begin: %s\n", getSearchPathPrintable(b).c_str(), printable(context.results[i].remaining).c_str()); fprintf(stderr, "search path: %s, end: %s\n", getSearchPathPrintable(e).c_str(), printable(context.results[i].endRemaining).c_str()); // <-- 4984 } #endif ``` For a point write `e == nullptr` (that is why `getSearchPathPrintable(e)` prints `<end>`), yet `printable(context.results[i].endRemaining)` still dereferences the indeterminate span with `for (uint8_t c : key)` (`Internal.h:582`). The intent was clearly to print the end search path/spans only for range writes. Production code is fine: the only other read of `endRemaining` is `ConflictSet.cpp:5010`, guarded by `if (context.results[i].endInsertionPoint == nullptr) { ... } else { ... }`. So this is confined to the debug print, but that print is the whole point of `DEBUG_VERBOSE`, which the file guards with `&& !defined(NDEBUG)` (i.e. it is meant to be used in exactly the builds where this fires). ## Impact Any developer who flips `DEBUG_VERBOSE` to 1 (the documented local-debugging switch in `Internal.h:25`; e.g. #82 was fixed in this very block) and runs a `Debug`/assertion-enabled build gets: * UB (read of an indeterminate pointer and length), * garbage/"end:" output for every point write, * wild-pointer reads: valgrind `Invalid read`, or a hard crash (ASan `heap-use-after-free` / `SEGV`), depending on the stack garbage — i.e. the debug print that is supposed to help find memory bugs becomes the thing that trips the sanitizer, hiding real bugs. `DEBUG_VERBOSE 0` (the committed value) is unaffected, so release/CI behaviour is unchanged. ## Reproduction A — shipped harness, deterministic under valgrind ```sh sed -i 's/#define DEBUG_VERBOSE 0/#define DEBUG_VERBOSE 1/' Internal.h clang++ -std=c++20 -O1 -g -UNDEBUG -I include -I . -isystem third_party/valgrind \ ConflictSet.cpp FuzzTestDriver.cpp -DENABLE_FUZZ -o fuzz_driver_dbg valgrind --error-exitcode=9 ./fuzz_driver_dbg corpus/003fdafe6e5f359e1043927b266e6ed6193562bc ``` Observed (reproducible, `exit=9`, 26 errors): ``` ==3117257== Conditional jump or move depends on uninitialised value(s) ==3117257== at 0x414BA8: printable[abi:cxx11](std::basic_string_view<...>) (Internal.h:582) ==3117257== by 0x43771F: printable (Internal.h:595) ==3117257== by 0x43771F: weaselab::ConflictSet::Impl::interleavedWrites(...) (???:4984) ==3117257== by 0x40E873: weaselab::ConflictSet::Impl::addWrites(...) (ConflictSet.cpp:5125) ... ==3117257== Use of uninitialised value of size 8 ==3117257== at 0x414C04: printable[abi:cxx11](std::basic_string_view<...>) (Internal.h:0) ... ==3117257== Invalid read of size 1 ==3117257== at 0x414C04: printable[abi:cxx11](std::basic_string_view<...>) (Internal.h:0) ==3117257== by 0x43771F: weaselab::ConflictSet::Impl::interleavedWrites(...) (???:4984) ``` With `DEBUG_VERBOSE 0` the same command reports `ERROR SUMMARY: 0 errors from 0 contexts` and exits 0, so the errors come from the debug block, not from the insertion machinery it is tracing. With `-fsanitize=address` and `DEBUG_VERBOSE 1`, running the same driver over a batch of corpus files turns the garbage span into a hard failure: ``` ==3117352==ERROR: AddressSanitizer: heap-use-after-free on address 0xfe5f6e2e2b3d READ of size 1 at 0xfe5f6e2e2b3d thread T0 #0 ... printable(std::basic_string_view<char, ...>) Internal.h:582 #1 ... printable(TrivialSpan) Internal.h:595 #2 ... weaselab::ConflictSet::Impl::interleavedWrites(...) ConflictSet.cpp:4984 #3 ... weaselab::ConflictSet::Impl::addWrites(...) SUMMARY: AddressSanitizer: heap-use-after-free ``` ## Reproduction B — minimal standalone program ```cpp #include "ConflictSet.h" #include <cstdio> #include <cstdlib> #include <cstring> #include <vector> using namespace weaselab; static ConflictSet::WriteRange *makeRanges(std::vector<char *> &bufs, const char *a, const char *b, const char *c, const char *d) { static ConflictSet::WriteRange w[2]; const char *keys[4] = {a, b, c, d}; bufs.clear(); for (int i = 0; i < 4; ++i) { size_t n = strlen(keys[i]); char *p = (char *)malloc(n); memcpy(p, keys[i], n); bufs.push_back(p); } w[0].begin.p = (const uint8_t *)bufs[0]; w[0].begin.len = (int)strlen(a); w[0].end.p = (const uint8_t *)bufs[1]; w[0].end.len = (int)strlen(b); w[1].begin.p = (const uint8_t *)bufs[2]; w[1].begin.len = (int)strlen(c); w[1].end.p = (const uint8_t *)bufs[3]; w[1].end.len = (int)strlen(d); return w; } int main() { ConflictSet cs{0}; { std::vector<char *> bufs; // Step 1: two range writes. interleavedWrites() stores endRemaining spans // pointing into these heap buffers. auto *w = makeRanges(bufs, "aaaa", "aaab", "bbbb", "bbbc"); cs.addWrites(w, 2, 1); for (auto b : bufs) free(b); } { // Step 2: two point writes. Same stack frame -> same stackResults array, // but Result's 2-arg constructor leaves endRemaining alone. char k1[] = "aaa"; char k2[] = "bbb"; ConflictSet::WriteRange w[2]; w[0].begin.p = (const uint8_t *)k1; w[0].begin.len = 3; w[0].end.len = 0; w[1].begin.p = (const uint8_t *)k2; w[1].begin.len = 3; w[1].end.len = 0; cs.addWrites(w, 2, 2); } printf("done\n"); return 0; } ``` Built with `DEBUG_VERBOSE 1` + assertions, this program reports, deterministically and on every run, `26 errors from 4 contexts` including `Conditional jump or move depends on uninitialised value(s)` and `Invalid read of size 1` at `ConflictSet.cpp:4984`; with `DEBUG_VERBOSE 0` it is valgrind-clean. Its stderr also shows the bug directly: ``` search path: x61x61x61, begin: search path: <end>, end: <- point write: "end:" printed from uninitialized memory ``` ## Suggested fix Either (or both): * initialize the range-only members in the 2-argument constructor (and `nextRangeWrite`), e.g. `endRemaining{}`, `nextRangeWrite(nullptr)`; or * only print the end span for range writes, mirroring the `if (e != nullptr)` check two lines above: ```cpp if (e != nullptr) { fprintf(stderr, "search path: %s, end: %s\n", getSearchPathPrintable(e).c_str(), printable(context.results[i].endRemaining).c_str()); } else { fprintf(stderr, "search path: <end>, end: <point write>\n"); } ``` ## Relation to existing issues This is not #82 (already fixed): #82 was the `e = b->forwardTo;` typo in the same block, fixed in `9a2c64d`. That fix is present and correct here; this defect is a different root cause (an uninitialized `Result` member) in the same print and survives the #82 fix. No open issue covers it (#85, #38, #37, #33, #28, #19 are unrelated).
weaselbot was assigned by andrew 2026-10-26 21:34:56 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: weaselab/conflict-set#86