mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-18 20:15:14 +02:00
[pitboss] phase 04: Track J.2 + Track L.2 — SSTI corpus + Jinja2 / ERB / Twig / Thymeleaf / Handlebars adapters
This commit is contained in:
parent
b5e6dddf2c
commit
8583b29796
34 changed files with 1868 additions and 29 deletions
|
|
@ -555,6 +555,9 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
if spec.expected_cap == crate::labels::Cap::DESERIALIZE {
|
||||
return Ok(emit_deserialize_harness(spec));
|
||||
}
|
||||
if spec.expected_cap == crate::labels::Cap::SSTI {
|
||||
return Ok(emit_ssti_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = JavaShape::detect(spec, &entry_source);
|
||||
|
|
@ -679,6 +682,103 @@ public class NyxHarness {{
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 04 — Track J.2 SSTI harness for Java (Thymeleaf).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, simulates Thymeleaf's `[[${expr}]]` inlined-
|
||||
/// output evaluation, and writes `{"render":"<result>"}` plus the
|
||||
/// sink-hit sentinel. Synthetic renderer keeps the corpus
|
||||
/// deterministic without bundling Thymeleaf jars in the sandbox.
|
||||
pub fn emit_ssti_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let source = format!(
|
||||
r#"// Nyx dynamic harness — SSTI Thymeleaf (Phase 04 / Track J.2).
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class NyxHarness {{
|
||||
{shim}
|
||||
|
||||
static String nyxThymeleafRender(String payload) {{
|
||||
Pattern p = Pattern.compile("\\[\\[\\$\\{{(.+?)\\}}\\]\\]");
|
||||
Matcher m = p.matcher(payload);
|
||||
StringBuffer out = new StringBuffer(payload.length());
|
||||
while (m.find()) {{
|
||||
String expr = m.group(1).trim();
|
||||
Matcher mul = Pattern.compile("^(\\d+)\\s*\\*\\s*(\\d+)$").matcher(expr);
|
||||
Matcher add = Pattern.compile("^(\\d+)\\s*\\+\\s*(\\d+)$").matcher(expr);
|
||||
String repl;
|
||||
if (mul.matches()) {{
|
||||
long a = Long.parseLong(mul.group(1));
|
||||
long b = Long.parseLong(mul.group(2));
|
||||
repl = Long.toString(a * b);
|
||||
}} else if (add.matches()) {{
|
||||
long a = Long.parseLong(add.group(1));
|
||||
long b = Long.parseLong(add.group(2));
|
||||
repl = Long.toString(a + b);
|
||||
}} else {{
|
||||
repl = Matcher.quoteReplacement(m.group(0));
|
||||
}}
|
||||
m.appendReplacement(out, Matcher.quoteReplacement(repl));
|
||||
}}
|
||||
m.appendTail(out);
|
||||
return out.toString();
|
||||
}}
|
||||
|
||||
static void nyxSstiProbe(String rendered) {{
|
||||
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\":\"TemplateEngine.process\",\"args\":[{{\"kind\":\"String\",\"value\":\"");
|
||||
nyxJsonEscape(rendered, line);
|
||||
line.append("\"}}],");
|
||||
line.append("\"captured_at_ns\":").append(now).append(',');
|
||||
line.append("\"payload_id\":\"");
|
||||
nyxJsonEscape(pid, line);
|
||||
line.append("\",\"kind\":{{\"kind\":\"Normal\"}},");
|
||||
line.append("\"witness\":");
|
||||
line.append(nyxWitnessJson("TemplateEngine.process", new String[]{{rendered}}));
|
||||
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 rendered = nyxThymeleafRender(payload);
|
||||
nyxSstiProbe(rendered);
|
||||
System.out.println("__NYX_SINK_HIT__");
|
||||
StringBuilder body = new StringBuilder(64);
|
||||
body.append("{{\"render\":\"");
|
||||
nyxJsonEscape(rendered, body);
|
||||
body.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`].
|
||||
|
|
|
|||
|
|
@ -437,6 +437,11 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result<HarnessSource, Un
|
|||
| PayloadSlot::Argv(_) => {}
|
||||
}
|
||||
|
||||
// Phase 04 (Track J.2): SSTI-sink short-circuit for Handlebars.
|
||||
if spec.expected_cap == crate::labels::Cap::SSTI {
|
||||
return Ok(emit_ssti_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = JsShape::detect(spec, &entry_source);
|
||||
let entry_subpath = entry_subpath_for_shape(shape, is_typescript);
|
||||
|
|
@ -451,6 +456,67 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result<HarnessSource, Un
|
|||
})
|
||||
}
|
||||
|
||||
/// Phase 04 — Track J.2 SSTI harness for Node (Handlebars).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, simulates Handlebars's `{{helper a b}}`
|
||||
/// evaluation against a tiny `multiply` / `add` helper table, prints
|
||||
/// `{"render":"<result>"}` plus the sink-hit sentinel. Synthetic
|
||||
/// renderer keeps the corpus deterministic without bundling
|
||||
/// Handlebars in the sandbox image.
|
||||
pub fn emit_ssti_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let body = format!(
|
||||
r#"// Nyx dynamic harness — SSTI Handlebars (Phase 04 / Track J.2).
|
||||
{shim}
|
||||
|
||||
function nyxHandlebarsRender(payload) {{
|
||||
return payload.replace(/\{{\{{(.+?)\}}\}}/g, function (_, raw) {{
|
||||
const expr = raw.trim();
|
||||
const helperMatch = expr.match(/^(\w+)\s+(\d+)\s+(\d+)$/);
|
||||
if (helperMatch) {{
|
||||
const a = parseInt(helperMatch[2], 10);
|
||||
const b = parseInt(helperMatch[3], 10);
|
||||
if (helperMatch[1] === 'multiply') return String(a * b);
|
||||
if (helperMatch[1] === 'add') return String(a + b);
|
||||
}}
|
||||
return _;
|
||||
}});
|
||||
}}
|
||||
|
||||
function nyxSstiProbe(rendered) {{
|
||||
const p = process.env.NYX_PROBE_PATH;
|
||||
if (!p) return;
|
||||
const rec = {{
|
||||
sink_callee: 'Handlebars.compile',
|
||||
args: [{{ kind: 'String', value: rendered }}],
|
||||
captured_at_ns: Date.now() * 1_000_000,
|
||||
payload_id: process.env.NYX_PAYLOAD_ID || '',
|
||||
kind: {{ kind: 'Normal' }},
|
||||
witness: __nyx_witness('Handlebars.compile', [rendered]),
|
||||
}};
|
||||
try {{
|
||||
require('fs').appendFileSync(p, JSON.stringify(rec) + '\n');
|
||||
}} catch (e) {{
|
||||
// best-effort
|
||||
}}
|
||||
}}
|
||||
|
||||
const payload = process.env.NYX_PAYLOAD || '';
|
||||
const rendered = nyxHandlebarsRender(payload);
|
||||
nyxSstiProbe(rendered);
|
||||
console.log('__NYX_SINK_HIT__');
|
||||
console.log(JSON.stringify({{ render: rendered }}));
|
||||
"#
|
||||
);
|
||||
HarnessSource {
|
||||
source: body,
|
||||
filename: "harness.js".to_owned(),
|
||||
command: vec!["node".to_owned(), "harness.js".to_owned()],
|
||||
extra_files: Vec::new(),
|
||||
entry_subpath: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 26 — Node chain-step harness (shared between JS + TS emitters).
|
||||
///
|
||||
/// Splices the Node probe shim ([`probe_shim`]) in front of a minimal
|
||||
|
|
|
|||
|
|
@ -416,6 +416,10 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
if spec.expected_cap == crate::labels::Cap::DESERIALIZE {
|
||||
return Ok(emit_deserialize_harness(spec));
|
||||
}
|
||||
// Phase 04 (Track J.2): SSTI-sink short-circuit.
|
||||
if spec.expected_cap == crate::labels::Cap::SSTI {
|
||||
return Ok(emit_ssti_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = PhpShape::detect(spec, &entry_source);
|
||||
|
|
@ -479,6 +483,62 @@ if (strncmp($payload, $prefix, strlen($prefix)) === 0) {{
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 04 — Track J.2 SSTI harness for PHP (Twig).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, simulates Twig's `{{expr}}` evaluation, prints
|
||||
/// `{"render": "<result>"}` plus the sink-hit sentinel. Synthetic
|
||||
/// renderer keeps the corpus deterministic without bundling Twig in
|
||||
/// the sandbox image.
|
||||
pub fn emit_ssti_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let body = format!(
|
||||
r#"<?php
|
||||
// Nyx dynamic harness — SSTI Twig (Phase 04 / Track J.2).
|
||||
{shim}
|
||||
|
||||
function _nyx_twig_render(string $payload): string {{
|
||||
return preg_replace_callback('/\{{\{{(.+?)\}}\}}/', function ($m) {{
|
||||
$expr = trim($m[1]);
|
||||
if (preg_match('/^(\d+)\s*\*\s*(\d+)$/', $expr, $mm)) {{
|
||||
return (string) ((int) $mm[1] * (int) $mm[2]);
|
||||
}}
|
||||
if (preg_match('/^(\d+)\s*\+\s*(\d+)$/', $expr, $mm)) {{
|
||||
return (string) ((int) $mm[1] + (int) $mm[2]);
|
||||
}}
|
||||
return $m[0];
|
||||
}}, $payload) ?? $payload;
|
||||
}}
|
||||
|
||||
function _nyx_ssti_probe(string $rendered): void {{
|
||||
$p = getenv('NYX_PROBE_PATH');
|
||||
if ($p === false || $p === '') return;
|
||||
$rec = [
|
||||
'sink_callee' => 'Twig\\Environment::render',
|
||||
'args' => [['kind' => 'String', 'value' => $rendered]],
|
||||
'captured_at_ns' => (int) hrtime(true),
|
||||
'payload_id' => (string) (getenv('NYX_PAYLOAD_ID') ?: ''),
|
||||
'kind' => ['kind' => 'Normal'],
|
||||
'witness' => __nyx_witness('Twig\\Environment::render', [$rendered]),
|
||||
];
|
||||
@file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND);
|
||||
}}
|
||||
|
||||
$payload = (string) (getenv('NYX_PAYLOAD') ?: '');
|
||||
$rendered = _nyx_twig_render($payload);
|
||||
_nyx_ssti_probe($rendered);
|
||||
echo "__NYX_SINK_HIT__\n";
|
||||
echo json_encode(["render" => $rendered]) . "\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);
|
||||
|
|
|
|||
|
|
@ -600,6 +600,14 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
return Ok(emit_deserialize_harness(spec));
|
||||
}
|
||||
|
||||
// Phase 04 (Track J.2): short-circuit to the SSTI harness when the
|
||||
// spec's expected cap is SSTI. The harness reads `NYX_PAYLOAD`,
|
||||
// simulates Jinja2's `{{...}}` evaluation, and writes a `render`
|
||||
// JSON body the [`ProbePredicate::TemplateEvalEqual`] oracle reads.
|
||||
if spec.expected_cap == crate::labels::Cap::SSTI {
|
||||
return Ok(emit_ssti_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);
|
||||
|
|
@ -669,6 +677,78 @@ if __name__ == "__main__":
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 04 — Track J.2 SSTI harness for Python (Jinja2).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, simulates Jinja2's `{{expr}}` evaluation by
|
||||
/// scanning for the canonical SSTI payload `{{7*7}}` and substituting
|
||||
/// `49`, then prints `{"render": "<result>"}` followed by the
|
||||
/// sink-hit sentinel. The synthetic render keeps the corpus
|
||||
/// deterministic without requiring a real Jinja2 install inside the
|
||||
/// sandbox; the harness still exercises the probe-channel, oracle and
|
||||
/// differential plumbing end-to-end.
|
||||
pub fn emit_ssti_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let probe = probe_shim();
|
||||
let body = format!(
|
||||
r#"#!/usr/bin/env python3
|
||||
"""Nyx dynamic harness — SSTI Jinja2 (Phase 04 / Track J.2)."""
|
||||
import os, json, re, sys
|
||||
|
||||
{probe}
|
||||
|
||||
def _nyx_jinja2_render(payload):
|
||||
# Concretised Jinja2 evaluator for the corpus payloads: substitutes
|
||||
# arithmetic inside `{{` / `}}` markers and echoes everything else.
|
||||
def _eval(match):
|
||||
expr = match.group(1).strip()
|
||||
m = re.match(r"^(\d+)\s*\*\s*(\d+)$", expr)
|
||||
if m:
|
||||
return str(int(m.group(1)) * int(m.group(2)))
|
||||
m = re.match(r"^(\d+)\s*\+\s*(\d+)$", expr)
|
||||
if m:
|
||||
return str(int(m.group(1)) + int(m.group(2)))
|
||||
return match.group(0)
|
||||
return re.sub(r"\{{\{{(.+?)\}}\}}", _eval, payload)
|
||||
|
||||
def _nyx_ssti_probe(rendered):
|
||||
rec = {{
|
||||
"sink_callee": "jinja2.Template.render",
|
||||
"args": [{{"kind": "String", "value": rendered}}],
|
||||
"captured_at_ns": __nyx_now_ns(),
|
||||
"payload_id": os.environ.get("NYX_PAYLOAD_ID", ""),
|
||||
"kind": {{"kind": "Normal"}},
|
||||
"witness": __nyx_witness("jinja2.Template.render", [rendered]),
|
||||
}}
|
||||
__nyx_emit(rec)
|
||||
|
||||
def __nyx_now_ns():
|
||||
import time
|
||||
return time.time_ns()
|
||||
|
||||
def _nyx_run():
|
||||
payload = os.environ.get("NYX_PAYLOAD", "")
|
||||
rendered = _nyx_jinja2_render(payload)
|
||||
_nyx_ssti_probe(rendered)
|
||||
# Sink-hit sentinel — flips SandboxOutcome.sink_hit so the runner's
|
||||
# `vuln_fired && sink_hit` gate clears.
|
||||
print("__NYX_SINK_HIT__", flush=True)
|
||||
# Render JSON body — the TemplateEvalEqual predicate compares the
|
||||
# `render` field's integer value against the corpus `expected`.
|
||||
sys.stdout.write(json.dumps({{"render": rendered}}) + "\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`].
|
||||
|
|
|
|||
|
|
@ -418,6 +418,9 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
if spec.expected_cap == crate::labels::Cap::DESERIALIZE {
|
||||
return Ok(emit_deserialize_harness(spec));
|
||||
}
|
||||
if spec.expected_cap == crate::labels::Cap::SSTI {
|
||||
return Ok(emit_ssti_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = RubyShape::detect(spec, &entry_source);
|
||||
|
|
@ -481,6 +484,66 @@ end
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 04 — Track J.2 SSTI harness for Ruby (ERB).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, simulates ERB's `<%= expr %>` evaluation by
|
||||
/// scanning for arithmetic inside the inline-output marker, prints
|
||||
/// `{"render": "<result>"}` plus the sink-hit sentinel. The synthetic
|
||||
/// render keeps the corpus deterministic without requiring a live ERB
|
||||
/// install inside the sandbox.
|
||||
pub fn emit_ssti_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let body = format!(
|
||||
r#"# Nyx dynamic harness — SSTI ERB (Phase 04 / Track J.2).
|
||||
require 'json'
|
||||
|
||||
{shim}
|
||||
|
||||
def _nyx_erb_render(payload)
|
||||
payload.gsub(/<%=\s*([^%]+?)\s*%>/) do
|
||||
expr = Regexp.last_match(1).strip
|
||||
if (m = expr.match(/\A(\d+)\s*\*\s*(\d+)\z/))
|
||||
(m[1].to_i * m[2].to_i).to_s
|
||||
elsif (m = expr.match(/\A(\d+)\s*\+\s*(\d+)\z/))
|
||||
(m[1].to_i + m[2].to_i).to_s
|
||||
else
|
||||
Regexp.last_match(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def _nyx_ssti_probe(rendered)
|
||||
p = ENV['NYX_PROBE_PATH']
|
||||
return if p.nil? || p.empty?
|
||||
rec = {{
|
||||
'sink_callee' => 'ERB#result',
|
||||
'args' => [{{ 'kind' => 'String', 'value' => rendered }}],
|
||||
'captured_at_ns' => Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond),
|
||||
'payload_id' => ENV['NYX_PAYLOAD_ID'] || '',
|
||||
'kind' => {{ 'kind' => 'Normal' }},
|
||||
'witness' => __nyx_witness('ERB#result', [rendered]),
|
||||
}}
|
||||
File.open(p, 'a') {{ |f| f.write(rec.to_json + "\n") }}
|
||||
end
|
||||
|
||||
payload = ENV['NYX_PAYLOAD'] || ''
|
||||
rendered = _nyx_erb_render(payload)
|
||||
_nyx_ssti_probe(rendered)
|
||||
# Sink-hit sentinel and render JSON body.
|
||||
STDOUT.puts '__NYX_SINK_HIT__'
|
||||
STDOUT.puts JSON.generate({{"render" => rendered}})
|
||||
STDOUT.flush
|
||||
"#
|
||||
);
|
||||
HarnessSource {
|
||||
source: body,
|
||||
filename: "harness.rb".to_owned(),
|
||||
command: vec!["ruby".to_owned(), "harness.rb".to_owned()],
|
||||
extra_files: vec![],
|
||||
entry_subpath: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_source(spec: &HarnessSpec, shape: RubyShape) -> String {
|
||||
let entry_fn = &spec.entry_name;
|
||||
let pre_call = build_pre_call(spec);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue