From cfb240281c9e6a345f077995f9f0185994a15494 Mon Sep 17 00:00:00 2001 From: pitboss Date: Fri, 22 May 2026 08:28:25 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0027 (20260522T043516Z-29b8) --- src/dynamic/corpus/header_injection/java.rs | 59 +++ src/dynamic/lang/java.rs | 430 +++++++++++++++++- .../header_injection/java_raw/Vuln.java | 86 ++++ tests/header_injection_corpus.rs | 95 ++++ 4 files changed, 669 insertions(+), 1 deletion(-) create mode 100644 tests/dynamic_fixtures/header_injection/java_raw/Vuln.java diff --git a/src/dynamic/corpus/header_injection/java.rs b/src/dynamic/corpus/header_injection/java.rs index 96de1661..9d8f559d 100644 --- a/src/dynamic/corpus/header_injection/java.rs +++ b/src/dynamic/corpus/header_injection/java.rs @@ -60,4 +60,63 @@ pub const PAYLOADS: &[CuratedPayload] = &[ benign_control: None, no_benign_control_rationale: None, }, + // Phase 08 tier-(b): raw-socket wire-frame smuggling payload. + // Same CRLF-bearing bytes as the servlet payload above, but pinned + // to the `java_raw` fixture (a `java.net.ServerSocket` driven by + // `createServer` + `runOnce` that writes raw bytes via + // `OutputStream.write(byte[])`). The wire frame captured off the + // response socket carries two distinct `Set-Cookie:` lines, so + // `HeaderSmuggledInWire { primary: "Set-Cookie", smuggled: + // "Set-Cookie" }` fires — proving the smuggled header survived + // to the actual wire instead of being CRLF-stripped en route by + // Tomcat / Jetty / Undertow. + // + // Distinct payload (not just an extra predicate on the servlet + // row) because every modern Java servlet container response + // serializer strips CRLF at the wire-write boundary, so the + // wire-frame predicate would never fire against the canonical + // servlet fixture. + CuratedPayload { + bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn", + label: "header-injection-java-raw-wire-smuggle", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::HeaderSmuggledInWire { + primary: "Set-Cookie", + smuggled: "Set-Cookie", + }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 12, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/header_injection/java_raw/Vuln.java"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::HeaderSmuggledInWire { + primary: "Set-Cookie", + smuggled: "Set-Cookie", + }], + benign_control: Some(PayloadRef { + label: "header-injection-java-raw-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn", + label: "header-injection-java-raw-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::HeaderSmuggledInWire { + primary: "Set-Cookie", + smuggled: "Set-Cookie", + }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 12, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/header_injection/java_raw/Vuln.java"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, ]; diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 286a9d28..bf7ea8da 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -1474,9 +1474,12 @@ public class NyxHarness {{ /// `ProbeKind::HeaderEmit` probe. Mirrors the synthetic-harness /// pattern used by Phase 03 / 04 / 05 / 06 / 07. pub fn emit_header_injection_harness(spec: &HarnessSpec) -> HarnessSource { + let entry_source = read_entry_source(&spec.entry_file); + if entry_source_uses_raw_socket(&entry_source) { + return emit_header_injection_wire_frame_harness(spec, &entry_source); + } let shim = probe_shim(); let extra_files = servlet_stubs_for_entry(&spec.entry_file); - let entry_source = read_entry_source(&spec.entry_file); let servlet_pkg = if entry_source.contains("jakarta.servlet") { "jakarta.servlet.http" } else { @@ -1612,6 +1615,311 @@ public class NyxHarness {{ } } +/// Phase 08 tier-(b) gate: route to the wire-frame harness when the +/// entry file exposes the raw-socket fixture API (`createServer` + +/// `runOnce` + `setCookieValue`) driven by `java.net.ServerSocket`. +/// The triple-token check keeps the gate firing only on the curated +/// `java_raw` fixture shape and never on the canonical +/// `HttpServletResponse.setHeader` fixture above. +fn entry_source_uses_raw_socket(src: &str) -> bool { + src.contains("java.net.ServerSocket") && src.contains("setCookieValue") +} + +/// Phase 08 — Track J.6 tier-(b) wire-frame harness for Java. +/// Drives the fixture's `createServer` / `runOnce` API on a worker +/// thread while the harness opens a client `java.net.Socket` against +/// the bound port, issues one `GET / HTTP/1.0`, and reads the bytes +/// the fixture wrote to the response socket up to the `\r\n\r\n` +/// boundary. The captured header block is emitted as a +/// `ProbeKind::HeaderWireFrame` probe; per-`Set-Cookie` lines are +/// also emitted as `ProbeKind::HeaderEmit` records so the tier-(a) +/// `HeaderInjected` predicate fires on the same pass. Prints a +/// `wire_frame_len` stdout marker so e2e tests can pin the branch. +/// +/// Reflective dispatch via `Class.forName(entry_fqn) +/// .getDeclaredMethod("setCookieValue", byte[].class)` etc. mirrors +/// the Phase 06 LDAP Java tier-(b) pattern. Avoids any external +/// jar bundling — only `java.net.*` + `java.io.*` (JDK built-ins). +fn emit_header_injection_wire_frame_harness( + _spec: &HarnessSpec, + entry_source: &str, +) -> HarnessSource { + let shim = probe_shim(); + let entry_class = derive_entry_class(entry_source); + let entry_fqn = derive_entry_qualifier(entry_source, &entry_class); + let source = format!( + r#"// Nyx dynamic harness — HEADER_INJECTION raw-socket wire frame (Phase 08 / Track J.6). +import java.io.ByteArrayOutputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; + +public class NyxHarness {{ +{shim} + + static void nyxWireFrameHeaderProbe(String name, String value) {{ + String p = System.getenv("NYX_PROBE_PATH"); + if (p == null || p.isEmpty()) return; + long now = System.nanoTime(); + String pid = System.getenv("NYX_PAYLOAD_ID"); + if (pid == null) pid = ""; + StringBuilder line = new StringBuilder(256); + line.append("{{\"sink_callee\":\"Socket.getOutputStream().write\",\"args\":["); + line.append("{{\"kind\":\"String\",\"value\":\""); + nyxJsonEscape(name, line); + line.append("\"}},{{\"kind\":\"String\",\"value\":\""); + nyxJsonEscape(value, line); + line.append("\"}}],"); + line.append("\"captured_at_ns\":").append(now).append(','); + line.append("\"payload_id\":\""); + nyxJsonEscape(pid, line); + line.append("\",\"kind\":{{\"kind\":\"HeaderEmit\",\"name\":\""); + nyxJsonEscape(name, line); + line.append("\",\"value\":\""); + nyxJsonEscape(value, line); + line.append("\",\"protocol\":\"wire\"}},"); + line.append("\"witness\":"); + line.append(nyxWitnessJson("Socket.getOutputStream().write", new String[]{{name, value}})); + line.append("}}\n"); + try (FileWriter fw = new FileWriter(p, true)) {{ + fw.write(line.toString()); + }} catch (IOException e) {{ + // best-effort + }} + }} + + static void nyxWireFrameProbe(byte[] rawBytes) {{ + String p = System.getenv("NYX_PROBE_PATH"); + if (p == null || p.isEmpty()) return; + long now = System.nanoTime(); + String pid = System.getenv("NYX_PAYLOAD_ID"); + if (pid == null) pid = ""; + StringBuilder line = new StringBuilder(256 + rawBytes.length * 4); + line.append("{{\"sink_callee\":\"Socket.getOutputStream().write\",\"args\":[],"); + line.append("\"captured_at_ns\":").append(now).append(','); + line.append("\"payload_id\":\""); + nyxJsonEscape(pid, line); + line.append("\",\"kind\":{{\"kind\":\"HeaderWireFrame\",\"raw_bytes\":["); + for (int i = 0; i < rawBytes.length; i++) {{ + if (i > 0) line.append(','); + line.append(((int) rawBytes[i]) & 0xff); + }} + line.append("]}},"); + line.append("\"witness\":"); + line.append(nyxWitnessJson("Socket.getOutputStream().write", new String[0])); + line.append("}}\n"); + try (FileWriter fw = new FileWriter(p, true)) {{ + fw.write(line.toString()); + }} catch (IOException e) {{ + // best-effort + }} + }} + + // Phase 08 tier-(b): install the cookie value on the fixture, + // boot its `ServerSocket` on 127.0.0.1:0, drive `runOnce` on a + // worker thread, then issue one raw-socket GET from the harness + // and read the bytes the fixture wrote to the response socket up + // to the CRLF-CRLF boundary. Returns `null` on reflection / boot + // / read failure so the caller can fall back to the synthetic + // probe path and keep the differential oracle live. + static byte[] nyxWireFrameViaFixture(String payload) {{ + Class entry; + try {{ + entry = Class.forName("{entry_fqn}"); + }} catch (ClassNotFoundException e) {{ + return null; + }} + byte[] payloadBytes = payload.getBytes(StandardCharsets.ISO_8859_1); + Method setCookie; + Method createServer; + Method runOnce; + try {{ + setCookie = entry.getDeclaredMethod("setCookieValue", byte[].class); + setCookie.setAccessible(true); + createServer = entry.getDeclaredMethod("createServer"); + createServer.setAccessible(true); + runOnce = entry.getDeclaredMethod("runOnce", ServerSocket.class); + runOnce.setAccessible(true); + }} catch (NoSuchMethodException e) {{ + return null; + }} + try {{ + setCookie.invoke(null, (Object) payloadBytes); + }} catch (IllegalAccessException | InvocationTargetException e) {{ + return null; + }} + ServerSocket server; + try {{ + Object srv = createServer.invoke(null); + if (!(srv instanceof ServerSocket)) {{ + return null; + }} + server = (ServerSocket) srv; + }} catch (IllegalAccessException | InvocationTargetException e) {{ + return null; + }} + final ServerSocket serverFinal = server; + final Method runOnceFinal = runOnce; + Thread worker = new Thread(() -> {{ + try {{ + runOnceFinal.invoke(null, serverFinal); + }} catch (IllegalAccessException | InvocationTargetException ignored) {{ + // ignore fixture errors so the harness can still capture + // whatever bytes were already written before the throw. + }} + }}, "nyx-wire-frame-worker"); + worker.setDaemon(true); + worker.start(); + int port = server.getLocalPort(); + ByteArrayOutputStream raw = new ByteArrayOutputStream(4096); + Socket client = null; + try {{ + client = new Socket(InetAddress.getByName("127.0.0.1"), port); + client.setSoTimeout(2000); + OutputStream out = client.getOutputStream(); + out.write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n" + .getBytes(StandardCharsets.ISO_8859_1)); + out.flush(); + InputStream in = client.getInputStream(); + byte[] buf = new byte[4096]; + long deadline = System.currentTimeMillis() + 5000; + while (raw.size() < 65536 && System.currentTimeMillis() < deadline) {{ + int read; + try {{ + read = in.read(buf, 0, buf.length); + }} catch (java.net.SocketTimeoutException te) {{ + break; + }} catch (IOException ioe) {{ + break; + }} + if (read < 0) {{ + break; + }} + raw.write(buf, 0, read); + if (nyxContainsCrlfCrlf(raw.toByteArray())) {{ + break; + }} + }} + }} catch (IOException ioe) {{ + // boot / connect / read failed — surface null so the caller + // takes the synthetic fallback path. + try {{ worker.interrupt(); }} catch (Exception ignored) {{}} + try {{ server.close(); }} catch (IOException ignored) {{}} + return null; + }} finally {{ + if (client != null) {{ + try {{ client.close(); }} catch (IOException ignored) {{}} + }} + try {{ worker.join(2000); }} catch (InterruptedException ignored) {{}} + try {{ server.close(); }} catch (IOException ignored) {{}} + }} + byte[] rawBytes = raw.toByteArray(); + int sep = nyxIndexCrlfCrlf(rawBytes); + if (sep < 0) {{ + return rawBytes; + }} + byte[] head = new byte[sep]; + System.arraycopy(rawBytes, 0, head, 0, sep); + return head; + }} + + private static boolean nyxContainsCrlfCrlf(byte[] buf) {{ + return nyxIndexCrlfCrlf(buf) >= 0; + }} + + private static int nyxIndexCrlfCrlf(byte[] buf) {{ + for (int i = 0; i + 3 < buf.length; i++) {{ + if (buf[i] == 0x0d && buf[i + 1] == 0x0a + && buf[i + 2] == 0x0d && buf[i + 3] == 0x0a) {{ + return i; + }} + }} + return -1; + }} + + // Derive `Set-Cookie:` HeaderEmit records from the raw wire-frame + // bytes so the tier-(a) `HeaderInjected` predicate fires on the + // same harness pass. The wire-frame branch owns the bytes; the + // HeaderEmit records are derived from them. + private static void nyxEmitSetCookieHeaderProbes(byte[] rawBytes) {{ + int start = 0; + for (int i = 0; i < rawBytes.length; i++) {{ + if (rawBytes[i] == 0x0a) {{ + int end = i; + if (end > start && rawBytes[end - 1] == 0x0d) {{ + end--; + }} + nyxMaybeEmitSetCookieLine(rawBytes, start, end); + start = i + 1; + }} + }} + if (start < rawBytes.length) {{ + nyxMaybeEmitSetCookieLine(rawBytes, start, rawBytes.length); + }} + }} + + private static void nyxMaybeEmitSetCookieLine(byte[] rawBytes, int start, int end) {{ + if (end <= start) return; + int colon = -1; + for (int i = start; i < end; i++) {{ + if (rawBytes[i] == 0x3a) {{ + colon = i; + break; + }} + }} + if (colon < 0) return; + String name = new String(rawBytes, start, colon - start, StandardCharsets.ISO_8859_1); + if (!name.equalsIgnoreCase("Set-Cookie")) return; + int valueStart = colon + 1; + if (valueStart < end && rawBytes[valueStart] == 0x20) {{ + valueStart++; + }} + String value = new String(rawBytes, valueStart, end - valueStart, StandardCharsets.ISO_8859_1); + nyxWireFrameHeaderProbe(name, value); + }} + + public static void main(String[] args) {{ + String payload = System.getenv("NYX_PAYLOAD"); + if (payload == null) payload = ""; + byte[] rawBytes = nyxWireFrameViaFixture(payload); + if (rawBytes != null) {{ + nyxWireFrameProbe(rawBytes); + nyxEmitSetCookieHeaderProbes(rawBytes); + System.out.println("__NYX_SINK_HIT__"); + System.out.println("{{\"wire_frame_len\":" + rawBytes.length + "}}"); + return; + }} + // Synthetic fallback when the fixture failed to boot — keeps + // the differential oracle live on a build/boot failure rather + // than silently shedding the attempt. + nyxWireFrameHeaderProbe("Set-Cookie", payload); + System.out.println("__NYX_SINK_HIT__"); + System.out.println("{{\"payload_len\":" + payload.getBytes(StandardCharsets.UTF_8).length + "}}"); + }} +}} +"# + ); + HarnessSource { + source, + filename: "NyxHarness.java".to_owned(), + command: vec![ + "java".to_owned(), + "-cp".to_owned(), + ".".to_owned(), + "NyxHarness".to_owned(), + ], + extra_files: Vec::new(), + entry_subpath: None, + } +} + /// Phase 09 — Track J.7 open-redirect harness for Java /// (`HttpServletResponse.sendRedirect`). /// @@ -3558,6 +3866,126 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn emit_header_injection_harness_routes_through_wire_frame_when_raw_socket_imported() { + let dir = std::env::temp_dir().join("nyx_phase08_java_test_wire_frame"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = write_servlet_fixture( + &dir, + "import java.net.ServerSocket;\n\ + public class Vuln {\n \ + public static void setCookieValue(byte[] value) {}\n \ + public static ServerSocket createServer() throws java.io.IOException { return new ServerSocket(0); }\n \ + public static void runOnce(ServerSocket server) {}\n\ + }\n", + ); + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::HEADER_INJECTION; + spec.entry_file = entry; + spec.entry_name = "run".into(); + let h = emit_header_injection_harness(&spec); + assert!( + h.extra_files.is_empty(), + "tier-(b) wire-frame harness must not ship servlet stubs: {:?}", + h.extra_files, + ); + assert!( + h.source.contains("static byte[] nyxWireFrameViaFixture(String payload)"), + "tier-(b) harness must define the wire-frame helper: {}", + h.source + ); + assert!( + h.source.contains("Class.forName(\"Vuln\")"), + "tier-(b) harness must reflectively load the fixture entry class: {}", + h.source + ); + assert!( + h.source.contains("getDeclaredMethod(\"setCookieValue\", byte[].class)"), + "tier-(b) harness must install the cookie value via reflection: {}", + h.source + ); + assert!( + h.source.contains("getDeclaredMethod(\"createServer\")"), + "tier-(b) harness must boot the fixture's ServerSocket via reflection: {}", + h.source + ); + assert!( + h.source.contains("getDeclaredMethod(\"runOnce\", ServerSocket.class)"), + "tier-(b) harness must drive runOnce on a worker thread: {}", + h.source + ); + assert!( + h.source.contains("new Thread(()"), + "tier-(b) harness must spawn a worker thread for the accept loop: {}", + h.source + ); + assert!( + h.source.contains("new Socket(InetAddress.getByName(\"127.0.0.1\"), port)"), + "tier-(b) harness must open a client Socket against the bound port: {}", + h.source + ); + assert!( + h.source.contains("GET / HTTP/1.0\\r\\nHost: 127.0.0.1"), + "tier-(b) harness must issue a raw GET request: {}", + h.source + ); + assert!( + h.source.contains("\\\"kind\\\":\\\"HeaderWireFrame\\\""), + "tier-(b) harness must emit a HeaderWireFrame probe kind: {}", + h.source + ); + assert!( + h.source.contains("\\\"raw_bytes\\\":["), + "tier-(b) harness must carry the raw_bytes array on the wire-frame probe: {}", + h.source + ); + assert!( + h.source.contains("\"{\\\"wire_frame_len\\\":\" + rawBytes.length"), + "tier-(b) harness must emit the wire_frame_len stdout marker: {}", + h.source + ); + assert!( + !h.source.contains("nyxDrainHeaders()"), + "tier-(b) harness must not invoke the servlet-stub drain path: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn emit_header_injection_harness_wire_frame_branch_drops_when_only_servlet_imported() { + let dir = std::env::temp_dir().join("nyx_phase08_java_test_no_wire_frame"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = write_servlet_fixture( + &dir, + "import javax.servlet.http.HttpServletResponse;\n\ + public class Vuln {\n public static void run(HttpServletResponse r, String v) {\n r.setHeader(\"Set-Cookie\", v);\n }\n}\n", + ); + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::HEADER_INJECTION; + spec.entry_file = entry; + spec.entry_name = "run".into(); + let h = emit_header_injection_harness(&spec); + assert!( + !h.source.contains("nyxWireFrameViaFixture"), + "servlet-only harness must not define the wire-frame helper: {}", + h.source + ); + assert!( + !h.source.contains("HeaderWireFrame"), + "servlet-only harness must not emit the HeaderWireFrame probe shape: {}", + h.source + ); + assert!( + !h.source.contains("wire_frame_len"), + "servlet-only harness must not emit the wire_frame_len stdout marker: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn emit_open_redirect_harness_drives_fixture_through_stub_when_servlet_present() { let dir = std::env::temp_dir().join("nyx_phase09_test_drive_fixture"); diff --git a/tests/dynamic_fixtures/header_injection/java_raw/Vuln.java b/tests/dynamic_fixtures/header_injection/java_raw/Vuln.java new file mode 100644 index 00000000..bdfa5da5 --- /dev/null +++ b/tests/dynamic_fixtures/header_injection/java_raw/Vuln.java @@ -0,0 +1,86 @@ +// Phase 08 (Track J.6) — Java raw-socket HEADER_INJECTION vuln fixture. +// +// Writes the response status line and headers directly to the wire via +// `OutputStream.write(byte[])` against the `java.net.Socket` returned +// by `ServerSocket.accept()`, bypassing the framework-level CRLF +// validator that Tomcat / Jetty / Undertow would otherwise interpose +// on `HttpServletResponse.setHeader`. A payload carrying +// `\r\nSet-Cookie: ...` splits the single Set-Cookie header into two +// on the wire, producing the canonical smuggled-second-header shape +// that `ProbeKind::HeaderWireFrame` is designed to catch. +// +// The harness (`src/dynamic/lang/java.rs::emit_header_injection_harness`) +// detects the `java.net.ServerSocket` + `setCookieValue` tokens in +// this file and routes through the tier-(b) wire-frame branch: bind +// a loopback `ServerSocket` via `createServer`, accept one client +// (`runOnce`) on a worker thread, issue one raw-socket +// `GET / HTTP/1.0` from the harness, read the bytes the fixture +// wrote to the response socket up to the CRLF-CRLF boundary, and +// emit them as a `ProbeKind::HeaderWireFrame` record. +// +// All three entry points are `public static` so the harness can +// resolve them via `Class.forName("Vuln").getDeclaredMethod(...)` +// reflective dispatch (same pattern as Phase 06 LDAP Java tier-(b)). +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; + +public class Vuln { + // Bytes go straight onto the wire with no encoding pass. The + // harness installs the cookie value before booting the accept + // loop, mirroring the Python `Handler.cookie_value` and Ruby + // `set_cookie_value` setters. + private static byte[] nyxCookieValue = new byte[0]; + + public static void setCookieValue(byte[] value) { + nyxCookieValue = (value == null) ? new byte[0] : value; + } + + public static ServerSocket createServer() throws IOException { + return new ServerSocket(0, 1, java.net.InetAddress.getByName("127.0.0.1")); + } + + public static void runOnce(ServerSocket server) { + Socket client = null; + try { + server.setSoTimeout(5000); + client = server.accept(); + client.setSoTimeout(1000); + // Drain whatever request bytes the client sent so the + // kernel does not stall the write that follows. Ignore + // read errors — the client may have already shut its + // write side. + try { + InputStream in = client.getInputStream(); + byte[] buf = new byte[4096]; + int read = in.read(buf, 0, buf.length); + // discard + if (read < 0) { + // EOF, nothing to drain + } + } catch (IOException ignored) { + // ignore drain errors + } + byte[] body = "ok\n".getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); + java.io.ByteArrayOutputStream raw = new java.io.ByteArrayOutputStream(); + raw.write("HTTP/1.0 200 OK\r\n".getBytes(java.nio.charset.StandardCharsets.ISO_8859_1)); + raw.write(("Content-Length: " + body.length + "\r\n") + .getBytes(java.nio.charset.StandardCharsets.ISO_8859_1)); + raw.write("Set-Cookie: ".getBytes(java.nio.charset.StandardCharsets.ISO_8859_1)); + raw.write(nyxCookieValue); + raw.write("\r\n\r\n".getBytes(java.nio.charset.StandardCharsets.ISO_8859_1)); + raw.write(body); + OutputStream out = client.getOutputStream(); + out.write(raw.toByteArray()); + out.flush(); + } catch (IOException e) { + // ignore — harness will time out reading and fall back + } finally { + if (client != null) { + try { client.close(); } catch (IOException ignored) {} + } + } + } +} diff --git a/tests/header_injection_corpus.rs b/tests/header_injection_corpus.rs index d0676c7c..13229bd5 100644 --- a/tests/header_injection_corpus.rs +++ b/tests/header_injection_corpus.rs @@ -1101,4 +1101,99 @@ mod e2e_phase_08 { .collect::>(), ); } + + // Phase 08 tier-(b): Java raw-socket wire-frame fixture. + // `tests/dynamic_fixtures/header_injection/java_raw/Vuln.java` + // binds a `java.net.ServerSocket` via `createServer` whose + // `runOnce` handler writes raw bytes via + // `Socket.getOutputStream().write(byte[])`, bypassing Tomcat / + // Jetty / Undertow's CRLF strip on `HttpServletResponse.setHeader`. + // The harness boots the server on a loopback port via reflective + // dispatch (`Class.forName("Vuln").getDeclaredMethod(...)`), opens + // a client `java.net.Socket`, reads the response-header block off + // the socket, and emits a `ProbeKind::HeaderWireFrame` record. + // Asserts the test exercises the wire-frame branch (not the + // synthetic fallback) by pinning `wire_frame_len` in the captured + // stdout — that literal only appears in the tier-(b) write path. + fn build_java_raw_spec(entry_name: &str) -> (HarnessSpec, TempDir) { + let fixture_src = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/dynamic_fixtures/header_injection/java_raw/Vuln.java"); + let tmp = TempDir::new().expect("create tempdir"); + let dst = tmp.path().join("Vuln.java"); + std::fs::copy(&fixture_src, &dst).expect("copy java_raw fixture into tempdir"); + let entry_file = dst.to_string_lossy().into_owned(); + let mut digest = blake3::Hasher::new(); + digest.update(b"phase08-e2e-header-injection|java_raw|Vuln.java"); + let spec_hash = format!("{:016x}", { + let bytes = digest.finalize(); + u64::from_le_bytes(bytes.as_bytes()[..8].try_into().unwrap()) + }); + // Mirror the Java workdir wipe used by build_spec — javac caches + // compiled bytecode under the shared workdir at + // `/tmp/nyx-harness/`, so a previous run with a + // different harness source can serve stale class files. + let workdir = std::path::PathBuf::from("/tmp/nyx-harness").join(&spec_hash); + let _ = std::fs::remove_dir_all(&workdir); + let spec = HarnessSpec { + finding_id: spec_hash.clone(), + entry_file: entry_file.clone(), + entry_name: entry_name.to_owned(), + entry_kind: EntryKind::Function, + lang: Lang::Java, + toolchain_id: default_toolchain_id(Lang::Java).into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::HEADER_INJECTION, + constraint_hints: vec![], + sink_file: entry_file, + sink_line: 1, + spec_hash: spec_hash.clone(), + derivation: SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), + }; + (spec, tmp) + } + + #[test] + fn java_raw_socket_vuln_confirms_via_wire_frame_probe() { + if !command_available("javac") { + eprintln!("SKIP java_raw: missing javac"); + return; + } + if !command_available("java") { + eprintln!("SKIP java_raw: missing java"); + return; + } + let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (spec, _tmp) = build_java_raw_spec("run"); + let opts = SandboxOptions { + backend: SandboxBackend::Process, + ..SandboxOptions::default() + }; + let outcome = match run_spec(&spec, &opts) { + Ok(outcome) => outcome, + Err(RunError::BuildFailed { stderr, attempts }) => { + eprintln!( + "SKIP java_raw: harness build failed after {attempts} attempts: {stderr}", + ); + return; + } + Err(e) => panic!("run_spec(java_raw) errored: {e:?}"), + }; + assert_confirmed(Lang::Java, &outcome); + let any_wire_frame_marker = outcome.attempts.iter().any(|a| { + String::from_utf8_lossy(&a.outcome.stdout).contains("wire_frame_len") + }); + assert!( + any_wire_frame_marker, + "java_raw fixture must exercise the tier-(b) wire-frame harness branch; \ + expected `wire_frame_len` substring in at least one attempt's stdout, got attempts={:?}", + outcome + .attempts + .iter() + .map(|a| String::from_utf8_lossy(&a.outcome.stdout).into_owned()) + .collect::>(), + ); + } }