From 853fd281c5a2a78159ea1fd2896f505375a30ce8 Mon Sep 17 00:00:00 2001 From: pitboss Date: Fri, 22 May 2026 07:54:57 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0025 (20260522T043516Z-29b8) --- src/dynamic/corpus/header_injection/ruby.rs | 57 ++++ src/dynamic/lang/ruby.rs | 322 +++++++++++++++++- .../header_injection/ruby_raw/vuln.rb | 54 +++ tests/header_injection_corpus.rs | 83 +++++ 4 files changed, 508 insertions(+), 8 deletions(-) create mode 100644 tests/dynamic_fixtures/header_injection/ruby_raw/vuln.rb diff --git a/src/dynamic/corpus/header_injection/ruby.rs b/src/dynamic/corpus/header_injection/ruby.rs index 42dac2a8..905df991 100644 --- a/src/dynamic/corpus/header_injection/ruby.rs +++ b/src/dynamic/corpus/header_injection/ruby.rs @@ -54,4 +54,61 @@ 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 Rack payload above, but pinned to + // the `ruby_raw` fixture (a `TCPServer` driven by `create_server` + // + `run_once` that writes raw bytes via `TCPSocket#write`). 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. + // + // Distinct payload (not just an extra predicate on the Rack row) + // because Rack / Sinatra / Rails response serializers strip CRLF + // at the wire-write boundary, so the wire-frame predicate would + // never fire against the canonical Rack fixture. + CuratedPayload { + bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn", + label: "header-injection-ruby-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/ruby_raw/vuln.rb"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::HeaderSmuggledInWire { + primary: "Set-Cookie", + smuggled: "Set-Cookie", + }], + benign_control: Some(PayloadRef { + label: "header-injection-ruby-raw-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn", + label: "header-injection-ruby-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/ruby_raw/vuln.rb"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, ]; diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index 348dc7d4..540215e3 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -1100,16 +1100,29 @@ STDOUT.flush /// Phase 08 — Track J.6 header-injection harness for Ruby /// (`Rack::Response#set_header`). /// -/// When the fixture imports `rack` the tier-(a) path imports the -/// fixture, prepends a permissive captor module onto `Rack::Response` -/// that records every `set_header(name, value)` call verbatim, and -/// invokes the named entry function with the payload. Otherwise the -/// synthetic fallback emits a single `HeaderEmit` probe with the -/// payload bytes pre-bound to `Set-Cookie`, matching the prior -/// behaviour. +/// Tier (a): when the fixture imports `rack`, prepend a permissive +/// captor module onto `Rack::Response` that records every +/// `set_header(name, value)` call verbatim, then invoke the named +/// entry function with the payload. +/// +/// Tier (b) — raw-socket wire frame: when the fixture binds a +/// `TCPServer` and exposes the `set_cookie_value` / `create_server` / +/// `run_once` triple, drive the fixture from the harness while +/// opening a client `TCPSocket` against the bound port, read the +/// bytes the fixture wrote to the response socket up to the +/// CRLF-CRLF boundary, and emit them as a `ProbeKind::HeaderWireFrame` +/// probe. Bypasses every framework-level CRLF validator since the +/// fixture owns the response-write path itself. +/// +/// Tier (c) synthetic fallback: when neither gate fires, emit a +/// single `HeaderEmit` probe with the payload bytes pre-bound to +/// `Set-Cookie`. pub fn emit_header_injection_harness(spec: &HarnessSpec) -> HarnessSource { - let shim = probe_shim(); 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 entry_basename = derive_entry_basename(&spec.entry_file); let entry_name = if spec.entry_name.is_empty() { "run".to_owned() @@ -1227,6 +1240,189 @@ _nyx_run } } +/// Tier-(b) wire-frame gate for HEADER_INJECTION. Fires when the +/// fixture binds a raw `TCPServer` and exposes the `set_cookie_value` +/// / `create_server` / `run_once` triple the harness drives. Distinct +/// from the Rack gate because the wire-frame branch owns the +/// response-write path itself and bypasses every framework CRLF +/// validator. +fn entry_source_uses_raw_socket(src: &str) -> bool { + src.contains("TCPServer.new") && src.contains("set_cookie_value") +} + +/// Phase 08 — Track J.6 tier-(b) wire-frame harness for Ruby. Drives +/// the fixture's `create_server` / `run_once` API in a worker thread +/// while the harness opens a `TCPSocket` 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. +fn emit_header_injection_wire_frame_harness( + spec: &HarnessSpec, + _entry_source: &str, +) -> HarnessSource { + let shim = probe_shim(); + let entry_basename = derive_entry_basename(&spec.entry_file); + let body = format!( + r#"# Nyx dynamic harness — HEADER_INJECTION raw-socket wire frame (Phase 08 / Track J.6). +require 'json' +require 'socket' + +{shim} + +def _nyx_header_probe(name, value) + p = ENV['NYX_PROBE_PATH'] + return if p.nil? || p.empty? + rec = {{ + 'sink_callee' => 'TCPSocket#write', + 'args' => [ + {{ 'kind' => 'String', 'value' => name }}, + {{ 'kind' => 'String', 'value' => value }}, + ], + 'captured_at_ns' => Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond), + 'payload_id' => ENV['NYX_PAYLOAD_ID'] || '', + 'kind' => {{ 'kind' => 'HeaderEmit', 'name' => name, 'value' => value, 'protocol' => 'wire' }}, + 'witness' => __nyx_witness('TCPSocket#write', [name, value]), + }} + File.open(p, 'a') {{ |f| f.write(rec.to_json + "\n") }} +end + +def _nyx_wire_frame_probe(raw_bytes) + p = ENV['NYX_PROBE_PATH'] + return if p.nil? || p.empty? + rec = {{ + 'sink_callee' => 'TCPSocket#write', + 'args' => [], + 'captured_at_ns' => Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond), + 'payload_id' => ENV['NYX_PAYLOAD_ID'] || '', + 'kind' => {{ 'kind' => 'HeaderWireFrame', 'raw_bytes' => raw_bytes.bytes }}, + 'witness' => __nyx_witness('TCPSocket#write', []), + }} + File.open(p, 'a') {{ |f| f.write(rec.to_json + "\n") }} +end + +def _nyx_wire_frame_via_fixture(payload) + # Phase 08 tier-(b): install the cookie value on the fixture, boot + # its `TCPServer` on 127.0.0.1:0, drive `run_once` 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 nil on import / boot / read failure + # so the caller can fall back to the synthetic probe. + $LOAD_PATH.unshift('.') + begin + require_relative './{entry_basename}' + rescue LoadError, ScriptError + return nil + end + obj = Object.new + begin + obj.__send__(:set_cookie_value, payload) + rescue StandardError + return nil + end + server = begin + obj.__send__(:create_server) + rescue StandardError + return nil + end + port = server.addr[1] + worker = Thread.new do + begin + obj.__send__(:run_once, server) + rescue StandardError + # ignore fixture errors so the harness can still capture the + # bytes already written before the throw. + end + end + raw = String.new(encoding: 'BINARY') + begin + client = TCPSocket.new('127.0.0.1', port) + rescue StandardError + worker.kill rescue nil + return nil + end + begin + client.write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n") + deadline = Time.now + 5.0 + while raw.bytesize < 65536 && Time.now < deadline + ready = IO.select([client], nil, nil, [deadline - Time.now, 0.0].max) + break unless ready + begin + chunk = client.recv(4096) + rescue StandardError + break + end + break if chunk.nil? || chunk.empty? + raw << chunk.b + break if raw.include?("\r\n\r\n".b) + end + ensure + begin + client.close + rescue StandardError + # ignore close errors + end + worker.join(2.0) rescue nil + begin + server.close + rescue StandardError + # ignore close errors + end + end + sep = raw.index("\r\n\r\n".b) + return raw if sep.nil? + raw.byteslice(0, sep) +end + +def _nyx_run + payload = ENV['NYX_PAYLOAD'] || '' + raw_bytes = _nyx_wire_frame_via_fixture(payload) + if raw_bytes + _nyx_wire_frame_probe(raw_bytes) + # Derive HeaderEmit records per Set-Cookie line on the wire so + # the tier-(a) HeaderInjected predicate also fires on the same + # harness pass. The wire-frame branch owns the bytes; the + # HeaderEmit records are derived from them. + raw_bytes.split("\n".b).each do |line| + trimmed = line.bytes.last == 13 ? line.byteslice(0, line.bytesize - 1) : line + sep = trimmed.index(':'.b) + next if sep.nil? + name = trimmed.byteslice(0, sep) + next unless name.downcase == 'set-cookie'.b + start = sep + 1 + start += 1 if start < trimmed.bytesize && trimmed.getbyte(start) == 32 + value = trimmed.byteslice(start, trimmed.bytesize - start) || ''.b + _nyx_header_probe(name.force_encoding('UTF-8'), value.force_encoding('UTF-8')) + end + STDOUT.puts '__NYX_SINK_HIT__' + STDOUT.puts JSON.generate({{ 'wire_frame_len' => raw_bytes.bytesize }}) + STDOUT.flush + return + end + # Synthetic fallback when the fixture failed to boot — keeps the + # differential oracle live on a build/boot failure rather than + # silently shedding the attempt. + _nyx_header_probe('Set-Cookie', payload) + STDOUT.puts '__NYX_SINK_HIT__' + STDOUT.puts JSON.generate({{ 'payload_len' => payload.bytesize }}) + STDOUT.flush +end + +_nyx_run +"# + ); + HarnessSource { + source: body, + filename: "harness.rb".to_owned(), + command: vec!["ruby".to_owned(), "harness.rb".to_owned()], + extra_files: vec![], + entry_subpath: None, + } +} + /// Phase 09 — Track J.7 open-redirect harness for Ruby /// (`Rack::Response#redirect`). /// @@ -2018,6 +2214,116 @@ 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_rb_test_wire_frame"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.rb"); + std::fs::write( + &entry, + "require 'socket'\n\ + def set_cookie_value(value)\n $nyx_cookie_value = value.b\nend\n\ + def create_server\n TCPServer.new('127.0.0.1', 0)\nend\n\ + def run_once(server)\n s = server.accept\n s.write('HTTP/1.0 200 OK\\r\\nSet-Cookie: ' + $nyx_cookie_value + '\\r\\n\\r\\nok')\n s.close\nend\n", + ) + .unwrap(); + let h = emit_header_injection_harness(&make_header_spec( + entry.to_str().unwrap(), + "run", + )); + assert!( + h.source.contains("def _nyx_wire_frame_via_fixture(payload)"), + "tier-(b) harness must define the wire-frame helper: {}", + h.source + ); + assert!( + h.source.contains("require_relative './vuln'"), + "tier-(b) harness must require the fixture by its file stem: {}", + h.source + ); + assert!( + h.source.contains("obj.__send__(:set_cookie_value, payload)"), + "tier-(b) harness must install the cookie value via __send__: {}", + h.source + ); + assert!( + h.source.contains("obj.__send__(:create_server)"), + "tier-(b) harness must boot the fixture's TCPServer via __send__: {}", + h.source + ); + assert!( + h.source.contains("obj.__send__(:run_once, server)"), + "tier-(b) harness must drive run_once on a worker thread: {}", + h.source + ); + assert!( + h.source.contains("Thread.new"), + "tier-(b) harness must spawn a worker thread for the accept loop: {}", + h.source + ); + assert!( + h.source.contains("TCPSocket.new('127.0.0.1', port)"), + "tier-(b) harness must open a client TCPSocket 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', 'raw_bytes' => raw_bytes.bytes"), + "tier-(b) harness must emit a HeaderWireFrame probe carrying the raw header-block bytes: {}", + h.source + ); + assert!( + h.source.contains("'wire_frame_len' => raw_bytes.bytesize"), + "tier-(b) harness must emit the wire_frame_len stdout marker: {}", + h.source + ); + assert!( + !h.source.contains("Rack::Response.prepend"), + "tier-(b) harness must not patch Rack::Response (that's the tier-(a) path): {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn emit_header_injection_harness_wire_frame_branch_drops_when_only_rack_imported() { + let dir = std::env::temp_dir().join("nyx_phase08_rb_test_no_wire_frame"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.rb"); + std::fs::write( + &entry, + "require 'rack'\n\ + def run(value)\n r = Rack::Response.new\n r.set_header('Set-Cookie', value)\n r\nend\n", + ) + .unwrap(); + let h = emit_header_injection_harness(&make_header_spec( + entry.to_str().unwrap(), + "run", + )); + assert!( + !h.source.contains("def _nyx_wire_frame_via_fixture"), + "rack-only harness must not define the wire-frame helper: {}", + h.source + ); + assert!( + !h.source.contains("HeaderWireFrame"), + "rack-only harness must not emit the HeaderWireFrame probe shape: {}", + h.source + ); + assert!( + !h.source.contains("wire_frame_len"), + "rack-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_routes_through_fixture_when_rack_required() { let dir = std::env::temp_dir().join("nyx_phase09_rb_test_drive_fixture"); diff --git a/tests/dynamic_fixtures/header_injection/ruby_raw/vuln.rb b/tests/dynamic_fixtures/header_injection/ruby_raw/vuln.rb new file mode 100644 index 00000000..db8e2404 --- /dev/null +++ b/tests/dynamic_fixtures/header_injection/ruby_raw/vuln.rb @@ -0,0 +1,54 @@ +# Phase 08 (Track J.6) — Ruby raw-socket HEADER_INJECTION vuln fixture. +# +# Writes the response status line and headers directly to the wire via +# `socket.write`, bypassing the framework-level CRLF validator that +# Rack / Sinatra / Rails would otherwise interpose. 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/ruby.rs::emit_header_injection_harness`) +# detects the `TCPServer.new` token in this file and routes through the +# tier-(b) wire-frame branch: bind a loopback `TCPServer` via +# `create_server`, accept one client (`run_once`), issue one raw +# `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. +require 'socket' + +# Bytes go straight onto the wire with no encoding pass. The harness +# installs the cookie value before booting the accept loop, mirroring +# the JS `setCookieValue` and Python `Handler.cookie_value =` setters. +$nyx_cookie_value = String.new(encoding: 'BINARY') + +def set_cookie_value(value) + $nyx_cookie_value = value.respond_to?(:b) ? value.b : value.to_s.b +end + +def create_server + TCPServer.new('127.0.0.1', 0) +end + +def run_once(server) + socket = server.accept + begin + socket.recv(4096) + rescue StandardError + # ignore client-side read errors + end + body = "ok\n".b + raw = String.new(encoding: 'BINARY') + raw << "HTTP/1.0 200 OK\r\n".b + raw << "Content-Length: #{body.bytesize}\r\n".b + raw << "Set-Cookie: ".b + raw << $nyx_cookie_value + raw << "\r\n\r\n".b + raw << body + socket.write(raw) +ensure + begin + socket.close if socket + rescue StandardError + # ignore close errors + end +end diff --git a/tests/header_injection_corpus.rs b/tests/header_injection_corpus.rs index bea1325f..08402369 100644 --- a/tests/header_injection_corpus.rs +++ b/tests/header_injection_corpus.rs @@ -934,4 +934,87 @@ mod e2e_phase_08 { .collect::>(), ); } + + // Phase 08 tier-(b): Ruby raw-socket wire-frame fixture. + // `tests/dynamic_fixtures/header_injection/ruby_raw/vuln.rb` binds + // a `TCPServer` via `create_server` whose `run_once` handler writes + // raw bytes via `TCPSocket#write`, bypassing Rack's CRLF strip on + // `Rack::Response#set_header`. The harness boots the server on a + // loopback port, opens a client `TCPSocket`, 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_ruby_raw_spec(entry_name: &str) -> (HarnessSpec, TempDir) { + let fixture_src = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/dynamic_fixtures/header_injection/ruby_raw/vuln.rb"); + let tmp = TempDir::new().expect("create tempdir"); + let dst = tmp.path().join("vuln.rb"); + std::fs::copy(&fixture_src, &dst).expect("copy ruby_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|ruby_raw|vuln.rb"); + let spec_hash = format!("{:016x}", { + let bytes = digest.finalize(); + u64::from_le_bytes(bytes.as_bytes()[..8].try_into().unwrap()) + }); + 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::Ruby, + toolchain_id: default_toolchain_id(Lang::Ruby).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 ruby_raw_socket_vuln_confirms_via_wire_frame_probe() { + if !command_available("ruby") { + eprintln!("SKIP ruby_raw: missing ruby"); + return; + } + let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (spec, _tmp) = build_ruby_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 ruby_raw: harness build failed after {attempts} attempts: {stderr}", + ); + return; + } + Err(e) => panic!("run_spec(ruby_raw) errored: {e:?}"), + }; + assert_confirmed(Lang::Ruby, &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, + "ruby_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::>(), + ); + } }