Skip to content

Char semantics: follow C on NUL and non-UTF-8 bytes (fixes five fuzz-found divergences) - #2

Merged
ndreno merged 6 commits into
mainfrom
fix/char-semantics-nul-highbyte
Sep 11, 2026
Merged

ndreno merged 6 commits into
mainfrom
fix/char-semantics-nul-highbyte

Conversation

@ndreno

@ndreno ndreno commented Sep 11, 2026

Copy link
Copy Markdown

The differential fuzzer finds divergences from the C library within
seconds, nearly all in one family: the port decodes raw input to a UTF-8
string, or stops a scan at a NUL, where C works on bytes and treats a NUL
as an ordinary set member (its strchr(set, ch) matches the set string's
own NUL terminator). This PR fixes five such classes, each following the
C control flow rather than a single input, and adds a differential test
for each (the text corpus reaches none of them: it has no NUL and no
non-UTF-8 bytes).

Fixes

  1. Variable name stops at a NUL (parse_var). C's strlencspn ends
    the name at the first NUL. Was a fuzz false positive.

  2. sp_password searched over raw bytes (is_not_whitelist). C's
    my_memmem is a case-sensitive byte search; the port decoded to a
    string first, which collapses to "" on any non-UTF-8 byte. Was a
    fuzz false negative.

  3. Collate bareword _ searched over raw bytes (fold). C's strchr
    searches the raw token value; the port used a lossy string.

  4. Number literals scanned with strlenspn (0x/0b prefixes and
    B'..'/X'..' forms). C counts an embedded NUL as a digit, so a NUL
    inside the literal is consumed rather than ending it.

  5. NUL is whitespace in the HTML5 tokenizer (h5_is_white). C's
    strchr(" \t\n\v\f\r", ch) matches the terminator, so a NUL ends an
    attribute name or unquoted value; the port ran on and could reach a
    later </+backtick as a comment, flagging XSS where C does not. Was a
    fuzz false positive.

The whole strchr-over-a-literal family is now audited across both
detectors and consistent: h5_is_white, char_is_white (NUL and 0xA0),
every strlenspn/strlencspn caller, and the collate _ search.

Gate

The full-corpus differential (162,963 inputs) stays at 0 divergences on
both verdicts and fingerprints. The reporting-only fuzz job is the source
of these; each class it finds is triaged and fixed here.

parse_var ended the variable-name run with a Rust `!var_chars.contains()` loop,
and var_chars does not list NUL. C ends it with strlencspn, whose
`strchr(reject, byte)` finds a NUL in the reject string's terminator, so a NUL
ends the name even though it is not in the set. Without this the port folded a
NUL into the variable and tokenized `@` runs differently.

This was a false positive: differential fuzzing flagged `\0"@\0"/@\0\xef` as an
injection here (fingerprint `sov`, blacklisted) while C sees it as clean. With
the fix the port agrees with C, and the corpus differential stays at zero.

parse_word already lists NUL in its own set, so it was unaffected; the number
scans (strlenspn) are a separate NUL case, tracked separately.
The sp_password force-true searched the input by decoding it to a UTF-8
str first (from_utf8(...).unwrap_or("")), which collapses the whole
string to "" on any non-UTF-8 byte, so the needle was never found. It
also lowercased, making the match case-insensitive.

C's my_memmem is a case-sensitive search over the raw input bytes: it
finds sp_password regardless of surrounding high bytes. Match it with a
byte-window search.

Found by the differential fuzzer: a comment-terminated fingerprint with
sp_password embedded among high bytes was a false negative (Rust: false,
C: true). The text corpus reaches this construct only in ASCII, so a
dedicated test in the differential suite pins the non-UTF-8 case against
C. Full corpus differential stays at 0.
The collate + bareword fold retypes the bareword as an SQL type when it
contains '_'. C uses strchr on the raw token value; the port searched
value_as_str(), which returns "<binary>" on any non-UTF-8 byte, so a '_'
sitting next to a high byte was lost and the bareword kept its type.

Search the token value bytes directly, as C does. Same class as the
sp_password fix. A dedicated differential test pins the non-UTF-8 case
(fingerprint `At`, the `t` being TYPE_SQLTYPE). Full corpus stays at 0.
The 0x/0b prefix scans and the B'..'/X'..' string forms hand-rolled
digit loops that stop at a NUL byte. C scans them with strlenspn, whose
strchr-based membership test treats a NUL as a digit, so a NUL inside
the literal is consumed as part of the number rather than ending it.

Route all four through the existing strlenspn helper, matching C. A
dedicated differential test pins the NUL-in-literal cases, including a
UNION injection whose hex literal contains a NUL. Full corpus stays at 0.
@ndreno ndreno changed the title Char semantics: stop a variable name at a NUL, matching C (fixes a fuzz false positive) Char semantics: follow C on NUL and non-UTF-8 bytes (fixes four fuzz-found divergences) Sep 11, 2026
C's h5_is_white is strchr(" \t\n\v\f\r", ch), and strchr matches the
string's own NUL terminator, so a NUL byte counts as whitespace. The
port's h5_is_white and is_whitespace omitted it, so a NUL inside an
attribute name or an unquoted attribute value did not end the token as C
does. The tokenizer then ran on and could reach a later `</`+backtick as
a comment, flagging XSS where C stays in a tag context and does not.

Add NUL to both predicates. state_tag_name keeps its own explicit NUL
branch, so it is unaffected. Found by the differential fuzzer; a dedicated
test pins the minimized input. Full corpus differential stays at 0.
@ndreno ndreno changed the title Char semantics: follow C on NUL and non-UTF-8 bytes (fixes four fuzz-found divergences) Char semantics: follow C on NUL and non-UTF-8 bytes (fixes five fuzz-found divergences) Sep 11, 2026
The per-pull-request fuzz job now passes its two-minute-per-detector
budget, so its comment no longer described what it does. Reword it as the
short smoke test it is.

Add fuzz-campaign.yml: a nightly and on-demand differential fuzzing job
with a tunable per-detector budget (default 30 minutes), which drives
toward an hours-clean surface and fails visibly on a divergence, uploading
the crashing input for triage. Local fuzzing is unreliable here, so the
campaign runs in CI.
@ndreno
ndreno merged commit e2557a1 into main Sep 11, 2026
4 checks passed
@ndreno
ndreno deleted the fix/char-semantics-nul-highbyte branch September 11, 2026 12:58
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.

1 participant