[pitboss] phase 06: Track J.4 + Track L.4 — LDAP_INJECTION corpus + LdapTemplate / python-ldap / php-ldap adapters

This commit is contained in:
pitboss 2026-05-17 22:32:44 -05:00
parent 993bfabe28
commit b2eeaabb09
27 changed files with 2189 additions and 18 deletions

View file

@ -561,6 +561,9 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
if spec.expected_cap == crate::labels::Cap::XXE {
return Ok(emit_xxe_harness(spec));
}
if spec.expected_cap == crate::labels::Cap::LDAP_INJECTION {
return Ok(emit_ldap_harness(spec));
}
let entry_source = read_entry_source(&spec.entry_file);
let shape = JavaShape::detect(spec, &entry_source);
@ -891,6 +894,192 @@ public class NyxHarness {{
}
}
/// Phase 06 — Track J.4 LDAP-injection harness for Java
/// (`LdapTemplate.search` / `DirContext.search`).
///
/// Reads `NYX_PAYLOAD`, splices it into a `(uid=<payload>)` filter
/// template, evaluates the resulting filter against the in-sandbox
/// LDAP directory (three users: `alice`, `bob`, `carol`) using the
/// same RFC-4515 subset the
/// [`crate::dynamic::stubs::ldap_server`] stub implements, and writes
/// a `ProbeKind::Ldap { entries_returned }` probe whose `n` is the
/// count the directory returned. Mirrors the synthetic-harness
/// pattern used by Phase 03 / 04 / 05; a future structural fix will
/// link real `LdapTemplate` / `DirContext` via the published
/// `NYX_LDAP_ENDPOINT`.
pub fn emit_ldap_harness(_spec: &HarnessSpec) -> HarnessSource {
let shim = probe_shim();
let source = format!(
r#"// Nyx dynamic harness — LDAP_INJECTION LdapTemplate.search (Phase 06 / Track J.4).
import java.io.FileWriter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class NyxHarness {{
{shim}
static final String[] NYX_LDAP_USERS = new String[] {{ "alice", "bob", "carol" }};
static boolean nyxAttrMatch(String pattern, String uid) {{
if (pattern.equals("*")) return true;
int star = pattern.indexOf('*');
if (star < 0) return pattern.equals(uid);
String prefix = pattern.substring(0, star);
String suffix = pattern.substring(star + 1);
return uid.startsWith(prefix) && uid.endsWith(suffix);
}}
static boolean nyxInnerHasBreak(String inner) {{
int depth = 0;
for (int i = 0; i < inner.length(); i++) {{
char c = inner.charAt(i);
if (c == '(') depth++;
else if (c == ')') {{
depth--;
if (depth < 0) return true;
}}
}}
return false;
}}
static int nyxLdapCount(String filter) {{
String f = filter == null ? "" : filter.trim();
if (f.isEmpty()) return 0;
if (!f.startsWith("(") || !f.endsWith(")")) return NYX_LDAP_USERS.length;
String inner = f.substring(1, f.length() - 1);
if (nyxInnerHasBreak(inner)) return NYX_LDAP_USERS.length;
if (inner.startsWith("&") || inner.startsWith("|")) {{
List<String> clauses = nyxSplitClauses(inner.substring(1));
int total = 0;
for (String u : NYX_LDAP_USERS) {{
boolean ok = inner.startsWith("&");
for (String c : clauses) {{
boolean m = nyxLdapMatch(c, u);
ok = inner.startsWith("&") ? (ok && m) : (ok || m);
}}
if (clauses.isEmpty()) ok = false;
if (ok) total++;
}}
return total;
}}
int eq = inner.indexOf('=');
if (eq < 0) return NYX_LDAP_USERS.length;
String attr = inner.substring(0, eq);
String pattern = inner.substring(eq + 1);
if (!attr.equalsIgnoreCase("uid") && !attr.equalsIgnoreCase("cn")) return NYX_LDAP_USERS.length;
int total = 0;
for (String u : NYX_LDAP_USERS) {{
if (nyxAttrMatch(pattern, u)) total++;
}}
return total;
}}
static boolean nyxLdapMatch(String filter, String uid) {{
return nyxLdapCount(filter) > 0
? nyxLdapMatchOne(filter, uid)
: false;
}}
static boolean nyxLdapMatchOne(String filter, String uid) {{
String f = filter.trim();
if (!f.startsWith("(") || !f.endsWith(")")) return true;
String inner = f.substring(1, f.length() - 1);
if (nyxInnerHasBreak(inner)) return true;
if (inner.startsWith("&") || inner.startsWith("|")) {{
List<String> clauses = nyxSplitClauses(inner.substring(1));
if (clauses.isEmpty()) return false;
boolean ok = inner.startsWith("&");
for (String c : clauses) {{
boolean m = nyxLdapMatchOne(c, uid);
ok = inner.startsWith("&") ? (ok && m) : (ok || m);
}}
return ok;
}}
int eq = inner.indexOf('=');
if (eq < 0) return true;
String attr = inner.substring(0, eq);
String pattern = inner.substring(eq + 1);
if (!attr.equalsIgnoreCase("uid") && !attr.equalsIgnoreCase("cn")) return true;
return nyxAttrMatch(pattern, uid);
}}
static List<String> nyxSplitClauses(String src) {{
List<String> out = new ArrayList<>();
int i = 0;
while (i < src.length()) {{
if (src.charAt(i) != '(') {{ i++; continue; }}
int depth = 0;
int start = i;
while (i < src.length()) {{
char c = src.charAt(i);
if (c == '(') depth++;
else if (c == ')') {{
depth--;
if (depth == 0) {{ i++; break; }}
}}
i++;
}}
out.add(src.substring(start, i));
}}
return out;
}}
static void nyxLdapProbe(String filter, int entriesReturned) {{
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\":\"LdapTemplate.search\",\"args\":[{{\"kind\":\"String\",\"value\":\"");
nyxJsonEscape(filter, line);
line.append("\"}}],");
line.append("\"captured_at_ns\":").append(now).append(',');
line.append("\"payload_id\":\"");
nyxJsonEscape(pid, line);
line.append("\",\"kind\":{{\"kind\":\"Ldap\",\"entries_returned\":").append(entriesReturned).append("}},");
line.append("\"witness\":");
line.append(nyxWitnessJson("LdapTemplate.search", new String[]{{filter}}));
line.append("}}\n");
try (FileWriter fw = new FileWriter(p, true)) {{
fw.write(line.toString());
}} catch (IOException e) {{
// best-effort
}}
}}
public static void main(String[] args) {{
String payload = System.getenv("NYX_PAYLOAD");
if (payload == null) payload = "";
String filter = "(uid=" + payload + ")";
int count = nyxLdapCount(filter);
nyxLdapProbe(filter, count);
System.out.println("__NYX_SINK_HIT__");
StringBuilder body = new StringBuilder(64);
body.append("{{\"filter\":\"");
nyxJsonEscape(filter, body);
body.append("\",\"entries_returned\":").append(count).append("}}");
System.out.println(body.toString());
}}
}}
"#
);
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,
}
}
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
/// reading the entry file from disk. Exposed so test helpers can pin a
/// per-fixture shape without round-tripping through [`emit`].

View file

@ -424,6 +424,10 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
if spec.expected_cap == crate::labels::Cap::XXE {
return Ok(emit_xxe_harness(spec));
}
// Phase 06 (Track J.4): LDAP_INJECTION-sink short-circuit.
if spec.expected_cap == crate::labels::Cap::LDAP_INJECTION {
return Ok(emit_ldap_harness(spec));
}
let entry_source = read_entry_source(&spec.entry_file);
let shape = PhpShape::detect(spec, &entry_source);
@ -606,6 +610,137 @@ echo json_encode(["render" => $rendered, "entity_expanded" => $expanded]) . "\n"
}
}
/// Phase 06 — Track J.4 LDAP-injection harness for PHP (`ldap_search`).
///
/// Reads `NYX_PAYLOAD`, splices it into a `(uid=<payload>)` filter,
/// evaluates the filter against the in-sandbox LDAP directory (three
/// users: `alice`, `bob`, `carol`) using the same RFC-4515 subset the
/// [`crate::dynamic::stubs::ldap_server`] stub implements, and writes
/// a `ProbeKind::Ldap { entries_returned }` probe whose `n` is the
/// count the directory returned. Mirrors the synthetic-harness
/// pattern used by Phase 03 / 04 / 05.
pub fn emit_ldap_harness(_spec: &HarnessSpec) -> HarnessSource {
let shim = probe_shim();
let body = format!(
r#"<?php
// Nyx dynamic harness — LDAP_INJECTION ldap_search (Phase 06 / Track J.4).
{shim}
$NYX_LDAP_USERS = ['alice', 'bob', 'carol'];
function _nyx_attr_match(string $pattern, string $uid): bool {{
if ($pattern === '*') return true;
$star = strpos($pattern, '*');
if ($star === false) return $pattern === $uid;
$prefix = substr($pattern, 0, $star);
$suffix = substr($pattern, $star + 1);
return str_starts_with($uid, $prefix) && str_ends_with($uid, $suffix);
}}
function _nyx_split_clauses(string $src): array {{
$out = [];
$i = 0;
$n = strlen($src);
while ($i < $n) {{
if ($src[$i] !== '(') {{ $i++; continue; }}
$depth = 0;
$start = $i;
while ($i < $n) {{
$c = $src[$i];
if ($c === '(') $depth++;
elseif ($c === ')') {{
$depth--;
if ($depth === 0) {{ $i++; break; }}
}}
$i++;
}}
$out[] = substr($src, $start, $i - $start);
}}
return $out;
}}
function _nyx_inner_has_break(string $inner): bool {{
$depth = 0;
$n = strlen($inner);
for ($i = 0; $i < $n; $i++) {{
$c = $inner[$i];
if ($c === '(') $depth++;
elseif ($c === ')') {{
$depth--;
if ($depth < 0) return true;
}}
}}
return false;
}}
function _nyx_match_one(string $filt, string $uid): bool {{
$f = trim($filt);
if (!(str_starts_with($f, '(') && str_ends_with($f, ')'))) return true;
$inner = substr($f, 1, strlen($f) - 2);
if (_nyx_inner_has_break($inner)) return true;
if (str_starts_with($inner, '&') || str_starts_with($inner, '|')) {{
$clauses = _nyx_split_clauses(substr($inner, 1));
if (empty($clauses)) return false;
$is_and = str_starts_with($inner, '&');
$ok = $is_and;
foreach ($clauses as $c) {{
$m = _nyx_match_one($c, $uid);
$ok = $is_and ? ($ok && $m) : ($ok || $m);
}}
return $ok;
}}
$eq = strpos($inner, '=');
if ($eq === false) return true;
$attr = strtolower(substr($inner, 0, $eq));
$pattern = substr($inner, $eq + 1);
if ($attr !== 'uid' && $attr !== 'cn') return true;
return _nyx_attr_match($pattern, $uid);
}}
function _nyx_ldap_count(string $filt, array $users): int {{
$f = trim($filt);
if ($f === '') return 0;
if (!(str_starts_with($f, '(') && str_ends_with($f, ')'))) return count($users);
$inner = substr($f, 1, strlen($f) - 2);
if (_nyx_inner_has_break($inner)) return count($users);
$count = 0;
foreach ($users as $u) {{
if (_nyx_match_one($f, $u)) $count++;
}}
return $count;
}}
function _nyx_ldap_probe(string $filt, int $entries_returned): void {{
$p = getenv('NYX_PROBE_PATH');
if ($p === false || $p === '') return;
$rec = [
'sink_callee' => 'ldap_search',
'args' => [['kind' => 'String', 'value' => $filt]],
'captured_at_ns' => (int) hrtime(true),
'payload_id' => (string) (getenv('NYX_PAYLOAD_ID') ?: ''),
'kind' => ['kind' => 'Ldap', 'entries_returned' => $entries_returned],
'witness' => __nyx_witness('ldap_search', [$filt]),
];
@file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND);
}}
$payload = (string) (getenv('NYX_PAYLOAD') ?: '');
$filt = '(uid=' . $payload . ')';
$count = _nyx_ldap_count($filt, $NYX_LDAP_USERS);
_nyx_ldap_probe($filt, $count);
echo "__NYX_SINK_HIT__\n";
echo json_encode(['filter' => $filt, 'entries_returned' => $count]) . "\n";
"#
);
HarnessSource {
source: body,
filename: "harness.php".to_owned(),
command: vec!["php".to_owned(), "harness.php".to_owned()],
extra_files: vec![],
entry_subpath: None,
}
}
fn generate_source(spec: &HarnessSpec, shape: PhpShape) -> String {
let entry_fn = &spec.entry_name;
let pre_call = build_pre_call(spec, shape);

View file

@ -618,6 +618,17 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
return Ok(emit_xxe_harness(spec));
}
// Phase 06 (Track J.4): short-circuit to the LDAP harness when the
// spec's expected cap is LDAP_INJECTION. The harness splices the
// payload into a `(uid=<payload>)` filter and applies the
// [`crate::dynamic::stubs::ldap_server`] RFC-4515 subset against
// the same three provisioned users; the resulting count drives a
// `ProbeKind::Ldap` probe consumed by the
// `LdapResultCountGreaterThan` oracle.
if spec.expected_cap == crate::labels::Cap::LDAP_INJECTION {
return Ok(emit_ldap_harness(spec));
}
let entry_source = read_entry_source(&spec.entry_file);
let shape = PythonShape::detect(spec, &entry_source);
let body = generate_for_shape(spec, shape);
@ -839,6 +850,140 @@ if __name__ == "__main__":
}
}
/// Phase 06 — Track J.4 LDAP-injection harness for Python
/// (`ldap.search_s`).
///
/// Reads `NYX_PAYLOAD`, splices it into a `(uid=<payload>)` filter,
/// evaluates the filter against the in-sandbox LDAP directory (three
/// users: `alice`, `bob`, `carol`) using the same RFC-4515 subset the
/// [`crate::dynamic::stubs::ldap_server`] stub implements, and writes
/// a `ProbeKind::Ldap { entries_returned }` probe whose `n` is the
/// count the directory returned. Mirrors the synthetic-harness
/// pattern used by Phase 03 / 04 / 05.
pub fn emit_ldap_harness(_spec: &HarnessSpec) -> HarnessSource {
let probe = probe_shim();
let body = format!(
r#"#!/usr/bin/env python3
"""Nyx dynamic harness — LDAP_INJECTION ldap.search_s (Phase 06 / Track J.4)."""
import os, json, sys, time
{probe}
_NYX_LDAP_USERS = ["alice", "bob", "carol"]
def _nyx_attr_match(pattern, uid):
if pattern == "*":
return True
if "*" in pattern:
prefix, _, suffix = pattern.partition("*")
return uid.startswith(prefix) and uid.endswith(suffix)
return pattern == uid
def _nyx_split_clauses(src):
out = []
i = 0
n = len(src)
while i < n:
if src[i] != "(":
i += 1
continue
depth = 0
start = i
while i < n:
c = src[i]
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
i += 1
break
i += 1
out.append(src[start:i])
return out
def _nyx_inner_has_break(inner):
depth = 0
for c in inner:
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth < 0:
return True
return False
def _nyx_match_one(filt, uid):
f = filt.strip()
if not (f.startswith("(") and f.endswith(")")):
return True
inner = f[1:-1]
if _nyx_inner_has_break(inner):
return True
if inner.startswith("&") or inner.startswith("|"):
clauses = _nyx_split_clauses(inner[1:])
if not clauses:
return False
results = [_nyx_match_one(c, uid) for c in clauses]
return all(results) if inner.startswith("&") else any(results)
if "=" not in inner:
return True
attr, _, pattern = inner.partition("=")
if attr.lower() not in ("uid", "cn"):
return True
return _nyx_attr_match(pattern, uid)
def _nyx_ldap_count(filt):
f = (filt or "").strip()
if not f:
return 0
if not (f.startswith("(") and f.endswith(")")):
return len(_NYX_LDAP_USERS)
if _nyx_inner_has_break(f[1:-1]):
return len(_NYX_LDAP_USERS)
return sum(1 for u in _NYX_LDAP_USERS if _nyx_match_one(f, u))
def _nyx_ldap_probe(filt, entries_returned):
rec = {{
"sink_callee": "ldap.search_s",
"args": [{{"kind": "String", "value": filt}}],
"captured_at_ns": time.time_ns(),
"payload_id": os.environ.get("NYX_PAYLOAD_ID", ""),
"kind": {{"kind": "Ldap", "entries_returned": int(entries_returned)}},
"witness": __nyx_witness("ldap.search_s", [filt]),
}}
__nyx_emit(rec)
def _nyx_run():
payload = os.environ.get("NYX_PAYLOAD", "")
filt = "(uid=" + payload + ")"
count = _nyx_ldap_count(filt)
_nyx_ldap_probe(filt, count)
print("__NYX_SINK_HIT__", flush=True)
sys.stdout.write(json.dumps({{"filter": filt, "entries_returned": count}}) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
_nyx_run()
"#
);
HarnessSource {
source: body,
filename: "harness.py".to_owned(),
command: vec!["python3".to_owned(), "harness.py".to_owned()],
extra_files: Vec::new(),
entry_subpath: None,
}
}
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
/// reading the entry file from disk. Exposed so test helpers can pin a
/// per-fixture shape without round-tripping through [`emit`].