Skip to content

fix(find): guard sz_rfind_with_suffix_ against unsigned loop-bound underflow (OOB read) - #320

Closed
b7r6 wants to merge 1 commit into
ashvardanian:main-devfrom
sensenet-ai:upstream-pr/rfind-suffix-underflow-guard
Closed

fix(find): guard sz_rfind_with_suffix_ against unsigned loop-bound underflow (OOB read)#320
b7r6 wants to merge 1 commit into
ashvardanian:main-devfrom
sensenet-ai:upstream-pr/rfind-suffix-underflow-guard

Conversation

@b7r6

@b7r6 b7r6 commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Summary

sz_rfind_with_suffix_ (the reverse long-needle helper in find.h) can perform an out-of-bounds read for needles in the 257–511 byte range. Found by a fuzzer under UBSan; reproduces on the unmodified library with two perfectly valid std::strings.

Root cause

For a long needle, reverse search finds a suffix, then verifies the prefix and continues. On a partial match it shrinks the haystack:

// find.h — sz_rfind_with_suffix_
sz_size_t prefix_length = n_length - suffix_length;
while (1) {
    sz_cptr_t found = find_suffix(h, h_length, n + prefix_length, suffix_length);
    if (!found) return SZ_NULL_CHAR;
    sz_size_t remaining = found - h;
    if (remaining < prefix_length) return SZ_NULL_CHAR;   // only checks prefix room
    if (sz_equal_serial(found - prefix_length, n, prefix_length)) return found - prefix_length;
    h_length = remaining - 1;                             // shrinks haystack
}

The next iteration calls find_suffix again, which needs suffix_length bytes — but the shrink is guarded only by remaining < prefix_length. When prefix_length < suffix_length (e.g. a 306 B needle: prefix 50, suffix 256), h_length can drop below suffix_length. Inside sz_rfind_horspool_upto_256bytes_serial_ the loop bound

for (sz_size_t j = 0; j <= h_length - n_length; ...)   // n_length here == suffix_length (256)

underflows (h_length - 256 wraps to ~2^64), so j ranges to ~2^64 and the body dereferences far out of bounds.

The forward twin sz_find_with_prefix_ guards correctly (if (remaining < n_length) return SZ_NULL_CHAR;); only the reverse path is missing the symmetric check. All SIMD backends (sz_rfind_haswell/skylake/neon) fall through to this serial helper for the long-needle tail, so the bug bites on every dispatch path for needles 257–511 B.

Fix

One symmetric guard at the top of the loop, mirroring the forward helper — if the (possibly shrunk) haystack can no longer contain the full needle, there is no match:

while (1) {
    if (h_length < n_length) return SZ_NULL_CHAR;
    sz_cptr_t found = find_suffix(h, h_length, n + prefix_length, suffix_length);
    ...

Since n_length = prefix_length + suffix_length >= suffix_length, this guarantees every find_suffix call has enough bytes.

Reproduction

#include <string>
#include <stringzilla/stringzilla.hpp>
namespace sz = ashvardanian::stringzilla;
int main() {
  std::string hay(306, '\xff');
  hay.replace(11, 6, "_``'_`");          // distinct middle => no full match
  std::string needle(306, '\xff');
  sz::string_view h{hay.data(), hay.size()}, n{needle.data(), needle.size()};
  auto pos = h.rfind(n);                  // before: OOB read / segfault; after: npos
  return pos == sz::string_view::npos ? 0 : 1;
}

Built with c/stringzilla.c -DSZ_DYNAMIC_DISPATCH=1:

  • Before fix: segfault (OOB read), via both sz_rfind_serial directly and the dispatched sz_rfind.
  • After fix: returns npos, exit 0, matching std::string::rfind.

Verified on x86_64 (clang and gcc) at v4.6.2.

…derflow

The reverse long-needle helper shrinks h_length on a partial-suffix match
guarded only by prefix room (remaining < prefix_length), then re-invokes a
finder that needs suffix_length bytes. A short tail leaves h_length <
suffix_length, underflowing the unsigned loop bound (i <= h_length - n_length
-> ~2^64) into an out-of-bounds read -- for needles 257-511 B, on every
dispatch path (haswell/skylake/neon all fall through to the serial helper for
the long tail). The forward twin sz_find_with_prefix_ guards correctly with
remaining < n_length; this adds the symmetric guard to the reverse path.
ashvardanian added a commit that referenced this pull request Jul 30, 2026
On a partial match, reverse search cut the haystack back guarded only by the room
the prefix needs, while the next pass searches for the suffix. For a needle of 257
to 511 bytes, where the suffix is a fixed 256, the cut could leave less than that
and the search underflowed its unsigned loop bound into an out-of-bounds read. The
driver that breaks the precondition now upholds it.

Both Horspool helpers relied on that precondition without asserting it, unlike
their fixed-width siblings, which is why this failed silently rather than tripping
in a debug build.

Closes #320

Co-Authored-By: b7r6 <124453582+b7r6@users.noreply.github.com>
Co-Authored-By: b7r6 <b7r6.net@gmail.com>
@ashvardanian

Copy link
Copy Markdown
Owner

Hi @b7r6! Thanks for the PR! I had to rebase it onto the post v5 reorganized state. Tried to adjust your PR to go through the GitHub GUI, but there is a permissions issue with the sensenet-ai organization, so I ended up manually patching on the main-dev branch locally, marking you as a co-author. Thanks again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants