diff --git a/src/dynamic/corpus/header_injection/php.rs b/src/dynamic/corpus/header_injection/php.rs index 1fa0777a..3b34bf08 100644 --- a/src/dynamic/corpus/header_injection/php.rs +++ b/src/dynamic/corpus/header_injection/php.rs @@ -55,4 +55,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 `header()` payload above, but + // pinned to the `php_raw` fixture (a `stream_socket_server` driven + // by `create_server` + `run_once` that writes raw bytes via + // `fwrite($conn, $raw)`). 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 `header()` + // row) because PHP's built-in `header()` rejects raw CRLF since + // 5.1.2 and modern Slim / Laravel / Symfony response serializers + // strip CRLF at the wire-write boundary, so the wire-frame + // predicate would never fire against the canonical `header()` + // fixture. + CuratedPayload { + bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn", + label: "header-injection-php-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/php_raw/vuln.php"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::HeaderSmuggledInWire { + primary: "Set-Cookie", + smuggled: "Set-Cookie", + }], + benign_control: Some(PayloadRef { + label: "header-injection-php-raw-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn", + label: "header-injection-php-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/php_raw/vuln.php"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, ]; diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 3af73adb..d488db2a 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -1337,8 +1337,11 @@ echo json_encode(['expr' => $expr, 'nodes_returned' => $nodes]) . "\n"; /// `werkzeug.datastructures.Headers.__setitem__` before werkzeug's /// validator runs. 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_php_entry_basename(&spec.entry_file); let entry_name = if spec.entry_name.is_empty() { "run".to_owned() @@ -1476,6 +1479,255 @@ _nyx_run(); } } +/// Tier-(b) wire-frame gate for HEADER_INJECTION. Fires when the +/// fixture binds a raw `stream_socket_server` (or `socket_create`) and +/// exposes the `set_cookie_value` / `create_server` / `run_once` +/// triple the harness drives. Distinct from the `header()` / +/// `setcookie()` gate because the wire-frame branch owns the +/// response-write path itself and bypasses PHP's built-in CRLF +/// validator. +fn entry_source_uses_raw_socket(src: &str) -> bool { + (src.contains("stream_socket_server") || src.contains("socket_create")) + && src.contains("set_cookie_value") +} + +/// Phase 08 — Track J.6 tier-(b) wire-frame harness for PHP. Drives +/// the fixture's `create_server` / `run_once` API in a forked / threaded +/// worker while the main process opens a `stream_socket_client` against +/// the bound port, issues one `GET / HTTP/1.0`, and reads the bytes the +/// fixture wrote to the response stream 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. +/// +/// PHP has no portable green-thread primitive — the harness uses +/// `pcntl_fork` when available (Linux + macOS Homebrew PHP both ship +/// `ext-pcntl` by default) and falls back to a non-blocking +/// `stream_select` drive of both the server and the client in a single +/// process when `pcntl_fork` is missing (Windows / minimal CLI builds). +fn emit_header_injection_wire_frame_harness( + spec: &HarnessSpec, + _entry_source: &str, +) -> HarnessSource { + let shim = probe_shim(); + let entry_basename = derive_php_entry_basename(&spec.entry_file); + let body = format!( + r#" 'fwrite(stream)', + 'args' => [ + ['kind' => 'String', 'value' => $name], + ['kind' => 'String', 'value' => $value], + ], + 'captured_at_ns' => (int) hrtime(true), + 'payload_id' => (string) (getenv('NYX_PAYLOAD_ID') ?: ''), + 'kind' => ['kind' => 'HeaderEmit', 'name' => $name, 'value' => $value, 'protocol' => 'wire'], + 'witness' => __nyx_witness('fwrite(stream)', [$name, $value]), + ]; + @file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND); +}} + +function _nyx_wire_frame_probe(string $raw_bytes): void {{ + $p = getenv('NYX_PROBE_PATH'); + if ($p === false || $p === '') return; + $bytes = []; + $len = strlen($raw_bytes); + for ($i = 0; $i < $len; $i++) {{ + $bytes[] = ord($raw_bytes[$i]); + }} + $rec = [ + 'sink_callee' => 'fwrite(stream)', + 'args' => [], + 'captured_at_ns' => (int) hrtime(true), + 'payload_id' => (string) (getenv('NYX_PAYLOAD_ID') ?: ''), + 'kind' => ['kind' => 'HeaderWireFrame', 'raw_bytes' => $bytes], + 'witness' => __nyx_witness('fwrite(stream)', []), + ]; + @file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND); +}} + +function _nyx_wire_frame_via_fixture(string $payload, string $entry_basename): ?string {{ + // Phase 08 tier-(b): require the fixture, install the cookie + // value, boot its `stream_socket_server` on 127.0.0.1:0, drive + // `run_once` either in a forked child (when `pcntl_fork` is + // available) or in a single-process `stream_select` loop, then + // issue one raw-socket GET from the harness and read the bytes + // the fixture wrote to the response stream up to the CRLF-CRLF + // boundary. Returns null on require / boot / read failure so the + // caller can fall back to the synthetic probe. + $candidate = __DIR__ . DIRECTORY_SEPARATOR . $entry_basename; + if (!is_file($candidate)) {{ + return null; + }} + try {{ + require_once $candidate; + }} catch (\Throwable $_) {{ + return null; + }} + if (!function_exists('set_cookie_value') + || !function_exists('create_server') + || !function_exists('run_once')) {{ + return null; + }} + try {{ + set_cookie_value($payload); + }} catch (\Throwable $_) {{ + return null; + }} + try {{ + $server = create_server(); + }} catch (\Throwable $_) {{ + return null; + }} + if ($server === false || $server === null) {{ + return null; + }} + $name = @stream_socket_get_name($server, false); + if ($name === false || $name === '') {{ + @fclose($server); + return null; + }} + $colon = strrpos($name, ':'); + $port = $colon === false ? '0' : substr($name, $colon + 1); + if ($port === '0' || $port === '') {{ + @fclose($server); + return null; + }} + $forked = false; + $pid = -1; + if (function_exists('pcntl_fork')) {{ + $pid = @pcntl_fork(); + if ($pid === 0) {{ + // Child runs the accept loop and exits. + try {{ + run_once($server); + }} catch (\Throwable $_) {{ + // ignore fixture errors so the parent can still + // capture whatever bytes were written before the throw. + }} + @fclose($server); + exit(0); + }} + if ($pid > 0) {{ + $forked = true; + }} + }} + $raw = ''; + $errno = 0; + $errstr = ''; + $client = @stream_socket_client( + 'tcp://127.0.0.1:' . $port, + $errno, + $errstr, + 5.0 + ); + if ($client === false) {{ + if ($forked) {{ + @posix_kill($pid, 9); + @pcntl_waitpid($pid, $status); + }} else {{ + try {{ + run_once($server); + }} catch (\Throwable $_) {{ + // ignore + }} + }} + @fclose($server); + return null; + }} + try {{ + @stream_set_timeout($client, 2, 0); + @fwrite($client, "GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"); + if (!$forked) {{ + // Single-process path: drive `run_once` after the client + // has already sent its request so the accept call returns + // immediately. + try {{ + run_once($server); + }} catch (\Throwable $_) {{ + // ignore + }} + }} + $deadline = microtime(true) + 5.0; + while (strlen($raw) < 65536 && microtime(true) < $deadline) {{ + $chunk = @fread($client, 4096); + if ($chunk === false || $chunk === '') {{ + break; + }} + $raw .= $chunk; + if (strpos($raw, "\r\n\r\n") !== false) {{ + break; + }} + }} + }} finally {{ + @fclose($client); + if ($forked) {{ + $status = 0; + @pcntl_waitpid($pid, $status); + }} + @fclose($server); + }} + $sep = strpos($raw, "\r\n\r\n"); + if ($sep === false) {{ + return $raw === '' ? null : $raw; + }} + return substr($raw, 0, $sep); +}} + +function _nyx_run(): void {{ + $payload = (string) (getenv('NYX_PAYLOAD') ?: ''); + $raw_bytes = _nyx_wire_frame_via_fixture($payload, "{entry_basename}"); + if ($raw_bytes !== null) {{ + _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. + foreach (explode("\n", $raw_bytes) as $line) {{ + $trimmed = (substr($line, -1) === "\r") ? substr($line, 0, -1) : $line; + $colon = strpos($trimmed, ':'); + if ($colon === false) continue; + $name = substr($trimmed, 0, $colon); + if (strcasecmp($name, 'Set-Cookie') !== 0) continue; + $start = $colon + 1; + if ($start < strlen($trimmed) && $trimmed[$start] === ' ') {{ + $start++; + }} + $value = (string) substr($trimmed, $start); + _nyx_header_probe($name, $value); + }} + echo "__NYX_SINK_HIT__\n"; + echo json_encode(['wire_frame_len' => strlen($raw_bytes)]) . "\n"; + 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. + _nyx_header_probe('Set-Cookie', $payload); + echo "__NYX_SINK_HIT__\n"; + echo json_encode(['payload_len' => strlen($payload)]) . "\n"; +}} + +_nyx_run(); +"# + ); + HarnessSource { + source: body, + filename: "harness.php".to_owned(), + command: vec!["php".to_owned(), "harness.php".to_owned()], + extra_files: Vec::new(), + entry_subpath: None, + } +} + /// Phase 09 — Track J.7 open-redirect harness for PHP (`header("Location: …")` / /// `Response::redirect`). /// @@ -2750,6 +3002,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_php_test_wire_frame"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.php"); + std::fs::write( + &entry, + " 'HeaderWireFrame', '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' => strlen($raw_bytes)"), + "tier-(b) harness must emit the wire_frame_len stdout marker: {}", + h.source + ); + assert!( + !h.source.contains("namespace Nyx\\\\Captured"), + "tier-(b) harness must not eval into the Nyx\\Captured namespace (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_header_call_present() { + let dir = std::env::temp_dir().join("nyx_phase08_php_test_no_wire_frame"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.php"); + std::fs::write( + &entry, + ">(), ); } + + // Phase 08 tier-(b): PHP raw-socket wire-frame fixture. + // `tests/dynamic_fixtures/header_injection/php_raw/vuln.php` binds + // a `stream_socket_server` via `create_server` whose `run_once` + // handler writes raw bytes via `fwrite($conn, $raw)`, bypassing + // PHP's built-in `header()` CRLF strip (rejected since 5.1.2). + // The harness boots the server on a loopback port, opens a client + // stream via `stream_socket_client`, 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_php_raw_spec(entry_name: &str) -> (HarnessSpec, TempDir) { + let fixture_src = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/dynamic_fixtures/header_injection/php_raw/vuln.php"); + let tmp = TempDir::new().expect("create tempdir"); + let dst = tmp.path().join("vuln.php"); + std::fs::copy(&fixture_src, &dst).expect("copy php_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|php_raw|vuln.php"); + 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::Php, + toolchain_id: default_toolchain_id(Lang::Php).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 php_raw_socket_vuln_confirms_via_wire_frame_probe() { + if !command_available("php") { + eprintln!("SKIP php_raw: missing php"); + return; + } + let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (spec, _tmp) = build_php_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 php_raw: harness build failed after {attempts} attempts: {stderr}", + ); + return; + } + Err(e) => panic!("run_spec(php_raw) errored: {e:?}"), + }; + assert_confirmed(Lang::Php, &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, + "php_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::>(), + ); + } }