Bound the RLE decode writes, and signal malformed streams to the caller - #9
Bound the RLE decode writes, and signal malformed streams to the caller#9ggorman wants to merge 1 commit into
Conversation
…formed
`Run_Length_Decode_Slow` loops while `num < num_expected_vals`, but the two
run-length branches write a whole run without re-checking that bound:
else if (ival == RLESC3) {
int rle = *((unsigned int*)p) >> 8; // 24-bit run length
for (int j = 0; j < rle; ++j) vals[num+j] = 0.0f;
num += rle; p += 3;
}
The only bound is `assert(num+rle <= num_expected_vals)` guarded by
`#ifdef DEBUG_DECODE`, and DEBUG_DECODE is commented out at
Run_Length_Encode_Slow.cpp:14. So in every shipped build a single escape
byte can write up to 16,777,215 floats (64 MB) past `vals`. RLESC1 has the
same shape with an 8-bit run.
Reproduced with a 256-float destination and a stream of eight literals
followed by one RLESC3 requesting 65,536, built from this translation unit
with clang++ -fsanitize=address and DEBUG_DECODE undefined:
heap-buffer-overflow, WRITE of size 262144, Run_Length_Encode_Slow.cpp:443
A control stream whose run lands exactly on the array end (248 after eight
literals) enters the same RLESC3 branch at the same `num` and completes
cleanly, so the difference is the run length and nothing else. A build with
-DDEBUG_DECODE confirms it: the project's own assertion fires on the
oversized run and not on the control.
This change does two things.
1. Clamp both run-length writes to the caller's remaining capacity.
2. Return -1 when a run was clamped, and check it at the call site. This
second half matters: clamping alone converts an out-of-bounds write into
a silently accepted malformed stream, because `num += rle` still runs and
CvxCompress::Decompress discarded the return value entirely. A truncated
or partially-written block would decode to an unreliable volume with
nothing to indicate it. Decompress now counts malformed blocks across the
parallel loop and reports once on stderr.
Well-formed input is bit-for-bit unaffected: the clamp is a no-op whenever
the run fits, and the return value is unchanged.
Two adjacent issues are NOT addressed here, and are worth separate
consideration:
- Run_Length_Decode_Slow receives no length for `compressed`, so its
look-ahead reads (the 8-byte SIMD load, and the 3-byte run field) can read
past the end of a truncated stream regardless of this write clamp.
CvxCompress::Decompress does receive `compressed_length` but does not pass
it down.
- `int rle = *((unsigned int*)p) >> 8` is an unaligned, type-punned load
whose value is endianness-dependent. Benign on the little-endian targets
in use, but not portable.
|
@mloubout — could you take a look when you get a chance? Flagging you since you've merged here before, and this touches the decoder path DevitoPRO's compressed snapshot save/restore goes through. I don't have permissions to add reviewers on this repo, hence the mention rather than a review request. The two-line clamp is the part that matters for the out-of-bounds write. The return-value change is the part I'd most like a second opinion on: it makes |
|
Follow-up 1 in the description — the missing input length — now has a reproducer rather than just an assertion, in case it helps decide whether it's worth a second PR. The decoder's only termination condition is a property of the output: for (; num < num_expected_vals; ++p)
int val0 = ((int*)p)[0]; // :397, 8-byte look-aheadso a stream shorter than the output demands runs
Identical on all three arms, which is the point — clamping bounds Geometry is hand-checkable: the fast path does One detail worth flagging if you do take this on: the look-ahead bound isn't 8 bytes. I've left it out of this PR because it changes a public signature and touches every call path, so it seemed like your call on the interface rather than something to bundle into a bounds fix. |
The defect
Run_Length_Decode_Slowloops whilenum < num_expected_vals, but the two run-length branches write a whole run without re-checking that bound:The only bound is
assert(num+rle <= num_expected_vals)guarded by#ifdef DEBUG_DECODE, andDEBUG_DECODEis commented out atRun_Length_Encode_Slow.cpp:14. So in every shipped build one escape byte can write up to 16,777,215 floats — 64 MB — pastvals.RLESC1has the same shape with an 8-bit run.Reproduction
256-float destination; stream of eight literals then one
RLESC3requesting 65,536. Built from this translation unit withclang++ -O1 -g -fsanitize=address,DEBUG_DECODEundefined:262144 is exactly 65536 × 4. A control stream whose run lands exactly on the array end (248 after the same eight literals) enters the same
RLESC3branch at the samenum=8and completes cleanly, so the run length is the only thing that differs. A-DDEBUG_DECODEbuild confirms it from the project's own instrumentation:The repository already treats this input as a violation — it just does not compile the check into shipped builds.
What this changes
1. Clamp both run-length writes to the caller's remaining capacity.
2. Return
-1when a run was clamped, and check it at the call site. This second half matters. Clamping alone converts an out-of-bounds write into a silently accepted malformed stream:num += rlestill runs, so the function returned 65,544 for a 256-element array, andCvxCompress::Decompressdiscarded the return value entirely (CvxCompress.cpp:561). A truncated or partially-written block would decode to an unreliable volume with nothing to indicate it.Decompressnow counts malformed blocks across the parallel loop (reduction(+:)) and reports once on stderr.Well-formed input is bit-for-bit unaffected: the clamp is a no-op whenever the run fits, and the return value is unchanged.
Verified against the reproducer with the patched files:
Context
Found while investigating an intermittent
double free or corruptionin an MPI seismic gradient using compressed disk snapshots. The proximate cause there was separately root-caused to concurrent writes to a shared snapshot path producing sparse files; this decoder behaviour is what turned those files into heap corruption instead of an error. Fixing it is defence in depth, and is independent of that root cause.Two adjacent issues NOT addressed here
Raised for separate consideration rather than bundled in:
compressed. Its look-ahead reads — the 8-byte SIMD load, and the 3-byte run field — can read past the end of a truncated stream regardless of this write clamp.CvxCompress::Decompressdoes receivecompressed_lengthbut does not pass it down.int rle = *((unsigned int*)p) >> 8is an unaligned, type-punned load whose value is endianness-dependent. Benign on the little-endian targets in use, but not portable.I'm happy to follow up on either if useful.