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
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: 2
updates:
# Opens a PR when libinjection moves, so the differential job runs the port
# against the new C code.
- package-ecosystem: gitsubmodule
directory: /
schedule:
interval: weekly
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
table keeps the behaviour.
- A line-coverage gate in CI (`cargo-llvm-cov`), enforcing a floor on the
library.
- Dependabot watches the `libinjection-c` submodule, so upstream changes open
a pull request that the differential job runs against.

### Changed
- The C reference is libinjection 4.0.1. The `libinjection-c` submodule had
been pinned at 85e252a (February 2025) since the port began. The port follows
the detection changes since then:
- A number like `1.e` or `10.E` with no exponent digits produces no token,
as MySQL ignores it, so `1' or 1.e(1)` folds and is flagged as in C.
- `LIKE (` is read as a function only when it does not follow an operand
(number, string, bareword, variable or `)`).
- The HTML5 tokenizer reads bytes unsigned when skipping whitespace, so a
0xFF byte is no longer taken as end of input and attributes after it are
checked.
- The XSS event-handler list follows upstream's 432 entries (145 added, 11
removed).
- `html_decode_char_at` and the event-handler comparison mirror 4.0.1's
length guards. Verdicts are unchanged.

4.0.0 fixed the `htmlencode_startswith` over-read described below; the
harness keeps the zero padding. Over the corpus, now 163,275 inputs, SQLi and
XSS divergences from the C library remain at 0.
- The differential fingerprint ceiling is lowered from 1,631 to 0: SQLi
fingerprints now match the C library exactly across the corpus.
- The FFI harness zero-pads the C input. libinjection reads a few bytes past
Expand Down
81 changes: 67 additions & 14 deletions comparison-bin/tests/differential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,15 +410,26 @@ fn nul_in_number_literal_matches_the_c_library() {
/// 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];
// A NUL ends the unquoted value `y`, so `onclick` after it is a new
// attribute, which C flags.
let input: &[u8] = b"<a x=y\0onclick=alert(1)>";
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");
assert!(c_xss(input), "C flags the onclick after the NUL, and so must the port");

// Fuzz-found: the NUL ends the attribute name `2`, the 0xFF after it is a
// second attribute name, and the trailing `</`+backtick is a bogus comment
// containing a backtick, which C flags.
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 and 0xFF in HTML5 diverge from the C library"
);
assert!(c_xss(input), "C flags the backtick comment, and so must the port");
}

/// Guards the variable token value: C stores the name without the leading `@`
Expand All @@ -442,16 +453,11 @@ fn at_variable_named_like_a_function_matches_the_c_library() {
assert!(c_is, "C flags this injection, and so must the port");
}

/// Guards an input on which libinjection's C code reads past the end of the
/// buffer: an ASan-confirmed over-read in `htmlencode_startswith`, reached from
/// `libinjection_is_xss`, on a URL-attribute value ending in an incomplete HTML
/// entity. Past the buffer the bytes are indeterminate, so the C verdict is not
/// reproducible across builds (it was `true` on the CI Linux build and `false`
/// here) until the FFI harness zero-pads the input. With the padded harness the
/// C answer is deterministic and the memory-safe port matches it. The differ-
/// ential fuzzer found this because it compared against an unpadded C build.
/// Guards URL-attribute values ending in an incomplete hex entity (`&#X`):
/// `html_decode_char_at` must not decode past the end of the value, and the
/// verdict must match the C library.
#[test]
fn xss_over_read_input_matches_the_c_library_with_a_padded_buffer() {
fn xss_trailing_incomplete_hex_entity_matches_the_c_library() {
let input: &[u8] = &[
0x22, 0x60, 0x3d, 0x27, 0x27, 0x58, 0x00, 0x54, 0x6f, 0x3d, 0x26, 0x23, 0x58, 0x00,
0x74, 0x6f, 0x5b, 0x26, 0x23, 0x58, 0x00, 0x74, 0x6f, 0x3d, 0x26, 0x23, 0x58, 0x00,
Expand All @@ -460,8 +466,22 @@ fn xss_over_read_input_matches_the_c_library_with_a_padded_buffer() {
assert_eq!(
libinjectionrs::detect_xss(input).is_injection(),
c_xss(input),
"XSS over-read input diverges from the C library under the padded harness"
"XSS input ending in an incomplete hex entity diverges from the C library"
);
}

/// Guards `h5_skip_white` returning the byte unsigned: a 0xFF before an
/// attribute is an ordinary byte, so the `onfocus` handler after it is seen.
#[test]
fn xss_0xff_byte_before_an_attribute_matches_the_c_library() {
let input: &[u8] = b"<input \xff onfocus=alert(1)>";
let c_is = c_xss(input);
assert_eq!(
libinjectionrs::detect_xss(input).is_injection(),
c_is,
"0xFF before an attribute diverges from the C library"
);
assert!(c_is, "C flags the onfocus handler after 0xFF, and so must the port");
}

/// Guards the numeric-entity comparison in `htmlencode_startswith`. C compares
Expand All @@ -487,3 +507,36 @@ fn numeric_entity_compares_by_its_low_byte_like_the_c_library() {
assert!(c_xss(b"<x to=\"d&#833ta:x\">"), "C flags this XSS, and so must the port");
assert!(!c_xss(b"<x to=\"d&#353ta:x\">"), "C upcases before truncating, so this is safe");
}

/// Fuzz-found XSS inputs containing 0xFF bytes. The verdict depends on
/// `h5_skip_white` treating 0xFF as an ordinary byte, so it must match C.
#[test]
fn xss_fuzz_inputs_with_0xff_bytes_match_the_c_library() {
let inputs: [&[u8]; 2] = [
// crash-472cde1c76cb772c42c53bf83e5bfe071f009983
&[
47, 93, 34, 47, 93, 34, 96, 214, 45, 53, 32, 47, 62, 60, 116, 255, 102, 102, 102, 102,
39, 96, 10, 39, 10, 90, 127, 60, 112, 10, 120, 96, 170, 84, 40, 47, 60, 39, 61, 255,
62, 96, 47, 60, 33, 105, 105, 105, 105, 105, 105, 105, 105, 105, 105, 105, 105, 105,
105, 105, 105, 61, 39, 212, 61, 61, 39, 13, 116, 255, 255, 255, 255, 255, 255, 255,
255, 255, 102, 102, 102, 102, 102, 255, 52, 39, 167, 1, 61, 96, 96, 47, 13, 96, 39,
45, 53, 32, 47, 62, 60, 116, 255, 102, 102, 102, 102, 102, 91, 102, 96, 102, 102, 102,
39, 167, 1, 61, 96, 96, 47, 13,
],
// 8ce9746b
&[
60, 112, 60, 112, 10, 47, 96, 2, 34, 96, 47, 13, 60, 47, 13, 13, 13, 96, 47, 84, 111,
47, 255, 67, 32, 32, 62, 132, 1, 60, 112, 10, 47, 96, 2, 34, 96, 32, 32, 62, 1, 60,
112, 62, 137, 125, 13, 60, 47, 13, 60, 47, 13, 13, 13, 96, 96, 32, 62, 132, 1, 60,
112, 10, 47, 96, 2, 34, 96, 32, 34,
],
];
for input in inputs {
let c_is = c_xss(input);
assert_eq!(
libinjectionrs::detect_xss(input).is_injection(),
c_is,
"fuzz input with 0xFF bytes diverges from the C library: {input:?}"
);
}
}
2 changes: 1 addition & 1 deletion ffi-harness/Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
CC = gcc
CFLAGS = -Wall -Wextra -std=c99 -O3 -fno-omit-frame-pointer -g -fPIC -DLIBINJECTION_VERSION=\"3.10.0\"
CFLAGS = -Wall -Wextra -std=c99 -O3 -fno-omit-frame-pointer -g -fPIC
INCLUDES = -I../libinjection-c/src
SRCDIR = ../libinjection-c/src
OBJDIR = obj
Expand Down
1 change: 0 additions & 1 deletion ffi-harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,3 @@ The Makefile uses:
- `-O3` - Optimization level 3
- `-g` - Debug information
- `-fno-omit-frame-pointer` - Keep frame pointers for debugging
- `-DLIBINJECTION_VERSION="3.10.0"` - Version definition
9 changes: 2 additions & 7 deletions ffi-harness/harness.c
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,8 @@
#include <stdlib.h>
#include <string.h>

// libinjection reads a few bytes past the end of the input on some adversarial
// inputs (an ASan-confirmed over-read in htmlencode_startswith, reached from
// libinjection_is_xss). Past the buffer the bytes are indeterminate, so the
// verdict is not reproducible across builds. Copy the input into a zeroed,
// over-allocated buffer so any such read lands on defined zero bytes and the
// C result is deterministic. The differential then compares against a stable
// C answer rather than stack garbage.
// Copy the input into a zeroed, over-allocated buffer so that any read past
// input_len lands on defined zero bytes and the C verdict is deterministic.
#define HARNESS_PAD 16

static char* padded_copy(const char* input, size_t input_len) {
Expand Down
2 changes: 1 addition & 1 deletion libinjection-c
Submodule libinjection-c updated 163 files
8 changes: 7 additions & 1 deletion libinjectionrs/src/sqli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,13 @@ impl<'a> SqliState<'a> {
let val = self.token_vec[left].value_as_str();
self.cstrcasecmp("LIKE", val) == 0 || self.cstrcasecmp("NOT LIKE", val) == 0
} {
if self.token_vec[left + 1].token_type == TokenType::LeftParenthesis {
if self.token_vec[left + 1].token_type == TokenType::LeftParenthesis &&
!(left > 0 &&
(self.token_vec[left - 1].token_type == TokenType::Number ||
self.token_vec[left - 1].token_type == TokenType::String ||
self.token_vec[left - 1].token_type == TokenType::Bareword ||
self.token_vec[left - 1].token_type == TokenType::Variable ||
self.token_vec[left - 1].token_type == TokenType::RightParenthesis)) {
// SELECT LIKE(... - it's a function
self.token_vec[left].token_type = TokenType::Function;
}
Expand Down
8 changes: 3 additions & 5 deletions libinjectionrs/src/sqli/tokenizer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1180,11 +1180,9 @@ impl<'a> SqliTokenizer<'a> {
}
}

// Check for invalid exponential format
if have_e && !have_exp {
let token = &self.input[start_pos..end_pos];
self.current.assign(TYPE_BAREWORD, start_pos, end_pos - start_pos, token);
} else {
// "1234.e", "10.10E", ".E": MySQL ignores these, so no token is
// assigned and the tokenizer moves past them as it does whitespace.
if !(have_e && !have_exp) {
let token = &self.input[start_pos..end_pos];
self.current.assign(TYPE_NUMBER, start_pos, end_pos - start_pos, token);
}
Expand Down
Loading
Loading