From 760fb0dfb5d11d3af3e5c96ae8f4e1679d1f968d Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Fri, 11 Sep 2026 12:18:29 +0200 Subject: [PATCH 1/6] Stop a variable name at a NUL byte, matching C 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. --- libinjectionrs/src/sqli/tokenizer.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/libinjectionrs/src/sqli/tokenizer.rs b/libinjectionrs/src/sqli/tokenizer.rs index 8ffce88..bada525 100644 --- a/libinjectionrs/src/sqli/tokenizer.rs +++ b/libinjectionrs/src/sqli/tokenizer.rs @@ -1018,9 +1018,16 @@ impl<'a> SqliTokenizer<'a> { // Regular variable name - must exactly match C implementation // C: " <>:\\?=@!#~+-*/&|^%(),';\t\n\v\f\r'`\"" let var_chars = b" <>:\\?=@!#~+-*/&|^%(),;'\t\n\x0B\x0C\r'`\""; + // C ends the run 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 listed. Without this a NUL is folded into the variable and + // the tokenization diverges. let mut end_pos = new_pos; - - while end_pos < slen && !var_chars.contains(&self.input[end_pos]) { + + while end_pos < slen + && self.input[end_pos] != 0 + && !var_chars.contains(&self.input[end_pos]) + { end_pos += 1; } From 704bfbb64c11748e19e4e84a2cf70dd4c55d09bc Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Fri, 11 Sep 2026 14:05:47 +0200 Subject: [PATCH 2/6] Find sp_password over raw bytes, matching C's my_memmem 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. --- comparison-bin/tests/differential.rs | 24 ++++++++++++++++++++++++ libinjectionrs/src/sqli/mod.rs | 6 ++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/comparison-bin/tests/differential.rs b/comparison-bin/tests/differential.rs index ed505ec..564470c 100644 --- a/comparison-bin/tests/differential.rs +++ b/comparison-bin/tests/differential.rs @@ -332,3 +332,27 @@ fn nul_in_dollar_token_matches_the_c_library() { assert_eq!(c_sqli(b"'$\0T").1, "s1n"); assert!(c_sqli(b"T'$\0T#").0, "C flags this injection, and so must the port"); } + +/// Guards the `sp_password` force-true when the input is not valid UTF-8. C's +/// `my_memmem` searches the raw bytes, so it finds `sp_password` even amid high +/// bytes; the port searches bytes too rather than lossily decoding to a string. +/// The text corpus reaches this only in ASCII, so the fuzzer found this case. +#[test] +fn sp_password_in_non_utf8_input_matches_the_c_library() { + // A comment-terminated fingerprint with `sp_password` embedded among high + // bytes. C flags it via the raw-byte memmem; the port must agree. + let input: &[u8] = &[ + 0x2d, 0xfe, 0x23, 0x28, 0x41, 0x29, 0x2d, 0x28, 0x73, 0x70, 0x5f, 0x70, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x8a, 0x8a, 0x8a, 0x8a, 0x5b, 0x8a, 0x8a, 0x3d, 0x8a, + 0x8a, 0x8a, 0x8a, 0x8a, 0x8a, 0x2d, 0xff, 0xff, 0xff, 0x09, 0xff, + ]; + let (c_is, c_fp) = c_sqli(input); + let rust = libinjectionrs::detect_sqli(input); + let rust_fp = rust.fingerprint.as_ref().map(|f| f.to_string()).unwrap_or_default(); + assert_eq!( + (rust.is_injection(), rust_fp.as_str()), + (c_is, c_fp.as_str()), + "sp_password in non-UTF-8 input diverges from the C library" + ); + assert!(c_is, "C flags this injection, and so must the port"); +} diff --git a/libinjectionrs/src/sqli/mod.rs b/libinjectionrs/src/sqli/mod.rs index 1ce32c8..6b066ec 100644 --- a/libinjectionrs/src/sqli/mod.rs +++ b/libinjectionrs/src/sqli/mod.rs @@ -1133,8 +1133,10 @@ impl<'a> SqliState<'a> { } fn contains_sp_password(&self) -> bool { - let input_str = core::str::from_utf8(self.input).unwrap_or(""); - input_str.to_ascii_lowercase().contains("sp_password") + // C's my_memmem is a case-sensitive search over the raw input bytes, + // so it finds the needle regardless of surrounding non-UTF-8 bytes. + const NEEDLE: &[u8] = b"sp_password"; + self.input.windows(NEEDLE.len()).any(|w| w == NEEDLE) } fn handle_two_token_whitelist(&self) -> bool { From f50dc85c331579a1e93633450bf98f2b87d8bec1 Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Fri, 11 Sep 2026 14:10:15 +0200 Subject: [PATCH 3/6] Search a collate bareword for '_' over raw bytes, matching C's strchr 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 "" 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. --- comparison-bin/tests/differential.rs | 20 ++++++++++++++++++++ libinjectionrs/src/sqli/mod.rs | 6 ++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/comparison-bin/tests/differential.rs b/comparison-bin/tests/differential.rs index 564470c..63b631e 100644 --- a/comparison-bin/tests/differential.rs +++ b/comparison-bin/tests/differential.rs @@ -356,3 +356,23 @@ fn sp_password_in_non_utf8_input_matches_the_c_library() { ); assert!(c_is, "C flags this injection, and so must the port"); } + +/// Guards the collate + bareword rule for a non-UTF-8 bareword. C's `strchr` +/// searches the raw token value for `_`, retyping the bareword as an SQL type; +/// the port searches the value bytes too rather than lossily decoding it. The +/// text corpus reaches this only in ASCII. +#[test] +fn collate_underscore_in_non_utf8_bareword_matches_the_c_library() { + // `collate` then a bareword with `_` next to a high byte: C's strchr finds + // the `_` and marks it TYPE_SQLTYPE (fingerprint `t`); the port must agree. + let input: &[u8] = b"collate \xff_z"; + let (c_is, c_fp) = c_sqli(input); + let rust = libinjectionrs::detect_sqli(input); + let rust_fp = rust.fingerprint.as_ref().map(|f| f.to_string()).unwrap_or_default(); + assert_eq!( + (rust.is_injection(), rust_fp.as_str()), + (c_is, c_fp.as_str()), + "collate + non-UTF-8 bareword diverges from the C library" + ); + assert_eq!(c_fp, "At", "C types the bareword as an SQL type (fingerprint char `t`)"); +} diff --git a/libinjectionrs/src/sqli/mod.rs b/libinjectionrs/src/sqli/mod.rs index 6b066ec..b1bdeff 100644 --- a/libinjectionrs/src/sqli/mod.rs +++ b/libinjectionrs/src/sqli/mod.rs @@ -656,8 +656,10 @@ impl<'a> SqliState<'a> { } else if self.token_vec[left].token_type == TokenType::Collate && self.token_vec[left + 1].token_type == TokenType::Bareword { // there are too many collation types.. so if the bareword has a "_" then it's TYPE_SQLTYPE - let val = self.token_vec[left + 1].value_as_str(); - if val.contains('_') { + // C's strchr searches the raw value bytes, so a `_` among + // non-UTF-8 bytes still counts. + let tok = &self.token_vec[left + 1]; + if tok.val[..tok.len.min(32)].contains(&b'_') { self.token_vec[left + 1].token_type = TokenType::SqlType; left = 0; } From 031d24dea68206193078dfe7fb46a7cd045e20aa Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Fri, 11 Sep 2026 14:13:10 +0200 Subject: [PATCH 4/6] Scan number literals with strlenspn so an embedded NUL is consumed 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. --- comparison-bin/tests/differential.rs | 27 ++++++++++++++++ libinjectionrs/src/sqli/tokenizer.rs | 48 ++++++++-------------------- 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/comparison-bin/tests/differential.rs b/comparison-bin/tests/differential.rs index 63b631e..09ce77f 100644 --- a/comparison-bin/tests/differential.rs +++ b/comparison-bin/tests/differential.rs @@ -376,3 +376,30 @@ fn collate_underscore_in_non_utf8_bareword_matches_the_c_library() { ); assert_eq!(c_fp, "At", "C types the bareword as an SQL type (fingerprint char `t`)"); } + +/// Guards the number scans that use `strlenspn`: the `0x`/`0b` prefixes and the +/// `B'..'`/`X'..'` string forms. C's `strlenspn` counts an embedded NUL as a +/// digit, so a NUL inside the literal is consumed rather than ending it. The +/// text corpus has no NUL bytes, so only the fuzzer reaches this. +#[test] +fn nul_in_number_literal_matches_the_c_library() { + let inputs: [&[u8]; 5] = [ + b"0x1\x002", + b"0b1\x001", + b"B'0\x001'", + b"X'a\x00b'", + b"1 union select 0x4\x005 from x", + ]; + for input in inputs { + let (c_is, c_fp) = c_sqli(input); + let rust = libinjectionrs::detect_sqli(input); + let rust_fp = rust.fingerprint.as_ref().map(|f| f.to_string()).unwrap_or_default(); + assert_eq!( + (rust.is_injection(), rust_fp.as_str()), + (c_is, c_fp.as_str()), + "{input:?} diverges from the C library" + ); + } + // The NUL inside the hex literal is consumed, so this stays a UNION injection. + assert!(c_sqli(b"1 union select 0x4\x005 from x").0, "C flags this injection, and so must the port"); +} diff --git a/libinjectionrs/src/sqli/tokenizer.rs b/libinjectionrs/src/sqli/tokenizer.rs index bada525..f138fd6 100644 --- a/libinjectionrs/src/sqli/tokenizer.rs +++ b/libinjectionrs/src/sqli/tokenizer.rs @@ -847,23 +847,18 @@ impl<'a> SqliTokenizer<'a> { return self.parse_word(); } - let content_start = pos + 2; - let mut content_end = content_start; - - // Only allow 0 and 1 - while content_end < slen && (self.input[content_end] == b'0' || self.input[content_end] == b'1') { - content_end += 1; - } - + // strlenspn counts an embedded NUL as a binary digit, as C does. + let content_end = strlenspn(self.input, pos + 2, b"01"); + if content_end >= slen || self.input[content_end] != b'\'' { return self.parse_word(); } - + let full_token = &self.input[pos..content_end + 1]; self.current.assign(TYPE_NUMBER, pos, content_end + 1 - pos, full_token); content_end + 1 } - + fn parse_xstring(&mut self) -> usize { let pos = self.pos; let slen = self.input.len(); @@ -873,17 +868,9 @@ impl<'a> SqliTokenizer<'a> { return self.parse_word(); } - let content_start = pos + 2; - let mut content_end = content_start; - - // Only allow hex digits - while content_end < slen { - match self.input[content_end] { - b'0'..=b'9' | b'A'..=b'F' | b'a'..=b'f' => content_end += 1, - _ => break, - } - } - + // strlenspn counts an embedded NUL as a hex digit, as C does. + let content_end = strlenspn(self.input, pos + 2, b"0123456789ABCDEFabcdef"); + if content_end >= slen || self.input[content_end] != b'\'' { return self.parse_word(); } @@ -1105,14 +1092,9 @@ impl<'a> SqliTokenizer<'a> { if end_pos < slen && self.input[end_pos] == b'0' && end_pos + 1 < slen { match self.input[end_pos + 1] { b'X' | b'x' => { - end_pos += 2; - while end_pos < slen { - match self.input[end_pos] { - b'0'..=b'9' | b'A'..=b'F' | b'a'..=b'f' => end_pos += 1, - _ => break, - } - } - + // strlenspn counts an embedded NUL as a hex digit, as C does. + end_pos = strlenspn(self.input, end_pos + 2, b"0123456789ABCDEFabcdef"); + if end_pos == pos + 2 { // No hex digits after 0x let token = &self.input[pos..pos + 2]; @@ -1125,11 +1107,9 @@ impl<'a> SqliTokenizer<'a> { } } b'B' | b'b' => { - end_pos += 2; - while end_pos < slen && (self.input[end_pos] == b'0' || self.input[end_pos] == b'1') { - end_pos += 1; - } - + // strlenspn counts an embedded NUL as a binary digit, as C does. + end_pos = strlenspn(self.input, end_pos + 2, b"01"); + if end_pos == pos + 2 { // No binary digits after 0b let token = &self.input[pos..pos + 2]; From 0be5c7a1237bc9d0c71f16a43b9934f320806bd0 Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Fri, 11 Sep 2026 14:24:48 +0200 Subject: [PATCH 5/6] Treat a NUL as whitespace in the HTML5 tokenizer, matching C 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 ` Html5State<'a> { None } + // C's h5_is_white is `strchr(" \t\n\v\f\r", ch) != NULL`, and strchr + // matches the string's own NUL terminator, so NUL counts as whitespace. fn is_whitespace(ch: u8) -> bool { - matches!(ch, 0x20 | 0x09 | 0x0A | 0x0B | 0x0C | 0x0D) + matches!(ch, 0x00 | 0x20 | 0x09 | 0x0A | 0x0B | 0x0C | 0x0D) } - - // Match C h5_is_white function exactly: " \t\n\v\f\r" + + // C's h5_is_white: " \t\n\v\f\r", with NUL counted via strchr's terminator. fn h5_is_white(ch: u8) -> bool { - matches!(ch, 0x20 | 0x09 | 0x0A | 0x0B | 0x0C | 0x0D) + matches!(ch, 0x00 | 0x20 | 0x09 | 0x0A | 0x0B | 0x0C | 0x0D) } // Match C alphabetic check exactly: (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') diff --git a/libinjectionrs/src/xss/tests.rs b/libinjectionrs/src/xss/tests.rs index a136693..5772fd9 100644 --- a/libinjectionrs/src/xss/tests.rs +++ b/libinjectionrs/src/xss/tests.rs @@ -226,3 +226,4 @@ fn test_fuzz_differential_070fdf5c() { assert_eq!(detector.detect(input), XssResult::Xss); } + From 84c5e9a963562e6603f8da1a5bae1c9b32a1168b Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Fri, 11 Sep 2026 14:32:07 +0200 Subject: [PATCH 6/6] Add a scheduled long fuzz campaign; scope the PR job as a smoke test 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. --- .github/workflows/ci.yml | 14 ++++---- .github/workflows/fuzz-campaign.yml | 51 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/fuzz-campaign.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5b496a..7977620 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,13 +90,11 @@ jobs: fuzz: name: Differential fuzzing (reporting) runs-on: ubuntu-latest - # Reporting, not gating, and deliberately so. With the NUL blind spot - # removed the fuzzer finds a new divergence class within a couple of - # minutes, repeatedly, so this job fails today and will keep failing until - # those are fixed. The alternative is widening the exception list until it - # excuses everything, which would leave a gate that cannot fail. The - # corpus differential above is the gate; this job is here to keep - # surfacing new classes. + # A short per-pull-request smoke test: two minutes per detector, enough to + # catch an obvious new divergence class quickly. It reports rather than + # gates, since a probabilistic short run is a weak signal to block a merge + # on; the corpus differential above is the deterministic gate. The long + # campaign lives in fuzz-campaign.yml. continue-on-error: true steps: - uses: actions/checkout@v5 @@ -111,7 +109,7 @@ jobs: - name: Install cargo-fuzz run: cargo install cargo-fuzz --locked # Short by design: enough to surface a class on every pull request, not - # a replacement for a long campaign. The corpus is committed, so runs + # a replacement for the long campaign. The corpus is committed, so runs # build on each other. - name: Fuzz SQLi differential run: cargo fuzz run fuzz_differential_sqli -- -max_total_time=120 diff --git a/.github/workflows/fuzz-campaign.yml b/.github/workflows/fuzz-campaign.yml new file mode 100644 index 0000000..0a7e1bb --- /dev/null +++ b/.github/workflows/fuzz-campaign.yml @@ -0,0 +1,51 @@ +name: Fuzz campaign + +# The long differential fuzzing campaign that drives toward an hours-clean +# surface, kept separate from the per-pull-request smoke test in ci.yml. It +# runs nightly and on demand, and fails visibly if either detector diverges +# from the C library so the crashing input can be triaged. The default budget +# is tunable through the workflow_dispatch input; the corpus is committed, so +# each run builds on the last. + +on: + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + inputs: + max_total_time: + description: Seconds to fuzz per detector + required: false + default: '1800' + +permissions: + contents: read + +env: + MAX_TOTAL_TIME: ${{ github.event.inputs.max_total_time || '1800' }} + +jobs: + campaign: + name: Differential fuzzing (campaign) + runs-on: ubuntu-latest + timeout-minutes: 360 + steps: + - uses: actions/checkout@v5 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@v2 + - name: Build the C harness + run: make -C ffi-harness + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked + - name: Fuzz SQLi differential + run: cargo fuzz run fuzz_differential_sqli -- -max_total_time=$MAX_TOTAL_TIME + - name: Fuzz XSS differential + run: cargo fuzz run fuzz_differential_xss -- -max_total_time=$MAX_TOTAL_TIME + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-artifacts + path: fuzz/artifacts/ + if-no-files-found: ignore