Repository navigation
Commit a407990
authored
fix: out-of-bounds read in ArrowBytesMap on a short/long hash collision (#25218)
## Which issue does this PR close?
- No existing issue. Found while running the extended CI
`force_hash_collisions`
job against unrelated work; happy to file one first if you prefer.
## Rationale for this change
`ArrowBytesMap` keeps a value's bytes inline in an entry's
`offset_or_inline`
field when the value is at most `SHORT_VALUE_LEN` (8) bytes, and keeps
an offset
into the value buffer there otherwise. The lookup for a **long** value
checked
only that the hashes matched, then built `header.range()` from that
field and
read the buffer with `get_unchecked`:
```rust
let entry = self.map.find_mut(hash, |header| {
if header.hash != hash {
return false;
}
// SAFETY: buffer is only appended to, and we correctly inserted values and offsets
let existing_value = unsafe { self.buffer.get_unchecked(header.range()) };
value == existing_value
});
```
When that hash matches a **short** entry, `offset_or_inline` holds
inlined bytes
rather than an offset, so the range is nonsense and the read goes
outside the
buffer. The lookup for a short value does not have this problem, because
it
already compares `header.len` first, which is precisely what keeps it
sound.
This is reachable whenever a value of at most 8 bytes and a value longer
than 8
bytes collide on the full 64-bit hash inside one map. Two things make
that worth
fixing rather than dismissing:
- DataFusion hashes with `foldhash::fast::FixedState::default()`, a
fixed
compile-time seed, so a colliding pair is reproducible across runs and
machines and can be found offline once.
- The expected number of such pairs grows with the product of the short
and long
distinct counts, so the exposure rises with cardinality rather than
staying
constant.
The map backs grouped aggregation on a single string or binary column
(`GroupValuesBytes`) and `count(DISTINCT …)` over those types
(`ArrowBytesSet`), so this is on the path of ordinary `GROUP BY` and
`COUNT(DISTINCT)` queries.
What happens on a collision depends on the inlined bytes read as an
offset. If
they happen to land inside the buffer, the comparison sees unrelated
bytes,
reports inequality, and the query is correct by luck. Otherwise the read
is out
of bounds, which is undefined behaviour: with UB checks enabled it
aborts, and
in a release build it reads unmapped or unrelated memory.
The extended CI job builds the workspace with `force_hash_collisions`,
where
every value hashes to `0` so every short/long pair collides. Today
`dataframe::test_grouping_with_alias` aborts there:
```
unsafe precondition(s) violated: slice::get_unchecked requires that the range
is within the slice
thread caused non-unwinding panic. aborting.
```
The check is asymmetric for a reason worth stating: the short-value
lookup
already compares `header.len`, so a long entry can never be mistaken for
a short
one. Only the long-value lookup was missing it. That single comparison
guards two
different failure modes, an out-of-bounds read in one direction and a
false
equality that merges two distinct values in the other, which is why the
regression test exercises both orders.
## What changes are included in this PR?
- Compare `header.len` on the long-value path, exactly as the
short-value path
already does. That rules out short entries, so `offset_or_inline` is
only ever
treated as an offset when it is one, and it doubles as a cheap
early-out. It
cannot reject a real match, since two equal long values have equal
lengths.
- Rewrite the `SAFETY` comment to state the invariant that actually
holds.
- Give `datafusion-physical-expr-common` the `force_hash_collisions`
passthrough
feature that `datafusion-common`, `datafusion-core`,
`datafusion-physical-plan`
and the two aggregate crates already have, so the path can be reached
deterministically from a unit test.
## What is the testing strategy for this PR?
`short_and_long_values_with_colliding_hashes` in `binary_map.rs`, gated
on
`force_hash_collisions`, inserts a short and a long value in both orders
and
asserts each distinct value is offered to the payload function exactly
once. The
values are chosen so that removing either length check actually fails:
`"ab"` inlines to 0x6162, far past the end of the buffer, and a single
NUL byte
inlines to 0, which is the offset of a long value stored first.
Mutation-checked
both ways: removing the long path's check aborts on the precondition
violation,
removing the short path's check trips the assertion.
With the fix, the whole `core_integration` suite passes under
`--features force_hash_collisions` (1144 tests) where it previously
aborted.
Without the feature, `datafusion-physical-expr-common`,
`datafusion-functions-aggregate` and the `dataframe` integration tests
all pass,
and clippy is clean in both feature configurations.
## Are there any user-facing changes?1 parent 9a1ad7c commit a407990
2 files changed
Lines changed: 68 additions & 4 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
48 | 51 | | |
49 | 52 | | |
50 | 53 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
511 | 511 | | |
512 | 512 | | |
513 | 513 | | |
514 | | - | |
515 | | - | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
516 | 519 | | |
517 | 520 | | |
518 | | - | |
519 | | - | |
| 521 | + | |
| 522 | + | |
520 | 523 | | |
521 | 524 | | |
522 | 525 | | |
| |||
1103 | 1106 | | |
1104 | 1107 | | |
1105 | 1108 | | |
| 1109 | + | |
| 1110 | + | |
| 1111 | + | |
| 1112 | + | |
| 1113 | + | |
| 1114 | + | |
| 1115 | + | |
| 1116 | + | |
| 1117 | + | |
| 1118 | + | |
| 1119 | + | |
| 1120 | + | |
| 1121 | + | |
| 1122 | + | |
| 1123 | + | |
| 1124 | + | |
| 1125 | + | |
| 1126 | + | |
| 1127 | + | |
| 1128 | + | |
| 1129 | + | |
| 1130 | + | |
| 1131 | + | |
| 1132 | + | |
| 1133 | + | |
| 1134 | + | |
| 1135 | + | |
| 1136 | + | |
| 1137 | + | |
| 1138 | + | |
| 1139 | + | |
| 1140 | + | |
| 1141 | + | |
| 1142 | + | |
| 1143 | + | |
| 1144 | + | |
| 1145 | + | |
| 1146 | + | |
| 1147 | + | |
| 1148 | + | |
| 1149 | + | |
| 1150 | + | |
| 1151 | + | |
| 1152 | + | |
| 1153 | + | |
| 1154 | + | |
| 1155 | + | |
| 1156 | + | |
| 1157 | + | |
| 1158 | + | |
| 1159 | + | |
| 1160 | + | |
| 1161 | + | |
| 1162 | + | |
| 1163 | + | |
| 1164 | + | |
| 1165 | + | |
| 1166 | + | |
1106 | 1167 | | |
1107 | 1168 | | |
1108 | 1169 | | |
| |||
0 commit comments