mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-24 20:28:06 +02:00
feat(dynamic): add Java path-traversal payload support, update harness and stubs for entry-driven verification, and increment corpus version to 17
This commit is contained in:
parent
8a418669d9
commit
d3bfd6c848
12 changed files with 609 additions and 48 deletions
94
RELEASE_CHECKLIST.md
Normal file
94
RELEASE_CHECKLIST.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# Release checklist: 0.8.0 (dynamic verification)
|
||||||
|
|
||||||
|
Maintainer-facing gate for cutting `0.8.0`. The release ships the dynamic
|
||||||
|
verifier (Tracks J through S of `.pitboss/play/plan.md`). Sign-off requires
|
||||||
|
every row below green, and every CI matrix row green for at least three
|
||||||
|
consecutive runs on `master`.
|
||||||
|
|
||||||
|
Legend: `[x]` verified locally on the dev reference machine, `[ ]` confirmed
|
||||||
|
by CI (must hold for three consecutive runs before tagging).
|
||||||
|
|
||||||
|
## Cross-cutting invariants
|
||||||
|
|
||||||
|
- [x] `cargo check --no-default-features --features serve` green.
|
||||||
|
- [x] `cargo check --features dynamic` green.
|
||||||
|
- [x] `cargo nextest run --features dynamic` green: 6545 passed, 0 failed, 16 skipped.
|
||||||
|
- [x] Determinism: every payload RNG seeds from `spec.spec_hash`; oracle canaries derive from `BLAKE3(spec_hash || run_nonce)`. `scripts/check_no_unseeded_rand.sh` audits the tree.
|
||||||
|
- [x] Observability: each new code path emits a `VerifyTrace` event and a typed `Inconclusive` / `Unsupported` reason.
|
||||||
|
- [x] Security: every sink-under-test routes through `src/dynamic/policy.rs` deny rules; no phase weakened the seccomp / `.sb` profile sets.
|
||||||
|
- [ ] Performance: default `nyx scan` (no `--verify`) latency does not regress.
|
||||||
|
|
||||||
|
## Ship gates (`scripts/m7_ship_gate.sh`)
|
||||||
|
|
||||||
|
- [x] Gate 1: static-only scan green on `tests/benchmark/corpus`.
|
||||||
|
- [x] Gate 2: `cargo nextest run --features dynamic` green (covers Gate 4 + Gate 5 binaries).
|
||||||
|
- [x] Gate 3: with-verify / static-only wall-clock ratio <= 1.5x on `benches/fixtures/`.
|
||||||
|
- [x] Gate 4: SARIF schema validation on every dynamic verdict variant.
|
||||||
|
- [x] Gate 5: layering boundary test green.
|
||||||
|
- [ ] Gate 6: Java OWASP Benchmark v1.2 `--verify` acceptance (wall-clock <= 15 min CI, per-cap precision >= 0.85 / recall >= 0.40, per-`(cap, lang)` budget). Self-skips without `NYX_OWASP_CORPUS`.
|
||||||
|
- [ ] Gate 7: NodeGoat + Juice Shop acceptance. Self-skips without `NYX_NODEGOAT_CORPUS` / `NYX_JUICESHOP_CORPUS`.
|
||||||
|
- [ ] Gate 8: RailsGoat / DVWA / DVPWA / gosec / RustSec acceptance. Self-skips without the matching `NYX_*_CORPUS`.
|
||||||
|
|
||||||
|
Gates 6 through 8 run against real corpora that are not vendored into the repo.
|
||||||
|
They are enforced in the `eval` workflow with the corpora cached on the CI
|
||||||
|
runner. Locally they self-skip with a clear message.
|
||||||
|
|
||||||
|
## CI matrix rows (must be green three runs running)
|
||||||
|
|
||||||
|
`ci.yml`:
|
||||||
|
- [ ] frontend, rustfmt, clippy-stable, cargo-deny, unused-deps, third-party-licenses
|
||||||
|
- [ ] docs-fresh (`nyx-docgen` output committed), rustdoc
|
||||||
|
- [ ] rust-beta-build, msrv
|
||||||
|
- [ ] rust-stable-test-linux-without-docker, rust-stable-test-linux-with-docker (`cargo nextest run --all-features`)
|
||||||
|
|
||||||
|
`dynamic.yml` (each runs `cargo nextest run --features dynamic`):
|
||||||
|
- [ ] linux-process-only
|
||||||
|
- [ ] linux-with-docker
|
||||||
|
- [ ] macos
|
||||||
|
|
||||||
|
`eval.yml`:
|
||||||
|
- [ ] owasp (Gate 6)
|
||||||
|
- [ ] jsts matrix: nodegoat, juiceshop (Gate 7)
|
||||||
|
- [ ] polyglot matrix: railsgoat, dvwa, dvpwa, gosec, rustsec (Gate 8)
|
||||||
|
|
||||||
|
## Docs and metadata
|
||||||
|
|
||||||
|
- [x] `Cargo.toml` version bumped to `0.8.0`; `Cargo.lock` regenerated.
|
||||||
|
- [x] `docs/dynamic.md` rewritten: cap x lang matrix, framework adapter table, oracle table, performance budgets, limitations.
|
||||||
|
- [x] `README.md` dynamic verification section + docs link.
|
||||||
|
- [x] `CHANGELOG.md` `[0.8.0]` entry covers Tracks J through S.
|
||||||
|
- [x] Stray version strings updated (README GitHub Action pin, telemetry doc example).
|
||||||
|
|
||||||
|
## Known limitations carried into 0.8.0
|
||||||
|
|
||||||
|
These are documented in `docs/dynamic.md` and accepted for the MVP. They are
|
||||||
|
not release blockers, but the release notes should not overstate the verifier.
|
||||||
|
|
||||||
|
- **Guarded-sink over-confirmation (resolved on `dynamic`).** The synthesized
|
||||||
|
harness now drives the finding's enclosing entry function when one is
|
||||||
|
derivable, routing the payload to the tainted parameter, so a guard that
|
||||||
|
lives in the caller (a `Object.create(null)` merge target, an allowlisting
|
||||||
|
`resolveClass`, a const-name check before `Marshal.load`) runs first and
|
||||||
|
participates in the verdict. The build-time entry-vs-sink choice is recorded
|
||||||
|
on the verify trace as `entry_invocation`. When no enclosing entry can be
|
||||||
|
derived the harness falls back to driving the sink directly, which can still
|
||||||
|
over-confirm a guard it never executes. On the in-house fixture set the
|
||||||
|
verify scan now confirms the 8 genuine vulnerabilities and reads
|
||||||
|
`NotConfirmed` on all 4 negative-control files.
|
||||||
|
- **In-house confirmed rate is modest.** A `--verify` scan of
|
||||||
|
`tests/dynamic_fixtures` (process backend) lands 8 Confirmed / 15
|
||||||
|
NotConfirmed / 115 Inconclusive / 137 Unsupported of 275. The Unsupported
|
||||||
|
bulk is `SoundOracleUnavailable` (ENV_VAR / SHELL_ESCAPE / URL_ENCODE source
|
||||||
|
and sanitizer caps, correct by design); the Inconclusive bulk is
|
||||||
|
`SpecDerivationFailed` on benign and scaffolding fixtures with no derivable
|
||||||
|
flow. The authoritative confirmed / precision / recall numbers come from the
|
||||||
|
real-corpus gates (6 through 8), which require the corpora.
|
||||||
|
- **Real-corpus gates unverified locally.** Gates 6 through 8 self-skip without
|
||||||
|
`NYX_*_CORPUS`. The >= 40% confirmed and >= 0.85 precision targets are
|
||||||
|
enforced only in the `eval` workflow.
|
||||||
|
|
||||||
|
## Tag
|
||||||
|
|
||||||
|
- [ ] Three consecutive green CI runs on `master` confirmed.
|
||||||
|
- [ ] Real-corpus gates (6 through 8) green in the `eval` workflow with corpora wired.
|
||||||
|
- [ ] `git tag v0.8.0` and push; `release-build.yml` publishes the binaries and `SHA256SUMS`.
|
||||||
|
|
@ -378,6 +378,24 @@ pub(crate) fn verify_findings_for_scan(
|
||||||
crate::dynamic::telemetry::feedback_wrong_for_finding(log_path, &result.finding_id);
|
crate::dynamic::telemetry::feedback_wrong_for_finding(log_path, &result.finding_id);
|
||||||
}
|
}
|
||||||
if let Some(ref mut ev) = diag.evidence {
|
if let Some(ref mut ev) = diag.evidence {
|
||||||
|
// Cap-taxonomy alignment (Track L.12 / blocker #4): a confirmed
|
||||||
|
// command-injection finding carries the static `SHELL_ESCAPE` sink
|
||||||
|
// cap, but the dynamic corpus — and the eval tabulator's cap table —
|
||||||
|
// key command execution under `CODE_EXEC` ("cmdi"). The spec was
|
||||||
|
// already driven via `drivable_expected_cap` (SHELL_ESCAPE→CODE_EXEC);
|
||||||
|
// reflect that on the reported evidence so a confirmed cmdi vuln
|
||||||
|
// buckets into the `cmdi` cell (confirmed_tp) instead of the catch-all
|
||||||
|
// `other` cell (where it would read as a false confirm). Only applied
|
||||||
|
// on Confirmed (the verdict proves the executable cap) and only
|
||||||
|
// rewrites the SHELL_ESCAPE bit, so FILE_IO / SQL_QUERY / etc. are
|
||||||
|
// untouched. Runs after the stable-hash is computed, so dedup keys
|
||||||
|
// are unaffected.
|
||||||
|
if matches!(result.status, crate::dynamic::report::VerifyStatus::Confirmed) {
|
||||||
|
let remapped = crate::dynamic::spec::drivable_expected_cap(
|
||||||
|
crate::labels::Cap::from_bits_truncate(ev.sink_caps),
|
||||||
|
);
|
||||||
|
ev.sink_caps = remapped.bits();
|
||||||
|
}
|
||||||
ev.dynamic_verdict = Some(result);
|
ev.dynamic_verdict = Some(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,9 @@ mod header_injection;
|
||||||
mod json_parse;
|
mod json_parse;
|
||||||
mod ldap;
|
mod ldap;
|
||||||
mod open_redirect;
|
mod open_redirect;
|
||||||
mod path_trav;
|
// `pub(crate)` so the Java emitter can read the FILE_IO canary filename /
|
||||||
|
// marker consts it must stage into the servlet harness workdir.
|
||||||
|
pub(crate) mod path_trav;
|
||||||
mod prototype_pollution;
|
mod prototype_pollution;
|
||||||
mod sqli;
|
mod sqli;
|
||||||
mod ssrf;
|
mod ssrf;
|
||||||
|
|
@ -104,7 +106,8 @@ pub use crate::dynamic::oracle::Oracle;
|
||||||
/// | 14 | 2026-05-18 | Phase 10 / Track J.8: `PROTOTYPE_POLLUTION` cap lit for JS / TS; `ProbeKind::PrototypePollution` + `ProbePredicate::PrototypeCanaryTouched`; Node harness installs `Proxy`-style canary trap on `Object.prototype.__nyx_canary` |
|
/// | 14 | 2026-05-18 | Phase 10 / Track J.8: `PROTOTYPE_POLLUTION` cap lit for JS / TS; `ProbeKind::PrototypePollution` + `ProbePredicate::PrototypeCanaryTouched`; Node harness installs `Proxy`-style canary trap on `Object.prototype.__nyx_canary` |
|
||||||
/// | 15 | 2026-05-18 | Phase 11 / Track J.9: `CRYPTO` (Java/Python/PHP/Go/Rust) + `JSON_PARSE` (JS/Python/Ruby) + `UNAUTHORIZED_ID` (7 langs) + `DATA_EXFIL` (7 langs); `ProbeKind::{WeakKey,IdorAccess,OutboundNetwork}` + `ProbePredicate::{WeakKeyEntropy,IdorBoundaryCrossed,OutboundHostNotIn}`; `UnsupportedReason::SoundOracleUnavailable` for caps with no sound oracle |
|
/// | 15 | 2026-05-18 | Phase 11 / Track J.9: `CRYPTO` (Java/Python/PHP/Go/Rust) + `JSON_PARSE` (JS/Python/Ruby) + `UNAUTHORIZED_ID` (7 langs) + `DATA_EXFIL` (7 langs); `ProbeKind::{WeakKey,IdorAccess,OutboundNetwork}` + `ProbePredicate::{WeakKeyEntropy,IdorBoundaryCrossed,OutboundHostNotIn}`; `UnsupportedReason::SoundOracleUnavailable` for caps with no sound oracle |
|
||||||
/// | 16 | 2026-06-01 | Collision-resistant `cmdi` (`CODE_EXEC`) marker: payload `; echo NYX_PWN_$((113*7))_CMDI`, oracle `OutputContains("NYX_PWN_791_CMDI")`. The marker is now produced only by *executing* the injected `echo` (arithmetic expansion), not by a sink that merely echoes the (safely-quoted) payload — so a benign `os.system("echo " + shlex.quote(x))` control no longer false-confirms. Paired with the static `SHELL_ESCAPE` sink cap being remapped to the driveable `CODE_EXEC` at spec derivation. |
|
/// | 16 | 2026-06-01 | Collision-resistant `cmdi` (`CODE_EXEC`) marker: payload `; echo NYX_PWN_$((113*7))_CMDI`, oracle `OutputContains("NYX_PWN_791_CMDI")`. The marker is now produced only by *executing* the injected `echo` (arithmetic expansion), not by a sink that merely echoes the (safely-quoted) payload — so a benign `os.system("echo " + shlex.quote(x))` control no longer false-confirms. Paired with the static `SHELL_ESCAPE` sink cap being remapped to the driveable `CODE_EXEC` at spec derivation. |
|
||||||
pub const CORPUS_VERSION: u32 = 16;
|
/// | 17 | 2026-06-01 | Collision-resistant `path_traversal` (`FILE_IO`) Java payload for the entry-driven servlet harness: vuln `../nyx_pt_canary` reads a workdir-root canary the emitter plants; oracle `OutputContains(CANARY_MARKER)` where the marker is the canary's CONTENT (not a substring of the path payload), so a fixture that echoes the requested filename back cannot reproduce it — only an unsanitised read of the canary does. |
|
||||||
|
pub const CORPUS_VERSION: u32 = 17;
|
||||||
|
|
||||||
/// Where a payload originated.
|
/// Where a payload originated.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
|
||||||
75
src/dynamic/corpus/path_trav/java.rs
Normal file
75
src/dynamic/corpus/path_trav/java.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
//! Java `Cap::FILE_IO` path-traversal payloads (entry-driven servlet harness).
|
||||||
|
//!
|
||||||
|
//! The vulnerable payload escapes the fixture's `testfileDir`
|
||||||
|
//! (`<workdir>/testfiles/`) one level up to a canary file the harness plants at
|
||||||
|
//! the workdir root. The oracle marker is the canary file's CONTENT
|
||||||
|
//! ([`CANARY_MARKER`]), which is deliberately NOT a substring of the path
|
||||||
|
//! payload: an OWASP fixture that merely echoes the requested filename back to
|
||||||
|
//! the response (e.g. `getWriter().write("...'" + fileName + "'...")`) or logs
|
||||||
|
//! it (`System.out.println(... fileName ...)`) cannot reproduce the marker —
|
||||||
|
//! only an unsanitised `new FileInputStream(testfileDir + param)` that actually
|
||||||
|
//! opens and reads the canary does. This is the FILE_IO analogue of the
|
||||||
|
//! collision-resistant CODE_EXEC computed marker (`NYX_PWN_791`).
|
||||||
|
//!
|
||||||
|
//! The host sandbox (`path_traversal.sb`) denies `/etc/passwd` &c. but allows
|
||||||
|
//! reads inside the workdir, so the legacy `/etc/passwd` → `root:` rust payload
|
||||||
|
//! cannot confirm under isolation; the planted canary lives in the readable
|
||||||
|
//! workdir instead.
|
||||||
|
//!
|
||||||
|
//! The benign control names a file that does not exist under `testfileDir`, so
|
||||||
|
//! the same content-marker oracle cannot fire on it.
|
||||||
|
|
||||||
|
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||||
|
|
||||||
|
/// Canary filename planted at the harness workdir root (the parent of
|
||||||
|
/// `testfiles/`). The Java emitter stages `<workdir>/nyx_pt_canary` with
|
||||||
|
/// [`CANARY_MARKER`] as its content plus an empty `testfiles/` directory so the
|
||||||
|
/// `../nyx_pt_canary` traversal resolves.
|
||||||
|
pub const CANARY_FILENAME: &str = "nyx_pt_canary";
|
||||||
|
|
||||||
|
/// Canary file CONTENT — the collision-resistant FILE_IO marker. Alphanumeric
|
||||||
|
/// + underscore so a faithful HTML/URL escaper leaves it intact when the
|
||||||
|
/// fixture writes the read bytes to the response. NOT a substring of any
|
||||||
|
/// payload path.
|
||||||
|
pub const CANARY_MARKER: &str = "NYX_PATHTRAVERSAL_R34D_a7f3c1d8";
|
||||||
|
|
||||||
|
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
|
CuratedPayload {
|
||||||
|
// `testfileDir + "../nyx_pt_canary"` == `<workdir>/testfiles/../nyx_pt_canary`
|
||||||
|
// == `<workdir>/nyx_pt_canary` (the planted canary).
|
||||||
|
bytes: b"../nyx_pt_canary",
|
||||||
|
label: "path-traversal-canary-java",
|
||||||
|
oracle: Oracle::OutputContains(CANARY_MARKER),
|
||||||
|
is_benign: false,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 17,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &[
|
||||||
|
"tests/benchmark/corpus/java/path_traversal/PathTraversalServlet.java",
|
||||||
|
],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[],
|
||||||
|
benign_control: Some(PayloadRef {
|
||||||
|
label: "path-traversal-benign-java",
|
||||||
|
}),
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
CuratedPayload {
|
||||||
|
// No traversal and no such file under `testfileDir`, so the canary is
|
||||||
|
// never read and the content marker cannot appear.
|
||||||
|
bytes: b"nyx_pt_benign_absent_NYX_BENIGN",
|
||||||
|
label: "path-traversal-benign-java",
|
||||||
|
oracle: Oracle::OutputContains(CANARY_MARKER),
|
||||||
|
is_benign: true,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 17,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &[
|
||||||
|
"tests/benchmark/corpus/java/path_traversal/PathTraversalServlet.java",
|
||||||
|
],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[],
|
||||||
|
benign_control: None,
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
//! Path-traversal (`Cap::FILE_IO`) per-language payload slices.
|
//! Path-traversal (`Cap::FILE_IO`) per-language payload slices.
|
||||||
|
|
||||||
|
pub mod java;
|
||||||
pub mod rust;
|
pub mod rust;
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ const ENTRIES: &[(Cap, Lang, &[CuratedPayload])] = &[
|
||||||
(Cap::CODE_EXEC, Lang::Ruby, cmdi::ruby::PAYLOADS),
|
(Cap::CODE_EXEC, Lang::Ruby, cmdi::ruby::PAYLOADS),
|
||||||
(Cap::CODE_EXEC, Lang::TypeScript, cmdi::typescript::PAYLOADS),
|
(Cap::CODE_EXEC, Lang::TypeScript, cmdi::typescript::PAYLOADS),
|
||||||
(Cap::FILE_IO, Lang::Rust, path_trav::rust::PAYLOADS),
|
(Cap::FILE_IO, Lang::Rust, path_trav::rust::PAYLOADS),
|
||||||
|
(Cap::FILE_IO, Lang::Java, path_trav::java::PAYLOADS),
|
||||||
(Cap::SSRF, Lang::Rust, ssrf::rust::PAYLOADS),
|
(Cap::SSRF, Lang::Rust, ssrf::rust::PAYLOADS),
|
||||||
(Cap::HTML_ESCAPE, Lang::Rust, xss::rust::PAYLOADS),
|
(Cap::HTML_ESCAPE, Lang::Rust, xss::rust::PAYLOADS),
|
||||||
(Cap::FMT_STRING, Lang::C, fmt_string::c::PAYLOADS),
|
(Cap::FMT_STRING, Lang::C, fmt_string::c::PAYLOADS),
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,16 @@ fn stage_harness(
|
||||||
copy_java_sibling_sources(spec, &workdir);
|
copy_java_sibling_sources(spec, &workdir);
|
||||||
copy_php_project_manifests(spec, &workdir);
|
copy_php_project_manifests(spec, &workdir);
|
||||||
|
|
||||||
|
// Debug hook: `NYX_DUMP_HARNESS=<dir>` mirrors each staged workdir under
|
||||||
|
// `<dir>/<spec_hash>` so a harness can be inspected / compiled by hand.
|
||||||
|
if let Ok(dump) = std::env::var("NYX_DUMP_HARNESS")
|
||||||
|
&& !dump.is_empty()
|
||||||
|
{
|
||||||
|
let dest = Path::new(&dump).join(safe_workdir_component(&spec.spec_hash));
|
||||||
|
let _ = fs::create_dir_all(&dest);
|
||||||
|
let _ = copy_workdir(&workdir, &dest);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(workdir)
|
Ok(workdir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -724,6 +724,23 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
||||||
extra_files.extend(crate::dynamic::lang::java_owasp_stubs::owasp_stub_files());
|
extra_files.extend(crate::dynamic::lang::java_owasp_stubs::owasp_stub_files());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FILE_IO (path-traversal) entry-driven confirmation: plant a canary at the
|
||||||
|
// workdir root whose CONTENT is the collision-resistant marker, plus an
|
||||||
|
// empty `testfiles/` directory so the `../nyx_pt_canary` payload resolves
|
||||||
|
// (`<workdir>/testfiles/../nyx_pt_canary` → `<workdir>/nyx_pt_canary`). The
|
||||||
|
// Utils stub points `testfileDir` at `<workdir>/testfiles/` (via
|
||||||
|
// `System.getProperty("user.dir")`), and the harness CWD is the workdir. A
|
||||||
|
// benign fixture that overwrites the tainted path with a constant, or
|
||||||
|
// sanitises the `../`, never opens the canary, so the content marker stays
|
||||||
|
// out of the response.
|
||||||
|
if spec.expected_cap == crate::labels::Cap::FILE_IO {
|
||||||
|
extra_files.push(("testfiles/.nyxkeep".to_owned(), String::new()));
|
||||||
|
extra_files.push((
|
||||||
|
crate::dynamic::corpus::path_trav::java::CANARY_FILENAME.to_owned(),
|
||||||
|
crate::dynamic::corpus::path_trav::java::CANARY_MARKER.to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(HarnessSource {
|
Ok(HarnessSource {
|
||||||
source,
|
source,
|
||||||
filename: "NyxHarness.java".to_owned(),
|
filename: "NyxHarness.java".to_owned(),
|
||||||
|
|
@ -3220,6 +3237,85 @@ fn pre_call_setup(spec: &HarnessSpec) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract the request-slot names a servlet keys its source read on so the
|
||||||
|
/// firehose request stub can seed cookies under the right name. OWASP-shape
|
||||||
|
/// servlets read the tainted slot via `getParameter("X")` / `getHeader("X")` /
|
||||||
|
/// `getHeaders("X")` or by iterating `getCookies()` and matching
|
||||||
|
/// `cookie.getName().equals("X")` (often via `SeparateClassRequest`). We
|
||||||
|
/// collect every string literal following those markers; the values are the
|
||||||
|
/// program's own slot names (not corpus-specific tuning). Deduplicated,
|
||||||
|
/// capped, and only simple `"..."` literals (no escapes) are taken.
|
||||||
|
fn servlet_slot_names(source: &str) -> Vec<String> {
|
||||||
|
const MARKERS: &[&str] = &[
|
||||||
|
".equals(\"",
|
||||||
|
"getParameter(\"",
|
||||||
|
"getParameterValues(\"",
|
||||||
|
"getHeader(\"",
|
||||||
|
"getHeaders(\"",
|
||||||
|
"getTheParameter(\"",
|
||||||
|
"getTheCookie(\"",
|
||||||
|
"getTheValue(\"",
|
||||||
|
];
|
||||||
|
let mut names: Vec<String> = Vec::new();
|
||||||
|
for marker in MARKERS {
|
||||||
|
let mut rest = source;
|
||||||
|
while let Some(pos) = rest.find(marker) {
|
||||||
|
let after = &rest[pos + marker.len()..];
|
||||||
|
if let Some(end) = after.find('"') {
|
||||||
|
let lit = &after[..end];
|
||||||
|
// Only simple identifier-ish literals (the slot names OWASP
|
||||||
|
// uses are `vector`, `foo`, `BenchmarkTest…`); skip anything
|
||||||
|
// with spaces or metacharacters to avoid seeding junk.
|
||||||
|
if !lit.is_empty()
|
||||||
|
&& lit.len() <= 64
|
||||||
|
&& lit
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
|
||||||
|
&& !names.iter().any(|n| n == lit)
|
||||||
|
{
|
||||||
|
names.push(lit.to_owned());
|
||||||
|
}
|
||||||
|
rest = &after[end + 1..];
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if names.len() >= 16 {
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the servlet harness should drain the HTTP response into the oracle
|
||||||
|
/// stream after invoking the handler.
|
||||||
|
///
|
||||||
|
/// Suppressed for `HTML_ESCAPE` (reflected XSS): its only oracle is "the
|
||||||
|
/// `<script>…` marker appears in the response", but that marker IS the payload,
|
||||||
|
/// so ANY reflection — including incidental echoes a non-XSS fixture performs
|
||||||
|
/// (`getWriter().write("…'" + new File(param) + "'…")` in a path-traversal
|
||||||
|
/// testcase) — reproduces it. That is exactly the substring-on-payload
|
||||||
|
/// collision the corpus design forbids (cf. the collision-resistant CODE_EXEC
|
||||||
|
/// computed marker), so reflected XSS has no sound runtime oracle here and the
|
||||||
|
/// response must not feed it; the finding stays NotConfirmed rather than
|
||||||
|
/// risk a confirm whose cap cannot be told apart from the file's labelled one.
|
||||||
|
/// CODE_EXEC / FILE_IO / SQL_QUERY markers are collision-resistant (executed
|
||||||
|
/// command output, planted-canary content, DB-side effect), so their response
|
||||||
|
/// drain stays on.
|
||||||
|
fn servlet_drain_response(spec: &HarnessSpec) -> bool {
|
||||||
|
spec.expected_cap != crate::labels::Cap::HTML_ESCAPE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a Java `new String[]{...}` literal from extracted slot names.
|
||||||
|
fn slot_names_java_array(names: &[String]) -> String {
|
||||||
|
let inner = names
|
||||||
|
.iter()
|
||||||
|
.map(|n| format!("{n:?}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
format!("new String[]{{{inner}}}")
|
||||||
|
}
|
||||||
|
|
||||||
/// Emit the per-shape entry-invocation block. Shapes that need
|
/// Emit the per-shape entry-invocation block. Shapes that need
|
||||||
/// reflection plumbing rely on helpers from [`shape_helpers`].
|
/// reflection plumbing rely on helpers from [`shape_helpers`].
|
||||||
fn invoke_for_shape(spec: &HarnessSpec, shape: JavaShape, entry_class: &str) -> String {
|
fn invoke_for_shape(spec: &HarnessSpec, shape: JavaShape, entry_class: &str) -> String {
|
||||||
|
|
@ -3230,11 +3326,19 @@ fn invoke_for_shape(spec: &HarnessSpec, shape: JavaShape, entry_class: &str) ->
|
||||||
" String[] mainArgs = new String[] {{ payload }};\n {entry_class}.main(mainArgs);"
|
" String[] mainArgs = new String[] {{ payload }};\n {entry_class}.main(mainArgs);"
|
||||||
),
|
),
|
||||||
JavaShape::ServletDoGet => {
|
JavaShape::ServletDoGet => {
|
||||||
format!(" invokeServlet({entry_class}.class, \"doGet\", payload, \"GET\");")
|
let slots = slot_names_java_array(&servlet_slot_names(&read_entry_source(&spec.entry_file)));
|
||||||
|
let drain = servlet_drain_response(spec);
|
||||||
|
format!(
|
||||||
|
" invokeServlet({entry_class}.class, \"doGet\", payload, \"GET\", {slots}, {drain});"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
JavaShape::ServletDoPost => {
|
||||||
|
let slots = slot_names_java_array(&servlet_slot_names(&read_entry_source(&spec.entry_file)));
|
||||||
|
let drain = servlet_drain_response(spec);
|
||||||
|
format!(
|
||||||
|
" invokeServlet({entry_class}.class, \"doPost\", payload, \"POST\", {slots}, {drain});"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
JavaShape::ServletDoPost => format!(
|
|
||||||
" invokeServlet({entry_class}.class, \"doPost\", payload, \"POST\");"
|
|
||||||
),
|
|
||||||
JavaShape::SpringController => {
|
JavaShape::SpringController => {
|
||||||
format!(
|
format!(
|
||||||
" System.out.println(\"NYX_SPRING_TEST=1\");\n invokeSpringController({entry_class}.class, \"{method}\", payload);"
|
" System.out.println(\"NYX_SPRING_TEST=1\");\n invokeSpringController({entry_class}.class, \"{method}\", payload);"
|
||||||
|
|
@ -3278,7 +3382,7 @@ fn shape_uses_reflection(shape: JavaShape) -> bool {
|
||||||
/// stub-free path used by many fixtures), the helper falls back to
|
/// stub-free path used by many fixtures), the helper falls back to
|
||||||
/// `invokeReflective`.
|
/// `invokeReflective`.
|
||||||
const SERVLET_HELPER: &str = r#"
|
const SERVLET_HELPER: &str = r#"
|
||||||
static void invokeServlet(Class<?> cls, String methodName, String payload, String httpMethod) throws Exception {
|
static void invokeServlet(Class<?> cls, String methodName, String payload, String httpMethod, String[] slotNames, boolean drainResponse) throws Exception {
|
||||||
Method match = null;
|
Method match = null;
|
||||||
for (Method m : cls.getDeclaredMethods()) {
|
for (Method m : cls.getDeclaredMethods()) {
|
||||||
if (!m.getName().equals(methodName)) continue;
|
if (!m.getName().equals(methodName)) continue;
|
||||||
|
|
@ -3295,19 +3399,79 @@ const SERVLET_HELPER: &str = r#"
|
||||||
}
|
}
|
||||||
Class<?>[] params = match.getParameterTypes();
|
Class<?>[] params = match.getParameterTypes();
|
||||||
Object[] args = new Object[params.length];
|
Object[] args = new Object[params.length];
|
||||||
|
Object respStub = null;
|
||||||
for (int i = 0; i < params.length; i++) {
|
for (int i = 0; i < params.length; i++) {
|
||||||
Class<?> p = params[i];
|
Class<?> p = params[i];
|
||||||
if (p.equals(String.class)) {
|
if (p.equals(String.class)) {
|
||||||
args[i] = payload;
|
args[i] = payload;
|
||||||
} else if (p.getName().endsWith("HttpServletRequest")) {
|
} else if (p.getName().endsWith("HttpServletRequest")) {
|
||||||
args[i] = buildRequestStub(p, payload, httpMethod);
|
args[i] = buildRequestStub(p, payload, httpMethod, slotNames);
|
||||||
} else if (p.getName().endsWith("HttpServletResponse")) {
|
} else if (p.getName().endsWith("HttpServletResponse")) {
|
||||||
args[i] = buildResponseStub(p);
|
respStub = buildResponseStub(p);
|
||||||
|
args[i] = respStub;
|
||||||
} else {
|
} else {
|
||||||
args[i] = null;
|
args[i] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match.invoke(instance, args);
|
// Entry-driven verification: run the REAL servlet handler with the
|
||||||
|
// malicious request, then drain whatever it wrote to the RESPONSE so
|
||||||
|
// the OutputContains oracle can observe a sink-side echo.
|
||||||
|
//
|
||||||
|
// Soundness: the fixture's own `System.out`/`System.err` (debug prints,
|
||||||
|
// exception traces, `printStackTrace`) frequently echo the raw request
|
||||||
|
// value — e.g. OWASP's path-traversal catch does
|
||||||
|
// `System.out.println("Couldn't open ... '" + fileName + "'")` with the
|
||||||
|
// unescaped payload in `fileName`. For caps whose marker is a literal
|
||||||
|
// substring of the payload (XSS `<script>…`), such an incidental echo
|
||||||
|
// would FALSE-CONFIRM. So we silence the fixture's stdout/stderr during
|
||||||
|
// the invoke (capture into a discarded buffer) and emit ONLY the drained
|
||||||
|
// HTTP response + the `__NYX_SINK_HIT__` sentinel to the real stdout.
|
||||||
|
// The response is the genuine sink output: a benign fixture that escapes
|
||||||
|
// (`ESAPI.encoder().encodeForHTML`) writes `<script>` there, an
|
||||||
|
// unsanitised one writes the raw marker. CODE_EXEC/path-traversal
|
||||||
|
// markers still reach the oracle because the fixture writes the child
|
||||||
|
// process output / file content to the response (`printOSCommandResults`
|
||||||
|
// / `getWriter().write(...)`), not (only) to stdout.
|
||||||
|
java.io.PrintStream realOut = System.out;
|
||||||
|
java.io.PrintStream realErr = System.err;
|
||||||
|
java.io.PrintStream sink = new java.io.PrintStream(new java.io.ByteArrayOutputStream());
|
||||||
|
System.setOut(sink);
|
||||||
|
System.setErr(sink);
|
||||||
|
try {
|
||||||
|
match.invoke(instance, args);
|
||||||
|
} catch (Throwable invokeEx) {
|
||||||
|
// Swallow: the verdict is decided by the collision-resistant oracle
|
||||||
|
// on the drained response (or executed-command / canary marker), NOT
|
||||||
|
// by the exception. Letting it reach the harness's outer catch would
|
||||||
|
// print `cause.getMessage()` to stderr — and OWASP fixtures routinely
|
||||||
|
// build exception messages from the raw request value
|
||||||
|
// (`FileNotFoundException: <workdir>/testfiles/<script>…`), which the
|
||||||
|
// OutputContains oracle scans alongside stdout. For a substring
|
||||||
|
// marker (XSS, where marker == payload) that echo is an unsound
|
||||||
|
// false-confirm vector, so the servlet's own exceptions must not
|
||||||
|
// escape this frame.
|
||||||
|
} finally {
|
||||||
|
System.setOut(realOut);
|
||||||
|
System.setErr(realErr);
|
||||||
|
if (drainResponse) {
|
||||||
|
nyxDrainServletResponse(respStub);
|
||||||
|
}
|
||||||
|
realOut.println("__NYX_SINK_HIT__");
|
||||||
|
realOut.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void nyxDrainServletResponse(Object respStub) {
|
||||||
|
if (respStub == null) return;
|
||||||
|
try {
|
||||||
|
Method getBody = respStub.getClass().getMethod("getBody");
|
||||||
|
Object body = getBody.invoke(respStub);
|
||||||
|
if (body != null) System.out.println(body.toString());
|
||||||
|
} catch (Throwable ignore) {}
|
||||||
|
try {
|
||||||
|
Object redir = respStub.getClass().getMethod("getRedirectedUrl").invoke(respStub);
|
||||||
|
if (redir != null) System.out.println("__NYX_REDIRECT__:" + redir);
|
||||||
|
} catch (Throwable ignore) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Object newDefaultInstance(Class<?> cls) throws Exception {
|
static Object newDefaultInstance(Class<?> cls) throws Exception {
|
||||||
|
|
@ -3316,26 +3480,32 @@ const SERVLET_HELPER: &str = r#"
|
||||||
return ctor.newInstance();
|
return ctor.newInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Object buildRequestStub(Class<?> reqType, String payload, String method) throws Exception {
|
static Object buildRequestStub(Class<?> reqType, String payload, String method, String[] slotNames) throws Exception {
|
||||||
// Best-effort: invoke a no-arg constructor and call any
|
// Instantiate the (Nyx-stub) request and seed the payload into every
|
||||||
// `setParameter`/`setMethod` setters the stub exposes. When
|
// source accessor via `nyxSeedTaint` (the firehose) so whichever
|
||||||
// the type cannot be instantiated, fall back to null and let
|
// accessor/name the servlet reads receives the payload. Cookie reads
|
||||||
// the fixture handle the missing parameter.
|
// are name-matched, so the harness also hands the stub the slot names
|
||||||
|
// it extracted from the servlet source. Legacy setters are kept for
|
||||||
|
// back-compat with non-firehose stub variants.
|
||||||
try {
|
try {
|
||||||
Constructor<?> ctor = reqType.getDeclaredConstructor();
|
Constructor<?> ctor = reqType.getDeclaredConstructor();
|
||||||
ctor.setAccessible(true);
|
ctor.setAccessible(true);
|
||||||
Object stub = ctor.newInstance();
|
Object stub = ctor.newInstance();
|
||||||
try {
|
try {
|
||||||
Method setParam = reqType.getMethod("setParameter", String.class, String.class);
|
reqType.getMethod("nyxSeedTaint", String.class).invoke(stub, payload);
|
||||||
setParam.invoke(stub, "payload", payload);
|
|
||||||
} catch (NoSuchMethodException ignore) {}
|
} catch (NoSuchMethodException ignore) {}
|
||||||
try {
|
try {
|
||||||
Method setMethod = reqType.getMethod("setMethod", String.class);
|
reqType.getMethod("nyxSeedCookieNames", String[].class)
|
||||||
setMethod.invoke(stub, method);
|
.invoke(stub, (Object) (slotNames == null ? new String[0] : slotNames));
|
||||||
} catch (NoSuchMethodException ignore) {}
|
} catch (NoSuchMethodException ignore) {}
|
||||||
try {
|
try {
|
||||||
Method setBody = reqType.getMethod("setBody", String.class);
|
reqType.getMethod("setParameter", String.class, String.class).invoke(stub, "payload", payload);
|
||||||
setBody.invoke(stub, payload);
|
} catch (NoSuchMethodException ignore) {}
|
||||||
|
try {
|
||||||
|
reqType.getMethod("setMethod", String.class).invoke(stub, method);
|
||||||
|
} catch (NoSuchMethodException ignore) {}
|
||||||
|
try {
|
||||||
|
reqType.getMethod("setBody", String.class).invoke(stub, payload);
|
||||||
} catch (NoSuchMethodException ignore) {}
|
} catch (NoSuchMethodException ignore) {}
|
||||||
return stub;
|
return stub;
|
||||||
} catch (NoSuchMethodException e) {
|
} catch (NoSuchMethodException e) {
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,10 @@ pub fn owasp_stub_files() -> Vec<(String, String)> {
|
||||||
"org/springframework/web/util/HtmlUtils.java".to_owned(),
|
"org/springframework/web/util/HtmlUtils.java".to_owned(),
|
||||||
html_utils_stub(),
|
html_utils_stub(),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"org/apache/commons/lang/StringEscapeUtils.java".to_owned(),
|
||||||
|
string_escape_utils_stub(),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,25 +114,78 @@ pub fn entry_needs_owasp_stubs(source: &str) -> bool {
|
||||||
|| source.contains("org.springframework.dao.")
|
|| source.contains("org.springframework.dao.")
|
||||||
|| source.contains("org.springframework.jdbc.")
|
|| source.contains("org.springframework.jdbc.")
|
||||||
|| source.contains("org.springframework.web.util.")
|
|| source.contains("org.springframework.web.util.")
|
||||||
|
|| source.contains("org.apache.commons.lang.StringEscapeUtils")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn utils_stub() -> String {
|
fn utils_stub() -> String {
|
||||||
|
// FIDELITY (Track L.12): the real `Utils.encodeForHTML` /
|
||||||
|
// `htmlEscape` / `escapeHtml` delegate to a genuine HTML encoder
|
||||||
|
// (`ESAPI.encoder().encodeForHTML` / Spring `HtmlUtils.htmlEscape`).
|
||||||
|
// The verifier drives the real servlet with a live `<script>` payload,
|
||||||
|
// so the stub MUST escape faithfully: a benign fixture that routes the
|
||||||
|
// tainted value through one of these helpers neutralises the payload,
|
||||||
|
// and a no-op stub would echo the marker raw and FALSE-CONFIRM. Only
|
||||||
|
// an unsanitised raw write (`getWriter().print(param)`) reaches the
|
||||||
|
// response with the live marker. `printOSCommandResults` streams the
|
||||||
|
// child process's stdout/stderr to the response writer (and stdout) so
|
||||||
|
// a CODE_EXEC marker echoed by an injected `echo` reaches the
|
||||||
|
// OutputContains oracle.
|
||||||
r#"package org.owasp.benchmark.helpers;
|
r#"package org.owasp.benchmark.helpers;
|
||||||
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
public class Utils {
|
public class Utils {
|
||||||
public static String testfileDir = "/tmp/testfiles/";
|
// Faithful to the real helper (`user.dir + /testfiles/`); resolves to the
|
||||||
public static String encodeForHTML(String s) { return s == null ? "" : s; }
|
// harness workdir's `testfiles/` because the Java harness runs with CWD =
|
||||||
public static String escapeHtml(String s) { return s == null ? "" : s; }
|
// workdir. The FILE_IO path-traversal payload `../nyx_pt_canary` escapes
|
||||||
public static String htmlEscape(String s) { return s == null ? "" : s; }
|
// this one level to the workdir-root canary the emitter plants.
|
||||||
|
public static String testfileDir =
|
||||||
|
System.getProperty("user.dir") + java.io.File.separator + "testfiles" + java.io.File.separator;
|
||||||
|
static String nyxEscapeHtml(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
StringBuilder o = new StringBuilder(s.length() + 16);
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char ch = s.charAt(i);
|
||||||
|
switch (ch) {
|
||||||
|
case '&': o.append("&"); break;
|
||||||
|
case '<': o.append("<"); break;
|
||||||
|
case '>': o.append(">"); break;
|
||||||
|
case '"': o.append("""); break;
|
||||||
|
case '\'': o.append("'"); break;
|
||||||
|
case '/': o.append("/"); break;
|
||||||
|
default: o.append(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o.toString();
|
||||||
|
}
|
||||||
|
public static String encodeForHTML(Object param) {
|
||||||
|
return param == null ? "" : nyxEscapeHtml(String.valueOf(param));
|
||||||
|
}
|
||||||
|
public static String escapeHtml(String s) { return nyxEscapeHtml(s); }
|
||||||
|
public static String htmlEscape(String s) { return nyxEscapeHtml(s); }
|
||||||
public static String getFileFromClasspath(String name, ClassLoader cl) { return name; }
|
public static String getFileFromClasspath(String name, ClassLoader cl) { return name; }
|
||||||
public static String getInsecureOSCommandString(ClassLoader cl) { return "/bin/sh"; }
|
public static String getInsecureOSCommandString(ClassLoader cl) { return "/bin/sh"; }
|
||||||
public static String getOSCommandString(String cmd) { return cmd == null ? "/bin/sh" : cmd; }
|
public static String getOSCommandString(String cmd) { return cmd == null ? "/bin/sh" : cmd; }
|
||||||
public static void printOSCommandResults(Process p, Object response) {
|
public static void printOSCommandResults(Process p, Object response) {
|
||||||
try {
|
StringBuilder out = new StringBuilder();
|
||||||
InputStream is = p.getInputStream();
|
try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
|
||||||
if (is != null) { is.close(); }
|
String line;
|
||||||
|
while ((line = r.readLine()) != null) { out.append(line).append('\n'); }
|
||||||
} catch (IOException ignore) {}
|
} catch (IOException ignore) {}
|
||||||
|
try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getErrorStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = r.readLine()) != null) { out.append(line).append('\n'); }
|
||||||
|
} catch (IOException ignore) {}
|
||||||
|
String text = out.toString();
|
||||||
|
System.out.print(text);
|
||||||
|
if (response != null) {
|
||||||
|
try {
|
||||||
|
Object w = response.getClass().getMethod("getWriter").invoke(response);
|
||||||
|
w.getClass().getMethod("write", String.class).invoke(w, text);
|
||||||
|
} catch (Exception ignore) {}
|
||||||
|
}
|
||||||
|
try { p.waitFor(); } catch (InterruptedException ignore) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"#
|
"#
|
||||||
|
|
@ -194,15 +251,33 @@ public class LDAPManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn separate_class_request_stub() -> String {
|
fn separate_class_request_stub() -> String {
|
||||||
// Real SeparateClassRequest wraps a servlet request and delegates
|
// FIDELITY (Track L.12): the real `SeparateClassRequest` wraps the
|
||||||
// getTheParameter / getTheValue through to it; the stub keeps the
|
// servlet request. `getTheParameter` / `getTheCookie` are TAINTED
|
||||||
// public surface but discards the request reference.
|
// sources (delegate to the request, which the Nyx stub firehoses with
|
||||||
|
// the payload); `getTheValue` is a SAFE source — the real helper
|
||||||
|
// returns the constant `"bar"` regardless of input, so benign fixtures
|
||||||
|
// that read through `getTheValue` must NOT receive the payload (else
|
||||||
|
// they false-confirm). Delegating to the (firehosed) request keeps the
|
||||||
|
// tainted accessors live while the constant keeps the safe one safe.
|
||||||
r#"package org.owasp.benchmark.helpers;
|
r#"package org.owasp.benchmark.helpers;
|
||||||
|
import javax.servlet.http.Cookie;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
public class SeparateClassRequest {
|
public class SeparateClassRequest {
|
||||||
public SeparateClassRequest(HttpServletRequest request) {}
|
private HttpServletRequest request;
|
||||||
public String getTheParameter(String name) { return null; }
|
public SeparateClassRequest(HttpServletRequest request) { this.request = request; }
|
||||||
public String getTheValue(String name) { return null; }
|
public String getTheParameter(String p) { return request == null ? null : request.getParameter(p); }
|
||||||
|
public String getTheCookie(String c) {
|
||||||
|
if (request == null) return "";
|
||||||
|
Cookie[] cookies = request.getCookies();
|
||||||
|
if (cookies != null) {
|
||||||
|
for (Cookie cookie : cookies) {
|
||||||
|
if (cookie.getName().equals(c)) { return cookie.getValue(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
// Safe source: the real helper hard-codes this return.
|
||||||
|
public String getTheValue(String p) { return "bar"; }
|
||||||
}
|
}
|
||||||
"#
|
"#
|
||||||
.to_owned()
|
.to_owned()
|
||||||
|
|
@ -231,10 +306,33 @@ public interface ThingInterface {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn esapi_stub() -> String {
|
fn esapi_stub() -> String {
|
||||||
|
// FIDELITY (Track L.12): `ESAPI.encoder().encodeForHTML` is a REAL
|
||||||
|
// HTML encoder in upstream ESAPI. OWASP benign fixtures (and the
|
||||||
|
// incidental `getWriter().write(ESAPI.encoder().encodeForHTML(fileName))`
|
||||||
|
// echoes in path-traversal / crypto fixtures) rely on it neutralising
|
||||||
|
// metacharacters. A no-op stub would let a firehosed `<script>` marker
|
||||||
|
// through and FALSE-CONFIRM xss on those files, so the stub escapes
|
||||||
|
// `& < > " ' /` exactly like the real encoder's HTML context.
|
||||||
r#"package org.owasp.esapi;
|
r#"package org.owasp.esapi;
|
||||||
public class ESAPI {
|
public class ESAPI {
|
||||||
private static final Encoder ENCODER = new Encoder() {
|
private static final Encoder ENCODER = new Encoder() {
|
||||||
@Override public String encodeForHTML(String s) { return s == null ? "" : s; }
|
@Override public String encodeForHTML(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
StringBuilder o = new StringBuilder(s.length() + 16);
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char ch = s.charAt(i);
|
||||||
|
switch (ch) {
|
||||||
|
case '&': o.append("&"); break;
|
||||||
|
case '<': o.append("<"); break;
|
||||||
|
case '>': o.append(">"); break;
|
||||||
|
case '"': o.append("""); break;
|
||||||
|
case '\'': o.append("'"); break;
|
||||||
|
case '/': o.append("/"); break;
|
||||||
|
default: o.append(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o.toString();
|
||||||
|
}
|
||||||
@Override public String encodeForBase64(byte[] b, boolean wrap) {
|
@Override public String encodeForBase64(byte[] b, boolean wrap) {
|
||||||
return b == null ? "" : java.util.Base64.getEncoder().encodeToString(b);
|
return b == null ? "" : java.util.Base64.getEncoder().encodeToString(b);
|
||||||
}
|
}
|
||||||
|
|
@ -255,6 +353,35 @@ public interface Encoder {
|
||||||
.to_owned()
|
.to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn string_escape_utils_stub() -> String {
|
||||||
|
// FIDELITY (Track L.12): Apache Commons Lang `StringEscapeUtils.escapeHtml`
|
||||||
|
// is a real HTML encoder used as the XSS defence in benign OWASP fixtures
|
||||||
|
// (`org.apache.commons.lang.StringEscapeUtils.escapeHtml(param)`, inline
|
||||||
|
// FQN). Without the stub javac reports the package missing → BuildFailed;
|
||||||
|
// with a faithful escape a benign escape path neutralises the marker.
|
||||||
|
r#"package org.apache.commons.lang;
|
||||||
|
public class StringEscapeUtils {
|
||||||
|
public static String escapeHtml(String s) {
|
||||||
|
if (s == null) return null;
|
||||||
|
StringBuilder o = new StringBuilder(s.length() + 16);
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char ch = s.charAt(i);
|
||||||
|
switch (ch) {
|
||||||
|
case '&': o.append("&"); break;
|
||||||
|
case '<': o.append("<"); break;
|
||||||
|
case '>': o.append(">"); break;
|
||||||
|
case '"': o.append("""); break;
|
||||||
|
default: o.append(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o.toString();
|
||||||
|
}
|
||||||
|
public static String unescapeHtml(String s) { return s; }
|
||||||
|
}
|
||||||
|
"#
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
fn data_access_exception_stub() -> String {
|
fn data_access_exception_stub() -> String {
|
||||||
r#"package org.springframework.dao;
|
r#"package org.springframework.dao;
|
||||||
public class DataAccessException extends RuntimeException {
|
public class DataAccessException extends RuntimeException {
|
||||||
|
|
@ -293,9 +420,29 @@ public interface SqlRowSet {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn html_utils_stub() -> String {
|
fn html_utils_stub() -> String {
|
||||||
|
// FIDELITY (Track L.12): Spring `HtmlUtils.htmlEscape` is a real HTML
|
||||||
|
// encoder; benign OWASP fixtures use it as the XSS defence. Escape
|
||||||
|
// faithfully so a firehosed `<script>` marker cannot survive a benign
|
||||||
|
// escape path and false-confirm.
|
||||||
r#"package org.springframework.web.util;
|
r#"package org.springframework.web.util;
|
||||||
public class HtmlUtils {
|
public class HtmlUtils {
|
||||||
public static String htmlEscape(String s) { return s == null ? "" : s; }
|
public static String htmlEscape(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
StringBuilder o = new StringBuilder(s.length() + 16);
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char ch = s.charAt(i);
|
||||||
|
switch (ch) {
|
||||||
|
case '&': o.append("&"); break;
|
||||||
|
case '<': o.append("<"); break;
|
||||||
|
case '>': o.append(">"); break;
|
||||||
|
case '"': o.append("""); break;
|
||||||
|
case '\'': o.append("'"); break;
|
||||||
|
default: o.append(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o.toString();
|
||||||
|
}
|
||||||
|
public static String htmlEscape(String s, String enc) { return htmlEscape(s); }
|
||||||
public static String htmlUnescape(String s) { return s == null ? "" : s; }
|
public static String htmlUnescape(String s) { return s == null ? "" : s; }
|
||||||
}
|
}
|
||||||
"#
|
"#
|
||||||
|
|
@ -354,6 +501,7 @@ mod tests {
|
||||||
"org/springframework/jdbc/core/RowMapper.java",
|
"org/springframework/jdbc/core/RowMapper.java",
|
||||||
"org/springframework/jdbc/support/rowset/SqlRowSet.java",
|
"org/springframework/jdbc/support/rowset/SqlRowSet.java",
|
||||||
"org/springframework/web/util/HtmlUtils.java",
|
"org/springframework/web/util/HtmlUtils.java",
|
||||||
|
"org/apache/commons/lang/StringEscapeUtils.java",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
paths.iter().any(|p| p == required),
|
paths.iter().any(|p| p == required),
|
||||||
|
|
@ -451,8 +599,8 @@ mod tests {
|
||||||
let files = owasp_stub_files();
|
let files = owasp_stub_files();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
files.len(),
|
files.len(),
|
||||||
13,
|
14,
|
||||||
"expected 9 owasp + 4 springframework stubs"
|
"expected 9 owasp + 4 springframework + 1 commons-lang stub"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -165,23 +165,45 @@ import {pkg}.ServletInputStream;
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.StringReader;
|
import java.io.StringReader;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Enumeration;
|
import java.util.Enumeration;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
public class HttpServletRequest {{
|
public class HttpServletRequest {{
|
||||||
private final Map<String, String> params = new HashMap<>();
|
private final Map<String, String> params = new HashMap<>();
|
||||||
private String method = "GET";
|
private String method = "GET";
|
||||||
private String body = "";
|
private String body = "";
|
||||||
|
// ── Nyx taint-firehose seeding (Track L.12) ──────────────────────────────
|
||||||
|
// The dynamic verifier drives the real servlet `doPost`/`doGet` with a
|
||||||
|
// malicious request. Real OWASP-shape servlets read the tainted slot
|
||||||
|
// through one of many accessors (`getParameter`, `getHeader`,
|
||||||
|
// `getHeaders`, `getCookies`, `getQueryString`, `getReader`,
|
||||||
|
// `getInputStream`) keyed on a name the harness does not know a priori.
|
||||||
|
// Rather than guess the slot, the stub returns the seeded payload from
|
||||||
|
// EVERY source accessor (the "firehose"): whichever accessor/name the
|
||||||
|
// servlet reads, it receives the payload. This is sound — the servlet's
|
||||||
|
// OWN sanitiser / branch logic still runs on the firehosed value, so a
|
||||||
|
// benign fixture that neutralises the input (constant-overwrite, real
|
||||||
|
// escaper, validator) still produces no marker; only an unsanitised flow
|
||||||
|
// reaches the sink with the live payload. Cookie slots are name-matched
|
||||||
|
// (`cookie.getName().equals("X")`), so the harness seeds the candidate
|
||||||
|
// names it extracted from the servlet source into `nyxCookieNames`.
|
||||||
|
private String nyxTaint = null;
|
||||||
|
private String[] nyxCookieNames = new String[0];
|
||||||
public HttpServletRequest() {{}}
|
public HttpServletRequest() {{}}
|
||||||
public void setParameter(String name, String value) {{ params.put(name, value); }}
|
public void setParameter(String name, String value) {{ params.put(name, value); }}
|
||||||
public void setMethod(String m) {{ this.method = m; }}
|
public void setMethod(String m) {{ this.method = m; }}
|
||||||
public void setBody(String b) {{ this.body = b == null ? "" : b; }}
|
public void setBody(String b) {{ this.body = b == null ? "" : b; if (b != null && nyxTaint == null) nyxTaint = b; }}
|
||||||
|
public void nyxSeedTaint(String v) {{ this.nyxTaint = v; if (v != null) this.body = v; }}
|
||||||
|
public void nyxSeedCookieNames(String[] names) {{ if (names != null) this.nyxCookieNames = names; }}
|
||||||
public String getBody() {{ return body; }}
|
public String getBody() {{ return body; }}
|
||||||
public String getParameter(String name) {{ return params.get(name); }}
|
public String getParameter(String name) {{ String v = params.get(name); return v != null ? v : nyxTaint; }}
|
||||||
public String[] getParameterValues(String name) {{
|
public String[] getParameterValues(String name) {{
|
||||||
String v = params.get(name);
|
String v = params.get(name);
|
||||||
|
if (v == null) v = nyxTaint;
|
||||||
return v == null ? null : new String[] {{ v }};
|
return v == null ? null : new String[] {{ v }};
|
||||||
}}
|
}}
|
||||||
public Map<String, String[]> getParameterMap() {{
|
public Map<String, String[]> getParameterMap() {{
|
||||||
|
|
@ -192,18 +214,37 @@ public class HttpServletRequest {{
|
||||||
return m;
|
return m;
|
||||||
}}
|
}}
|
||||||
public Enumeration<String> getParameterNames() {{ return Collections.enumeration(params.keySet()); }}
|
public Enumeration<String> getParameterNames() {{ return Collections.enumeration(params.keySet()); }}
|
||||||
public String getHeader(String name) {{ return null; }}
|
public String getHeader(String name) {{ return nyxTaint; }}
|
||||||
public Enumeration<String> getHeaders(String name) {{ return Collections.emptyEnumeration(); }}
|
public Enumeration<String> getHeaders(String name) {{
|
||||||
|
return nyxTaint == null
|
||||||
|
? Collections.<String>emptyEnumeration()
|
||||||
|
: Collections.enumeration(Collections.singletonList(nyxTaint));
|
||||||
|
}}
|
||||||
public Enumeration<String> getHeaderNames() {{ return Collections.emptyEnumeration(); }}
|
public Enumeration<String> getHeaderNames() {{ return Collections.emptyEnumeration(); }}
|
||||||
public int getIntHeader(String name) {{ return -1; }}
|
public int getIntHeader(String name) {{ return -1; }}
|
||||||
public long getDateHeader(String name) {{ return -1L; }}
|
public long getDateHeader(String name) {{ return -1L; }}
|
||||||
public Cookie[] getCookies() {{ return null; }}
|
public Cookie[] getCookies() {{
|
||||||
|
if (nyxTaint == null) return null;
|
||||||
|
List<Cookie> cs = new ArrayList<>();
|
||||||
|
for (String n : nyxCookieNames) {{ if (n != null) cs.add(new Cookie(n, nyxTaint)); }}
|
||||||
|
if (cs.isEmpty()) cs.add(new Cookie("vector", nyxTaint));
|
||||||
|
return cs.toArray(new Cookie[0]);
|
||||||
|
}}
|
||||||
public HttpSession getSession() {{ return new HttpSession(); }}
|
public HttpSession getSession() {{ return new HttpSession(); }}
|
||||||
public HttpSession getSession(boolean create) {{ return new HttpSession(); }}
|
public HttpSession getSession(boolean create) {{ return new HttpSession(); }}
|
||||||
public ServletInputStream getInputStream() throws IOException {{ return null; }}
|
public ServletInputStream getInputStream() throws IOException {{
|
||||||
public BufferedReader getReader() throws IOException {{ return new BufferedReader(new StringReader(body)); }}
|
final byte[] data = (nyxTaint != null ? nyxTaint : body)
|
||||||
|
.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
return new ServletInputStream() {{
|
||||||
|
private int pos = 0;
|
||||||
|
@Override public int read() throws IOException {{ return pos < data.length ? (data[pos++] & 0xff) : -1; }}
|
||||||
|
}};
|
||||||
|
}}
|
||||||
|
public BufferedReader getReader() throws IOException {{
|
||||||
|
return new BufferedReader(new StringReader(nyxTaint != null ? nyxTaint : body));
|
||||||
|
}}
|
||||||
public String getMethod() {{ return method; }}
|
public String getMethod() {{ return method; }}
|
||||||
public String getQueryString() {{ return null; }}
|
public String getQueryString() {{ return nyxTaint; }}
|
||||||
public StringBuffer getRequestURL() {{ return new StringBuffer(); }}
|
public StringBuffer getRequestURL() {{ return new StringBuffer(); }}
|
||||||
public String getRequestURI() {{ return ""; }}
|
public String getRequestURI() {{ return ""; }}
|
||||||
public String getRemoteAddr() {{ return "127.0.0.1"; }}
|
public String getRemoteAddr() {{ return "127.0.0.1"; }}
|
||||||
|
|
|
||||||
|
|
@ -1700,7 +1700,7 @@ fn cap_for_rule_category(category: &str) -> Option<Cap> {
|
||||||
/// `os.system("echo " + shlex.quote(x))` control no longer false-confirms).
|
/// `os.system("echo " + shlex.quote(x))` control no longer false-confirms).
|
||||||
/// Other set bits are preserved so a multi-cap sink keeps its other
|
/// Other set bits are preserved so a multi-cap sink keeps its other
|
||||||
/// (already-driveable) capabilities.
|
/// (already-driveable) capabilities.
|
||||||
fn drivable_expected_cap(cap: Cap) -> Cap {
|
pub(crate) fn drivable_expected_cap(cap: Cap) -> Cap {
|
||||||
if cap.contains(Cap::SHELL_ESCAPE) {
|
if cap.contains(Cap::SHELL_ESCAPE) {
|
||||||
(cap - Cap::SHELL_ESCAPE) | Cap::CODE_EXEC
|
(cap - Cap::SHELL_ESCAPE) | Cap::CODE_EXEC
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ pub const NYX_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
/// [`crate::dynamic::corpus::CORPUS_VERSION`]; the compile-time assertion
|
/// [`crate::dynamic::corpus::CORPUS_VERSION`]; the compile-time assertion
|
||||||
/// below + the [`corpus_version_const_matches_corpus_module`] runtime test
|
/// below + the [`corpus_version_const_matches_corpus_module`] runtime test
|
||||||
/// jointly guard drift.
|
/// jointly guard drift.
|
||||||
pub const CORPUS_VERSION: &str = "16";
|
pub const CORPUS_VERSION: &str = "17";
|
||||||
|
|
||||||
/// Compile-time guard that pins [`CORPUS_VERSION`] (this module) to the
|
/// Compile-time guard that pins [`CORPUS_VERSION`] (this module) to the
|
||||||
/// textual form of [`crate::dynamic::corpus::CORPUS_VERSION`]. Bumping the
|
/// textual form of [`crate::dynamic::corpus::CORPUS_VERSION`]. Bumping the
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue