From dd9da4eef52d8525c754048ffae96c20eda31360 Mon Sep 17 00:00:00 2001 From: pitboss Date: Thu, 21 May 2026 20:23:29 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0019 (20260521T201327Z-3848) --- src/dynamic/lang/php.rs | 450 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 427 insertions(+), 23 deletions(-) diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index edb4e5e1..7e7df7f8 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -1109,13 +1109,112 @@ echo json_encode(['expr' => $expr, 'nodes_returned' => $nodes]) . "\n"; /// Phase 08 — Track J.6 header-injection harness for PHP (`header()`). /// -/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented `header()` -/// shim that records the *unmodified* value bytes (including any -/// embedded `\r\n`) via a `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 { +/// Tier-(a): when the fixture source calls `header(` or `setcookie(`, +/// load the entry source into a synthetic `Nyx\Captured` namespace via +/// `eval()` so unqualified calls to `header()` / `setcookie()` resolve +/// to permissive shims defined in that namespace (rather than PHP's +/// built-in `header()` which rejects raw CRLF since PHP 5.1.2). The +/// shim records every `(name, value)` pair into a global capture +/// array verbatim; the harness then emits one `ProbeKind::HeaderEmit` +/// per captured pair. When the gate marker is absent or the eval / +/// invocation fails, fall back to the inline synthetic probe that +/// records the raw payload as a `Set-Cookie` value. The namespace +/// shadowing pattern mirrors how Python's tier-(a) monkey-patches +/// `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); + let entry_basename = derive_php_entry_basename(&spec.entry_file); + let entry_name = if spec.entry_name.is_empty() { + "run".to_owned() + } else { + spec.entry_name.clone() + }; + let uses_header_writer = + entry_source.contains("header(") || entry_source.contains("setcookie("); + let via_fixture = if uses_header_writer { + r#"function _nyx_header_via_fixture(string $payload, string $entry_basename, string $entry_name): ?array { + // Phase 08 tier-(a): load the entry source into a synthetic + // `Nyx\Captured` namespace via eval() so unqualified `header()` + // / `setcookie()` calls inside the fixture resolve to permissive + // shims defined in that namespace (PHP's built-in `header()` + // rejects raw CRLF since 5.1.2 and would not let us record the + // attack bytes verbatim). Returns the captured `(name, value)` + // pairs on success, `null` when load / eval / invoke fails so + // the caller can fall back to the inline synthetic probe. + $candidate = __DIR__ . DIRECTORY_SEPARATOR . $entry_basename; + if (!is_file($candidate)) { + return null; + } + $src = @file_get_contents($candidate); + if ($src === false) { + return null; + } + $stripped = preg_replace('/^\s*<\?php\s*/', '', $src); + if ($stripped === null) { + return null; + } + $GLOBALS['__nyx_captured_headers'] = []; + $eval_src = "namespace Nyx\\Captured;\n" + . "function header(string \$header, bool \$replace = true, int \$response_code = 0): void { \$GLOBALS['__nyx_captured_headers'][] = \$header; }\n" + . "function setcookie(string \$name, string \$value = '', \$expires_or_options = 0, string \$path = '', string \$domain = '', bool \$secure = false, bool \$httponly = false): bool { \$GLOBALS['__nyx_captured_headers'][] = 'Set-Cookie: ' . \$name . '=' . \$value; return true; }\n" + . $stripped; + try { + $eval_result = @eval($eval_src); + } catch (\Throwable $_) { + return null; + } + if ($eval_result === false) { + return null; + } + $fq = 'Nyx\\Captured\\' . $entry_name; + if (!function_exists($fq)) { + return null; + } + try { + $fq($payload); + } catch (\Throwable $_) { + // shim may have captured bytes before the throw + } + $captured = []; + foreach ($GLOBALS['__nyx_captured_headers'] ?? [] as $h) { + if (!is_string($h)) continue; + $colon = strpos($h, ':'); + if ($colon === false) { + $captured[] = [$h, '']; + } else { + $name = trim(substr($h, 0, $colon)); + $value = ltrim(substr($h, $colon + 1)); + $captured[] = [$name, $value]; + } + } + if (empty($captured)) { + return null; + } + return $captured; +} + +"# + } else { + "" + }; + let invoke_via_fixture = if uses_header_writer { + format!( + r#"$captured = _nyx_header_via_fixture($payload, "{entry_basename}", "{entry_name}"); + if ($captured !== null) {{ + foreach ($captured as $pair) {{ + _nyx_header_probe($pair[0], $pair[1]); + }} + echo "__NYX_SINK_HIT__\n"; + echo json_encode(['headers' => $captured]) . "\n"; + return; + }} + "# + ) + } else { + String::new() + }; let body = format!( r#" $name, 'value' => $value]) . "\n"; +{via_fixture}function _nyx_run(): void {{ + $payload = (string) (getenv('NYX_PAYLOAD') ?: ''); + {invoke_via_fixture}// Synthetic fallback — records the raw payload as a `Set-Cookie` + // value via `_nyx_header_probe`. Used when the fixture does not + // call `header()` / `setcookie()` (gate marker absent) or when + // the eval / invocation path fails. + $name = 'Set-Cookie'; + $value = $payload; + _nyx_header_probe($name, $value); + echo "__NYX_SINK_HIT__\n"; + echo json_encode(['name' => $name, 'value' => $value]) . "\n"; +}} + +_nyx_run(); "# ); HarnessSource { @@ -1158,12 +1265,100 @@ echo json_encode(['name' => $name, 'value' => $value]) . "\n"; /// Phase 09 — Track J.7 open-redirect harness for PHP (`header("Location: …")` / /// `Response::redirect`). /// -/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented redirect shim -/// that records the bound `Location:` value plus the request's origin -/// host via a `ProbeKind::Redirect` probe. Mirrors the -/// synthetic-harness pattern used by Phase 03 / 04 / 05 / 06 / 07 / 08. -pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource { +/// Tier-(a): when the fixture source references a redirect surface +/// (`RedirectResponse` constructor, bare `header(`, or `redirect(`), +/// `require_once` the entry, call its `$entry_name` with the payload, +/// and read the bound `Location:` off the returned response object +/// via `getTargetUrl()` or `->headers->get("Location")` (Symfony-style +/// `Response`). When the require / invoke fails (Symfony not +/// installed, fixture throws, no recognisable response shape), return +/// null so the caller can fall back to the inline synthetic probe +/// that records the raw payload as the redirect target. +pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource { let shim = probe_shim(); + let entry_source = read_entry_source(&spec.entry_file); + let entry_basename = derive_php_entry_basename(&spec.entry_file); + let entry_name = if spec.entry_name.is_empty() { + "run".to_owned() + } else { + spec.entry_name.clone() + }; + let uses_redirect_surface = entry_source.contains("RedirectResponse") + || entry_source.contains("header(") + || entry_source.contains("Response::") + || entry_source.contains("redirect("); + let via_fixture = if uses_redirect_surface { + r#"function _nyx_redirect_via_fixture(string $payload, string $entry_basename, string $entry_name): ?array { + // Phase 09 tier-(a): require the entry fixture, call its + // `$entry_name` so the real redirect surface runs, then read the + // bound `Location:` off the returned response object. Recognises + // both Symfony-style `Response` instances (via `getTargetUrl()` + // and `->headers->get("Location")`) and arbitrary objects whose + // `headers` property exposes a `get` method. Returns + // `(location, "example.com")` on success or `null` when the + // require / invoke fails so the caller can fall back to the + // inline 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($entry_name)) { + return null; + } + try { + $result = $entry_name($payload); + } catch (\Throwable $_) { + return null; + } + if (is_object($result)) { + if (method_exists($result, 'getTargetUrl')) { + try { + $loc = $result->getTargetUrl(); + } catch (\Throwable $_) { + $loc = null; + } + if (is_string($loc) && $loc !== '') { + return [$loc, 'example.com']; + } + } + if (isset($result->headers) && is_object($result->headers) && method_exists($result->headers, 'get')) { + try { + $loc = $result->headers->get('Location'); + } catch (\Throwable $_) { + $loc = null; + } + if (is_string($loc) && $loc !== '') { + return [$loc, 'example.com']; + } + } + } + return null; +} + +"# + } else { + "" + }; + let invoke_via_fixture = if uses_redirect_surface { + format!( + r#"$captured = _nyx_redirect_via_fixture($payload, "{entry_basename}", "{entry_name}"); + if ($captured !== null) {{ + [$location, $requestHost] = $captured; + _nyx_redirect_probe($location, $requestHost); + echo "__NYX_SINK_HIT__\n"; + echo json_encode(['location' => $location, 'request_host' => $requestHost]) . "\n"; + return; + }} + "# + ) + } else { + String::new() + }; let body = format!( r#" $location, 'request_host' => $requestHost]) . "\n"; +{via_fixture}function _nyx_run(): void {{ + $payload = (string) (getenv('NYX_PAYLOAD') ?: ''); + {invoke_via_fixture}// Synthetic fallback — records the raw payload as the redirect + // location via `_nyx_redirect_probe`. Used when the fixture does + // not reference a recognised redirect surface (gate marker absent) + // or when the require / invoke path fails (Symfony classes + // missing, fixture throws). + $requestHost = 'example.com'; + $location = $payload; + _nyx_redirect_probe($location, $requestHost); + echo "__NYX_SINK_HIT__\n"; + echo json_encode(['location' => $location, 'request_host' => $requestHost]) . "\n"; +}} + +_nyx_run(); "# ); HarnessSource { @@ -2012,4 +2216,204 @@ mod tests { "PHP XPath harness must use the entry-file basename, not a hard-coded literal", ); } + + // ── Phase 08 / 09 tier-(a) PHP emitter tests ───────────────────────────── + + fn make_header_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::HEADER_INJECTION; + spec.entry_file = entry_file.to_owned(); + spec.entry_name = entry_name.to_owned(); + spec + } + + fn make_redirect_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::OPEN_REDIRECT; + spec.entry_file = entry_file.to_owned(); + spec.entry_name = entry_name.to_owned(); + spec + } + + #[test] + fn emit_header_injection_harness_routes_through_fixture_when_header_call_present() { + let dir = std::env::temp_dir().join("nyx_phase08_php_test_drive_fixture"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.php"); + std::fs::write( + &entry, + "headers->get('Location')"), + "tier-(a) helper must fall back to ->headers->get('Location'): {}", + h.source + ); + assert!( + h.source.contains("\"vuln.php\""), + "tier-(a) harness must pass the entry basename to the helper: {}", + h.source + ); + assert!( + h.source.contains("$captured = _nyx_redirect_via_fixture("), + "harness main must call the fixture-routing helper first: {}", + h.source + ); + assert!( + h.source.contains("$location = $payload;\n _nyx_redirect_probe("), + "fallback path must keep the synthetic probe: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn emit_open_redirect_harness_falls_back_when_no_redirect_surface() { + let dir = std::env::temp_dir().join("nyx_phase09_php_test_no_redirect"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.php"); + std::fs::write(&entry, "