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
6 changes: 6 additions & 0 deletions crates/bashkit/fuzz/fuzz_targets/arithmetic_fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ fuzz_target!(|data: &[u8]| {
// `strip_real_shell_error_lines` recognizes, so filter at the input
// layer, as `glob_fuzz` and `cpython_fuzz` do, and keep the leak
// detector strict for genuine internals.
//
// The shape need not be in the input at all. The diagnostic renders
// `{expr}: {msg}`, so run 295's `wZTok:` gained the separator's colon
// and read `bash: line 1: wZTok:: syntax error in expression`,
// spelling `Tok::` across the join. `input_echo_would_trip` renders
// the slot before matching, so the gate sees those too.
if bashkit::testing::input_echo_would_trip(input) {
return;
}
Expand Down
49 changes: 48 additions & 1 deletion crates/bashkit/src/testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,12 +250,42 @@ fn ends_shell_error_template(line: &str) -> bool {
/// run 272 libFuzzer recovered a 32-byte `PersAutoDict` entry for it and
/// spliced the whole canary into an `arithmetic_fuzz` expression.
pub fn input_echo_would_trip(input: &str) -> bool {
// Matching the raw bytes alone is not enough either: a diagnostic wraps
// the slot in its own punctuation, so a banned shape can be spelled
// across the join without appearing in the input at all. See
// `DIAGNOSTIC_ECHO_PAD`.
let (lead, trail) = DIAGNOSTIC_ECHO_PAD;
// Mirror `diag_echo(expr.trim())`: the slot holds the trimmed text, so
// trailing whitespace must not hide a join the render will still make.
let rendered = format!("{lead}{}{trail}", input.trim());
UNIVERSAL_BANNED
.iter()
.chain(std::iter::once(&FUZZ_HOST_CANARY))
.any(|pat| carries_through_expansion(input, pat))
.any(|pat| {
carries_through_expansion(input, pat) || carries_through_expansion(&rendered, pat)
})
}

/// Punctuation a real-shell diagnostic places immediately around an echoed
/// user-input slot, used to render the slot before matching it.
///
/// `Interpreter::arith_diag` renders
/// `bash: line N: {diag_echo(expr.trim())}: {msg}`, so the expression slot
/// carries `: ` on its left from the line prefix and `: ` on its right as
/// the message separator. A banned shape can therefore form across either
/// join while the input itself is clean: nightly fuzz run 295 submitted
/// `wZTok:`, whose diagnostic reads `bash: line 1: wZTok:: syntax error in
/// expression (error token is ":")` and spells the parser-token shape
/// `Tok::` out of the input's trailing colon plus the separator's. The
/// tokenizer's `Tok` enum is never formatted, and `Tok::` appears nowhere
/// in the script, so this is an echo and not a TM-INF-022 leak.
///
/// Padding both sides at once is the real rendering rather than a widening:
/// both characters come from the same template around the same slot. The
/// "error token" slot is rendered inside double quotes and no banned shape
/// contains `"`, so it needs no padding of its own.
const DIAGNOSTIC_ECHO_PAD: (&str, &str) = (": ", ": ");

/// True when `text` carries `needle` into a diagnostic — literally, or
/// after the shell drops NUL bytes during word expansion, which turns
/// `</r\0ustc/` into `/rustc/` and `\0{ code:` into ` { code:`.
Expand Down Expand Up @@ -616,6 +646,23 @@ mod tests {
assert!(stripped.contains("Tok::"), "stripped: {stripped:?}");
}

#[test]
fn input_echo_would_trip_sees_the_diagnostic_separator() {
// Run 295: `wZTok:` carries only `Tok:`; the arithmetic diagnostic
// renders `{expr}: {msg}` and the separator's colon completes the
// banned `Tok::`. The gate has to render the slot, not match raw.
assert!(!"wZTok:".contains("Tok::"));
assert!(input_echo_would_trip("wZTok:"));
// The slot holds `expr.trim()`, so padding is measured after trimming.
assert!(input_echo_would_trip("wZTok: "));
// The leading `: ` of `bash: line N: ` completes a space-prefixed
// shape the same way (glob_fuzz's ` { code:` class).
assert!(input_echo_would_trip("{ code:x"));
// Still narrow: one character short of the shape keeps running.
assert!(!input_echo_would_trip("wZTo:"));
assert!(!input_echo_would_trip("1+2*3"));
}

#[test]
fn input_echo_would_trip_sees_through_nul_stripping() {
// Both glob_fuzz crashes hinged on this: the raw bytes carry a NUL
Expand Down
43 changes: 43 additions & 0 deletions crates/bashkit/tests/integration/arithmetic_fuzz_scaffold_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,46 @@ fn canary_in_stdout_not_explained_by_the_script_is_still_a_leak() {
};
bashkit::testing::assert_fuzz_invariants(&result, "echo hi", "canary_real_leak_stdout", &[]);
}

/// Run 295: the banned shape straddles the join between the echoed
/// expression and the diagnostic's own punctuation.
///
/// `evaluate_arithmetic` renders `{diag_echo(expr.trim())}: {msg}`, so an
/// expression ending in `Tok:` picks up the separator's colon and the line
/// reads `bash: line 1: wZTok:: syntax error in expression (error token is
/// ":")`. The input carries only `Tok:`, which is not banned -- `Tok::`
/// exists nowhere in the script and nowhere in bashkit's own text; it is
/// spelled by the input's trailing colon sitting next to the template's.
/// Checking the raw input therefore admitted it and the detector tripped on
/// a faithful echo, so the gate renders the slot before matching.
#[tokio::test]
async fn run_295_banned_shape_straddles_the_separator() {
let input = "wZTok:";
// The input on its own carries no banned shape.
assert!(!input.contains("Tok::"), "{input}");
// The rendered diagnostic does, so the gate must skip it.
assert!(bashkit::testing::input_echo_would_trip(input));

let mut bash = fuzz_bash();
let r = bash
.exec(&format!("echo $(({input}))"))
.await
.expect("exec");
let err = r.stderr.to_string();
// The shape is the echo plus the separator, not a formatted `Tok` enum.
assert!(
err.contains("wZTok:: syntax error in expression"),
"expected the verbatim echo and separator: {err}"
);
assert!(err.len() <= 1024, "stderr is {} bytes:\n{err}", err.len());
}

/// The widened gate is still narrow: an expression that ends one character
/// short of the shape cannot form it against the separator, so it keeps
/// running under the strict detector.
#[tokio::test]
async fn near_miss_at_the_separator_is_still_checked() {
let input = "wZTo:";
assert!(!bashkit::testing::input_echo_would_trip(input));
fuzz_arith(input, "near_miss_separator").await;
}
16 changes: 16 additions & 0 deletions knowledge/security/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,22 @@ so `arithmetic_fuzz` went red in run 271 on `:A>>=::TTA:::Tok::::` until it
pre-filtered. Adding a builtin whose errors quote user text means choosing one
of the two, never relaxing `UNIVERSAL_BANNED`.

A pre-filter that matches the raw input is not enough, because the banned shape
need not be in the input at all -- the diagnostic's own punctuation can finish
it. Arithmetic errors render `{diag_echo(expr.trim())}: {msg}`, so run 295's
`wZTok:` picked up the message separator's colon and the line read `bash: line
1: wZTok:: syntax error in expression (error token is ":")`, spelling `Tok::`
across the join while the script contained only `Tok:`. `input_echo_would_trip`
therefore renders the slot with the punctuation a diagnostic places around it
(`DIAGNOSTIC_ECHO_PAD`) before matching, and matches the trimmed text because
the slot holds `expr.trim()`. The same join completes the space-prefixed
` { code:` shape from the `bash: line N: ` prefix. Keeping this in the gate
rather than widening `strip_real_shell_error_lines` to the arithmetic template
is deliberate: that template's `{msg}` half is bashkit's own text, the one slot
where a genuine Debug leak would appear, so stripping the line would blind the
detector exactly where it has to look. A skipped input hides nothing, because
the skip condition is that the shape is attributable to the input.

Run 272 showed the same false-positive class reaches the **host canary**, and
that the detector itself is what arms it. `assert_fuzz_invariants` compares
output against `FUZZ_HOST_CANARY` with `contains`, and that comparison is
Expand Down
Loading