Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
51 changes: 51 additions & 0 deletions .github/workflows/fuzz-campaign.yml
Original file line number Diff line number Diff line change
@@ -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
88 changes: 88 additions & 0 deletions comparison-bin/tests/differential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `</`+backtick never becomes a comment. The port must not flag it either.
let input: &[u8] = &[60, 0, 47, 50, 0, 255, 62, 60, 47, 96];
assert_eq!(
libinjectionrs::detect_xss(input).is_injection(),
c_xss(input),
"NUL-as-whitespace in HTML5 diverges from the C library"
);
assert!(!c_xss(input), "C treats this as safe, and so must the port");
}
12 changes: 8 additions & 4 deletions libinjectionrs/src/sqli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down
59 changes: 23 additions & 36 deletions libinjectionrs/src/sqli/tokenizer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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];
Expand All @@ -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];
Expand Down
10 changes: 6 additions & 4 deletions libinjectionrs/src/xss/html5.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions libinjectionrs/src/xss/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,4 @@ fn test_fuzz_differential_070fdf5c() {
assert_eq!(detector.detect(input), XssResult::Xss);
}


Loading