DEBUG_VERBOSE interleavedWrites print reads uninitialized Result::endRemaining for point writes (uninitialised/invalid read, ASan heap-use-after-free)
#86
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
structResult{Result()=default;Result(Node*insertionPoint,TrivialSpanremaining):insertionPoint(insertionPoint),remaining(remaining),endInsertionPoint(nullptr){}// <-- endRemaining NOT initialized
Result(Node*insertionPoint,TrivialSpanremaining,Node*endInsertionPoint,TrivialSpanendRemaining):insertionPoint(insertionPoint),remaining(remaining),endInsertionPoint(endInsertionPoint),endRemaining(endRemaining){}Node*insertionPoint;TrivialSpanremaining;Node*endInsertionPoint;// Range write only
TrivialSpanendRemaining;// 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:
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
==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:
#include"ConflictSet.h"#include<cstdio>#include<cstdlib>#include<cstring>#include<vector>usingnamespaceweaselab;staticConflictSet::WriteRange*makeRanges(std::vector<char*>&bufs,constchar*a,constchar*b,constchar*c,constchar*d){staticConflictSet::WriteRangew[2];constchar*keys[4]={a,b,c,d};bufs.clear();for(inti=0;i<4;++i){size_tn=strlen(keys[i]);char*p=(char*)malloc(n);memcpy(p,keys[i],n);bufs.push_back(p);}w[0].begin.p=(constuint8_t*)bufs[0];w[0].begin.len=(int)strlen(a);w[0].end.p=(constuint8_t*)bufs[1];w[0].end.len=(int)strlen(b);w[1].begin.p=(constuint8_t*)bufs[2];w[1].begin.len=(int)strlen(c);w[1].end.p=(constuint8_t*)bufs[3];w[1].end.len=(int)strlen(d);returnw;}intmain(){ConflictSetcs{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(autob:bufs)free(b);}{// Step 2: two point writes. Same stack frame -> same stackResults array,
// but Result's 2-arg constructor leaves endRemaining alone.
chark1[]="aaa";chark2[]="bbb";ConflictSet::WriteRangew[2];w[0].begin.p=(constuint8_t*)k1;w[0].begin.len=3;w[0].end.len=0;w[1].begin.p=(constuint8_t*)k2;w[1].begin.len=3;w[1].end.len=0;cs.addWrites(w,2,2);}printf("done\n");return0;}
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:
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 andrew2026-10-26 21:34:56 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
File:
ConflictSet.cpp:4984(the#if DEBUG_VERBOSE && !defined(NDEBUG)block at the top of Phase 2 ofweaselab::ConflictSet::Impl::interleavedWrites), together withConflictSet.cpp:3905-3907(interleaved_insert::Result's 2-argument constructor). Confirmed onmain@ba781b8.What the code does
interleaved_insert::Resulthas two constructors:Point writes are recorded with the 2-argument form (
*result = {n, remaining};, e.g.ConflictSet.cpp:4403), so for point writesendRemainingis left indeterminate.TrivialSpan's default constructor deliberately leavesbegin/lenuninitialized, soendRemainingis a garbage pointer/length pair.The debug block in Phase 2 prints it unconditionally, even when
endInsertionPoint == nullptr:For a point write
e == nullptr(that is whygetSearchPathPrintable(e)prints<end>), yetprintable(context.results[i].endRemaining)still dereferences the indeterminate span withfor (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
endRemainingisConflictSet.cpp:5010, guarded byif (context.results[i].endInsertionPoint == nullptr) { ... } else { ... }. So this is confined to the debug print, but that print is the whole point ofDEBUG_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_VERBOSEto 1 (the documented local-debugging switch inInternal.h:25; e.g. #82 was fixed in this very block) and runs aDebug/assertion-enabled build gets:Invalid read, or a hard crash (ASanheap-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
Observed (reproducible,
exit=9, 26 errors):With
DEBUG_VERBOSE 0the same command reportsERROR SUMMARY: 0 errors from 0 contextsand exits 0, so the errors come from the debug block, not from the insertion machinery it is tracing.With
-fsanitize=addressandDEBUG_VERBOSE 1, running the same driver over a batch of corpus files turns the garbage span into a hard failure:Reproduction B — minimal standalone program
Built with
DEBUG_VERBOSE 1+ assertions, this program reports, deterministically and on every run,26 errors from 4 contextsincludingConditional jump or move depends on uninitialised value(s)andInvalid read of size 1atConflictSet.cpp:4984; withDEBUG_VERBOSE 0it is valgrind-clean. Its stderr also shows the bug directly:Suggested fix
Either (or both):
nextRangeWrite), e.g.endRemaining{},nextRangeWrite(nullptr); orif (e != nullptr)check two lines above:Relation to existing issues
This is not #82 (already fixed): #82 was the
e = b->forwardTo;typo in the same block, fixed in9a2c64d. That fix is present and correct here; this defect is a different root cause (an uninitializedResultmember) in the same print and survives the #82 fix. No open issue covers it (#85, #38, #37, #33, #28, #19 are unrelated).