mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-30 20:39:39 +02:00
[pitboss/grind] deferred session-0026 (20260522T043516Z-29b8)
This commit is contained in:
parent
853fd281c5
commit
ed237ab45a
4 changed files with 574 additions and 1 deletions
|
|
@ -55,4 +55,63 @@ pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
benign_control: None,
|
benign_control: None,
|
||||||
no_benign_control_rationale: 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,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1337,8 +1337,11 @@ echo json_encode(['expr' => $expr, 'nodes_returned' => $nodes]) . "\n";
|
||||||
/// `werkzeug.datastructures.Headers.__setitem__` before werkzeug's
|
/// `werkzeug.datastructures.Headers.__setitem__` before werkzeug's
|
||||||
/// validator runs.
|
/// validator runs.
|
||||||
pub fn emit_header_injection_harness(spec: &HarnessSpec) -> HarnessSource {
|
pub fn emit_header_injection_harness(spec: &HarnessSpec) -> HarnessSource {
|
||||||
let shim = probe_shim();
|
|
||||||
let entry_source = read_entry_source(&spec.entry_file);
|
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_basename = derive_php_entry_basename(&spec.entry_file);
|
||||||
let entry_name = if spec.entry_name.is_empty() {
|
let entry_name = if spec.entry_name.is_empty() {
|
||||||
"run".to_owned()
|
"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#"<?php
|
||||||
|
// Nyx dynamic harness — HEADER_INJECTION raw-socket wire frame (Phase 08 / Track J.6).
|
||||||
|
{shim}
|
||||||
|
|
||||||
|
function _nyx_header_probe(string $name, string $value): void {{
|
||||||
|
$p = getenv('NYX_PROBE_PATH');
|
||||||
|
if ($p === false || $p === '') return;
|
||||||
|
$rec = [
|
||||||
|
'sink_callee' => '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: …")` /
|
/// Phase 09 — Track J.7 open-redirect harness for PHP (`header("Location: …")` /
|
||||||
/// `Response::redirect`).
|
/// `Response::redirect`).
|
||||||
///
|
///
|
||||||
|
|
@ -2750,6 +3002,116 @@ mod tests {
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
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,
|
||||||
|
"<?php\n\
|
||||||
|
$GLOBALS['nyx_cookie_value'] = '';\n\
|
||||||
|
function set_cookie_value($value) { $GLOBALS['nyx_cookie_value'] = (string) $value; }\n\
|
||||||
|
function create_server() { $e=0; $s=''; return stream_socket_server('tcp://127.0.0.1:0', $e, $s); }\n\
|
||||||
|
function run_once($server) { $c = stream_socket_accept($server, 5.0); if ($c === false) return; fwrite($c, \"HTTP/1.0 200 OK\\r\\nSet-Cookie: \" . $GLOBALS['nyx_cookie_value'] . \"\\r\\n\\r\\nok\"); fclose($c); }\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let h = emit_header_injection_harness(&make_header_spec(
|
||||||
|
entry.to_str().unwrap(),
|
||||||
|
"run",
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
h.source.contains("function _nyx_wire_frame_via_fixture("),
|
||||||
|
"tier-(b) harness must define the wire-frame helper: {}",
|
||||||
|
h.source
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
h.source.contains("require_once $candidate"),
|
||||||
|
"tier-(b) harness must require_once the fixture: {}",
|
||||||
|
h.source
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
h.source.contains("\"vuln.php\""),
|
||||||
|
"tier-(b) harness must pass the entry basename to the helper: {}",
|
||||||
|
h.source
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
h.source.contains("set_cookie_value($payload)"),
|
||||||
|
"tier-(b) harness must install the cookie value on the fixture: {}",
|
||||||
|
h.source
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
h.source.contains("create_server()"),
|
||||||
|
"tier-(b) harness must boot the fixture's stream socket via create_server: {}",
|
||||||
|
h.source
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
h.source.contains("run_once($server)"),
|
||||||
|
"tier-(b) harness must drive run_once: {}",
|
||||||
|
h.source
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
h.source.contains("stream_socket_client("),
|
||||||
|
"tier-(b) harness must open a client stream 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' => $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,
|
||||||
|
"<?php\nfunction run($value) {\n header(\"Set-Cookie: \" . $value);\n}\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let h = emit_header_injection_harness(&make_header_spec(
|
||||||
|
entry.to_str().unwrap(),
|
||||||
|
"run",
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
!h.source.contains("function _nyx_wire_frame_via_fixture("),
|
||||||
|
"header()-only harness must not define the wire-frame helper: {}",
|
||||||
|
h.source
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!h.source.contains("HeaderWireFrame"),
|
||||||
|
"header()-only harness must not emit the HeaderWireFrame probe shape: {}",
|
||||||
|
h.source
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!h.source.contains("wire_frame_len"),
|
||||||
|
"header()-only harness must not emit the wire_frame_len stdout marker: {}",
|
||||||
|
h.source
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emit_open_redirect_harness_routes_through_fixture_when_redirect_surface_present() {
|
fn emit_open_redirect_harness_routes_through_fixture_when_redirect_surface_present() {
|
||||||
let dir = std::env::temp_dir().join("nyx_phase09_php_test_drive_fixture");
|
let dir = std::env::temp_dir().join("nyx_phase09_php_test_drive_fixture");
|
||||||
|
|
|
||||||
68
tests/dynamic_fixtures/header_injection/php_raw/vuln.php
Normal file
68
tests/dynamic_fixtures/header_injection/php_raw/vuln.php
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
// Phase 08 (Track J.6) — PHP raw-socket HEADER_INJECTION vuln fixture.
|
||||||
|
//
|
||||||
|
// Writes the response status line and headers directly to the wire via
|
||||||
|
// `fwrite($conn, $raw)` against a `stream_socket_server` server-side
|
||||||
|
// stream, bypassing the framework-level CRLF validator that PHP's
|
||||||
|
// built-in `header()` (rejected since PHP 5.1.2) or a Slim / Symfony
|
||||||
|
// / Laravel response serializer 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/php.rs::emit_header_injection_harness`)
|
||||||
|
// detects the `stream_socket_server` token in this file and routes
|
||||||
|
// through the tier-(b) wire-frame branch: bind a loopback server 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 stream up to the CRLF-CRLF boundary, and emit them
|
||||||
|
// as a `ProbeKind::HeaderWireFrame` record.
|
||||||
|
//
|
||||||
|
// Uses `stream_socket_server` rather than `socket_create` so the
|
||||||
|
// fixture has no hard dep on `ext-sockets` (which is core but not
|
||||||
|
// always built into minimal PHP images); `stream_socket_server`
|
||||||
|
// resolves through the always-available filesystem-and-network
|
||||||
|
// stream layer.
|
||||||
|
|
||||||
|
// Bytes go straight onto the wire with no encoding pass. The harness
|
||||||
|
// installs the cookie value before booting the accept loop, mirroring
|
||||||
|
// the Ruby `set_cookie_value` and Python `Handler.cookie_value` setters.
|
||||||
|
$GLOBALS['nyx_cookie_value'] = '';
|
||||||
|
|
||||||
|
function set_cookie_value($value) {
|
||||||
|
$GLOBALS['nyx_cookie_value'] = (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function create_server() {
|
||||||
|
$errno = 0;
|
||||||
|
$errstr = '';
|
||||||
|
$server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
|
||||||
|
if ($server === false) {
|
||||||
|
throw new \RuntimeException("stream_socket_server failed: $errno $errstr");
|
||||||
|
}
|
||||||
|
return $server;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run_once($server) {
|
||||||
|
$conn = @stream_socket_accept($server, 5.0);
|
||||||
|
if ($conn === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Drain whatever request bytes the client sent so the kernel
|
||||||
|
// does not stall the write that follows. Ignore errors.
|
||||||
|
@stream_set_timeout($conn, 1, 0);
|
||||||
|
@fread($conn, 4096);
|
||||||
|
$body = "ok\n";
|
||||||
|
$cookie = isset($GLOBALS['nyx_cookie_value']) ? $GLOBALS['nyx_cookie_value'] : '';
|
||||||
|
$raw = "HTTP/1.0 200 OK\r\n"
|
||||||
|
. "Content-Length: " . strlen($body) . "\r\n"
|
||||||
|
. "Set-Cookie: " . $cookie . "\r\n"
|
||||||
|
. "\r\n"
|
||||||
|
. $body;
|
||||||
|
@fwrite($conn, $raw);
|
||||||
|
@fflush($conn);
|
||||||
|
} finally {
|
||||||
|
@fclose($conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1017,4 +1017,88 @@ mod e2e_phase_08 {
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue