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 diff --git a/comparison-bin/tests/differential.rs b/comparison-bin/tests/differential.rs index ed505ec..af1227e 100644 --- a/comparison-bin/tests/differential.rs +++ b/comparison-bin/tests/differential.rs @@ -332,3 +332,91 @@ 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"); +} + +/// 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`)"); +} + +/// 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"); +} + +/// Guards the HTML5 tokenizer's treatment of a NUL as whitespace. C's +/// `h5_is_white` is `strchr(" \t\n\v\f\r", ch)`, which matches the string's NUL +/// terminator, so a NUL ends an attribute name or unquoted value as whitespace +/// would. The text corpus has no NUL bytes, so only the fuzzer reaches this. +#[test] +fn nul_as_whitespace_in_html5_matches_the_c_library() { + // A NUL inside an attribute name: C ends the name there, so the trailing + // ` 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; } @@ -1133,8 +1135,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 { diff --git a/libinjectionrs/src/sqli/tokenizer.rs b/libinjectionrs/src/sqli/tokenizer.rs index 8ffce88..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(); } @@ -1018,9 +1005,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; } @@ -1098,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]; @@ -1118,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]; diff --git a/libinjectionrs/src/xss/html5.rs b/libinjectionrs/src/xss/html5.rs index a1a469e..c72d81a 100644 --- a/libinjectionrs/src/xss/html5.rs +++ b/libinjectionrs/src/xss/html5.rs @@ -238,13 +238,15 @@ impl<'a> 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); } +