mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-15 20:05:13 +02:00
feat(dynamic): implement entry-driven verification with fallback to synthetic direct-sink, enhance per-language emitters, and improve test coverage
This commit is contained in:
parent
130bf904eb
commit
738f1fedbc
9 changed files with 686 additions and 116 deletions
|
|
@ -759,8 +759,98 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
/// boundary fires for both vuln and benign payloads; downstream
|
||||
/// instantiation failures (e.g. `serialVersionUID` mismatch on the
|
||||
/// allow-listed payload) are caught and treated as non-probe paths.
|
||||
pub fn emit_deserialize_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
pub fn emit_deserialize_harness(spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
|
||||
// Tier-(a) main: drive the fixture's enclosing entry with the forged
|
||||
// blob so a caller-side mitigation (a `resolveClass` allowlist /
|
||||
// restricted ObjectInputStream subclass) runs before the gadget class
|
||||
// is resolved. Detection is by exception type: a vanilla
|
||||
// ObjectInputStream reaches `resolveClass(gadget)` and raises
|
||||
// ClassNotFoundException (the gadget is not on the classpath) — that is
|
||||
// unrestricted deserialization, so a probe fires. A guarded fixture
|
||||
// raises InvalidClassException at its allowlist check *before* the
|
||||
// class resolves, so no probe is written. Falls back to the tier-(b)
|
||||
// synthetic restricted-OIS path when reflection setup fails.
|
||||
let main_body = if spec.entry_is_derivable() {
|
||||
let class_name = java_entry_class_name(spec);
|
||||
let method_name = &spec.entry_name;
|
||||
format!(
|
||||
r#" public static void main(String[] args) {{
|
||||
String payload = System.getenv("NYX_PAYLOAD");
|
||||
if (payload == null) payload = "";
|
||||
String prefix = "NYX_GADGET_CLASS:";
|
||||
boolean drove = false;
|
||||
if (payload.startsWith(prefix)) {{
|
||||
String cls = payload.substring(prefix.length());
|
||||
// Tier-(a): drive `{class_name}.{method_name}(byte[])` so the
|
||||
// fixture's own (un)restricted deserialization path runs.
|
||||
try {{
|
||||
byte[] blob = nyxForgeClassDescriptor(cls);
|
||||
Class<?> entryCls = Class.forName("{class_name}");
|
||||
java.lang.reflect.Method m = entryCls.getMethod("{method_name}", byte[].class);
|
||||
drove = true;
|
||||
try {{
|
||||
m.invoke(null, (Object) blob);
|
||||
}} catch (java.lang.reflect.InvocationTargetException ite) {{
|
||||
if (nyxCauseChainHas(ite.getCause(), ClassNotFoundException.class)) {{
|
||||
// The fixture's deserializer reached and tried to
|
||||
// resolve the gadget class (unrestricted path).
|
||||
nyxDeserializeProbe(true);
|
||||
}}
|
||||
// InvalidClassException (a caller-side allowlist block)
|
||||
// lands here too but is not a ClassNotFoundException, so
|
||||
// a guarded fixture writes no probe.
|
||||
}} catch (Throwable t) {{
|
||||
// Other reflective-call failure — non-probe path.
|
||||
}}
|
||||
}} catch (Throwable setup) {{
|
||||
// Reflection setup failed (class / method missing) — fall
|
||||
// through to the tier-(b) synthetic path below.
|
||||
drove = false;
|
||||
}}
|
||||
}}
|
||||
if (!drove) {{
|
||||
// Tier-(b): the enclosing entry could not be driven — synthetic
|
||||
// restricted-OIS direct path (recorded as direct-sink fallback).
|
||||
nyxSyntheticDeserialize(payload);
|
||||
}}
|
||||
// Sink-reachability sentinel — runner's `vuln_fired && sink_hit`
|
||||
// gate consumes this; without it differential confirmation cannot
|
||||
// fire even when the probe was written.
|
||||
System.out.println("__NYX_SINK_HIT__");
|
||||
}}
|
||||
|
||||
/// True when `t` or any exception in its cause chain is an instance of
|
||||
/// `want` — used to detect the gadget-class resolution attempt that a
|
||||
/// vanilla ObjectInputStream surfaces as ClassNotFoundException.
|
||||
static boolean nyxCauseChainHas(Throwable t, Class<?> want) {{
|
||||
int hops = 0;
|
||||
while (t != null && hops < 32) {{
|
||||
if (want.isInstance(t)) return true;
|
||||
t = t.getCause();
|
||||
hops++;
|
||||
}}
|
||||
return false;
|
||||
}}
|
||||
"#
|
||||
)
|
||||
} else {
|
||||
// No derivable enclosing entry — drive the synthetic restricted-OIS
|
||||
// path directly.
|
||||
r#" public static void main(String[] args) {
|
||||
String payload = System.getenv("NYX_PAYLOAD");
|
||||
if (payload == null) payload = "";
|
||||
nyxSyntheticDeserialize(payload);
|
||||
// Sink-reachability sentinel — runner's `vuln_fired && sink_hit`
|
||||
// gate consumes this; without it differential confirmation cannot
|
||||
// fire even when the probe was written.
|
||||
System.out.println("__NYX_SINK_HIT__");
|
||||
}
|
||||
"#
|
||||
.to_owned()
|
||||
};
|
||||
|
||||
let source = format!(
|
||||
r#"// Nyx dynamic harness — deserialize (Phase 03 / Track J.1).
|
||||
import java.io.ByteArrayInputStream;
|
||||
|
|
@ -835,36 +925,33 @@ public class NyxHarness {{
|
|||
return baos.toByteArray();
|
||||
}}
|
||||
|
||||
public static void main(String[] args) {{
|
||||
String payload = System.getenv("NYX_PAYLOAD");
|
||||
if (payload == null) payload = "";
|
||||
/// Tier-(b) synthetic direct-sink: run the forged blob through a
|
||||
/// restricted ObjectInputStream the harness controls. Bypasses any
|
||||
/// caller-side guard, so it is used only when the fixture's own entry
|
||||
/// could not be driven.
|
||||
static void nyxSyntheticDeserialize(String payload) {{
|
||||
String prefix = "NYX_GADGET_CLASS:";
|
||||
if (payload.startsWith(prefix)) {{
|
||||
String cls = payload.substring(prefix.length());
|
||||
if (!payload.startsWith(prefix)) return;
|
||||
String cls = payload.substring(prefix.length());
|
||||
try {{
|
||||
byte[] blob = nyxForgeClassDescriptor(cls);
|
||||
NyxRestrictedOIS ois = new NyxRestrictedOIS(
|
||||
new ByteArrayInputStream(blob));
|
||||
try {{
|
||||
byte[] blob = nyxForgeClassDescriptor(cls);
|
||||
NyxRestrictedOIS ois = new NyxRestrictedOIS(
|
||||
new ByteArrayInputStream(blob));
|
||||
try {{
|
||||
ois.readObject();
|
||||
}} finally {{
|
||||
try {{ ois.close(); }} catch (IOException ignored) {{}}
|
||||
}}
|
||||
}} catch (InvalidClassException e) {{
|
||||
// Restricted block — probe already written above.
|
||||
}} catch (Throwable t) {{
|
||||
// Allow-listed but downstream instantiation fails (the
|
||||
// minimal stream omits the field bytes the real class
|
||||
// expects). resolveClass already fired; treat as a
|
||||
// non-probe path.
|
||||
ois.readObject();
|
||||
}} finally {{
|
||||
try {{ ois.close(); }} catch (IOException ignored) {{}}
|
||||
}}
|
||||
}} catch (InvalidClassException e) {{
|
||||
// Restricted block — probe already written above.
|
||||
}} catch (Throwable t) {{
|
||||
// Allow-listed but downstream instantiation fails (the minimal
|
||||
// stream omits the field bytes the real class expects).
|
||||
// resolveClass already fired; treat as a non-probe path.
|
||||
}}
|
||||
// Sink-reachability sentinel — runner's `vuln_fired && sink_hit`
|
||||
// gate consumes this; without it differential confirmation cannot
|
||||
// fire even when the probe was written.
|
||||
System.out.println("__NYX_SINK_HIT__");
|
||||
}}
|
||||
}}
|
||||
|
||||
{main_body}}}
|
||||
"#
|
||||
);
|
||||
HarnessSource {
|
||||
|
|
@ -881,6 +968,18 @@ public class NyxHarness {{
|
|||
}
|
||||
}
|
||||
|
||||
/// Derive the Java class that declares the entry method from the spec's
|
||||
/// `entry_file` basename (Java's public-class-per-file convention: a sink
|
||||
/// in `Vuln.java` lives in `public class Vuln`). Used by the
|
||||
/// deserialize harness to reflectively load the fixture class.
|
||||
fn java_entry_class_name(spec: &HarnessSpec) -> String {
|
||||
std::path::Path::new(&spec.entry_file)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_owned())
|
||||
.unwrap_or_else(|| "NyxEntry".to_owned())
|
||||
}
|
||||
|
||||
/// Phase 04 — Track J.2 SSTI harness for Java (Thymeleaf).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, simulates Thymeleaf's `[[${expr}]]` inlined-
|
||||
|
|
|
|||
|
|
@ -634,7 +634,7 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result<HarnessSource, Un
|
|||
// payload whose JSON literal carries only regular keys leaves
|
||||
// the prototype untouched.
|
||||
if spec.expected_cap == crate::labels::Cap::PROTOTYPE_POLLUTION {
|
||||
return Ok(emit_prototype_pollution_harness(spec));
|
||||
return Ok(emit_prototype_pollution_harness(spec, is_typescript));
|
||||
}
|
||||
|
||||
// Phase 11 (Track J.9): JSON_PARSE depth-bomb short-circuit. The
|
||||
|
|
@ -2611,6 +2611,61 @@ function nyxFollowLocation(location) {{
|
|||
}
|
||||
}
|
||||
|
||||
/// In-harness TypeScript entry loader (CommonJS).
|
||||
///
|
||||
/// Node's bare `require('./entry.ts')` either cannot parse a fixture that
|
||||
/// uses ES-module imports + type annotations, or — under native `.ts`
|
||||
/// loading — applies ESM-namespace interop so a CommonJS dependency's
|
||||
/// members (e.g. `lodash.merge` reached via `import * as _ from 'lodash'`)
|
||||
/// are not exposed on the namespace. This shim reproduces the
|
||||
/// `esModuleInterop` / `module: commonjs` semantics the fixtures were
|
||||
/// authored for: it strips TypeScript types with Node's own
|
||||
/// [`module.stripTypeScriptTypes`] (when available) then rewrites the ES
|
||||
/// module syntax to CommonJS `require` / `module.exports` with default
|
||||
/// interop, and compiles the result as a CommonJS module. On a Node
|
||||
/// build without `stripTypeScriptTypes` the `_compile` throws and the
|
||||
/// caller falls back to the synthetic direct-sink path.
|
||||
const TS_ENTRY_LOADER_JS: &str = r#"function nyxEsmToCjs(src) {
|
||||
const tail = [];
|
||||
src = src
|
||||
.replace(/^\s*import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+)['"];?\s*$/gm, "const $1 = require('$2');")
|
||||
.replace(/^\s*import\s+\{([^}]*)\}\s+from\s+['"]([^'"]+)['"];?\s*$/gm, "const {$1} = require('$2');")
|
||||
.replace(/^\s*import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+)['"];?\s*$/gm, "const $1 = ((m) => (m && m.__esModule ? m.default : m))(require('$2'));")
|
||||
.replace(/^\s*import\s+['"]([^'"]+)['"];?\s*$/gm, "require('$1');");
|
||||
src = src.replace(/^\s*export\s+default\s+/gm, "module.exports.default = ");
|
||||
src = src.replace(/^\s*export\s+((?:async\s+)?function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/gm, function (m, kw, name) { tail.push([name, name]); return kw + " " + name; });
|
||||
src = src.replace(/^\s*export\s+\{([^}]*)\};?\s*$/gm, function (m, names) {
|
||||
names.split(",").map(function (s) { return s.trim(); }).filter(Boolean).forEach(function (spec) {
|
||||
const parts = spec.split(/\s+as\s+/);
|
||||
const local = parts[0].trim();
|
||||
const exported = (parts.length > 1 ? parts[1] : parts[0]).trim();
|
||||
tail.push([exported, local]);
|
||||
});
|
||||
return "";
|
||||
});
|
||||
let suffix = "\n";
|
||||
for (const pair of tail) { suffix += "module.exports[" + JSON.stringify(pair[0]) + "] = " + pair[1] + ";\n"; }
|
||||
return src + suffix;
|
||||
}
|
||||
|
||||
function nyxLoadTsEntry(file) {
|
||||
const fs = require('fs');
|
||||
const Module = require('module');
|
||||
const path = require('path');
|
||||
let src = fs.readFileSync(file, 'utf8');
|
||||
if (typeof Module.stripTypeScriptTypes === 'function') {
|
||||
try { src = Module.stripTypeScriptTypes(src, { mode: 'transform' }); } catch (e) { /* fall through with raw source */ }
|
||||
}
|
||||
src = nyxEsmToCjs(src);
|
||||
const m = new Module(file, module);
|
||||
m.filename = path.resolve(file);
|
||||
m.paths = Module._nodeModulePaths(path.dirname(m.filename));
|
||||
m._compile(src, m.filename);
|
||||
return m.exports;
|
||||
}
|
||||
|
||||
"#;
|
||||
|
||||
/// Phase 10 — Track J.8 prototype-pollution harness for Node
|
||||
/// (`lodash.merge` / `Object.assign` / `JSON.parse`-then-deep-assign).
|
||||
///
|
||||
|
|
@ -2629,9 +2684,14 @@ function nyxFollowLocation(location) {{
|
|||
/// literal has no `__proto__` key — or a fixture that constructs
|
||||
/// its target via `Object.create(null)` — leaves the prototype
|
||||
/// chain untouched and emits no probe.
|
||||
pub fn emit_prototype_pollution_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
pub fn emit_prototype_pollution_harness(spec: &HarnessSpec, is_typescript: bool) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let body = format!(
|
||||
|
||||
// Shared canary-trap preamble: installs the Object.prototype setter
|
||||
// trap *before* any sink runs, so a write that lands on the shared
|
||||
// prototype is observed regardless of whether it came from the
|
||||
// fixture's own merge (tier-a) or the synthetic fallback (tier-b).
|
||||
let preamble = format!(
|
||||
r#"// Nyx dynamic harness — PROTOTYPE_POLLUTION canary trap (Phase 10 / Track J.8).
|
||||
{shim}
|
||||
|
||||
|
|
@ -2699,40 +2759,96 @@ function nyxPrototypePollutionProbe(value) {{
|
|||
}});
|
||||
}})();
|
||||
|
||||
// Phase 10 sink: route the parsed payload through the real
|
||||
// `lodash.merge` pinned at lodash 4.17.4. Lodash hardened `_.merge`
|
||||
// against the `__proto__` key starting in 4.17.5 (well before the
|
||||
// official CVE-2018-16487 fix at 4.17.11 which targeted `_.set` /
|
||||
// `_.setWith`), so the canary only fires against <= 4.17.4. The
|
||||
// staged `package.json` pins this version exactly; `prepare_node`
|
||||
// resolves the dep via `npm install` before the harness runs.
|
||||
// Exercising the real merge implementation (vs the hand-rolled
|
||||
// `nyxDeepMerge` that previously stood in) covers lodash's actual
|
||||
// recursion / cycle / array-vs-object decision shape so a future
|
||||
// fixture that hits a patched range can be added without re-shaping
|
||||
// the harness.
|
||||
const _lodashMerge = require('lodash').merge;
|
||||
|
||||
const payload = process.env.NYX_PAYLOAD || '';
|
||||
let parsed;
|
||||
try {{
|
||||
parsed = JSON.parse(payload);
|
||||
}} catch (e) {{
|
||||
parsed = {{}};
|
||||
}}
|
||||
const target = {{}};
|
||||
try {{
|
||||
_lodashMerge(target, parsed);
|
||||
}} catch (e) {{
|
||||
// lodash.merge can throw on weird inputs; the canary observation
|
||||
// already wrote any probe before the throw.
|
||||
}}
|
||||
console.log('__NYX_SINK_HIT__');
|
||||
console.log(JSON.stringify({{
|
||||
canary_present: Object.prototype.hasOwnProperty(NYX_PP_CANARY),
|
||||
}}));
|
||||
"#
|
||||
);
|
||||
|
||||
// Tier-(b) synthetic direct-sink block. Routes the parsed payload
|
||||
// through the real `lodash.merge` pinned at lodash 4.17.4 (hardened
|
||||
// against `__proto__` from 4.17.5) into a *vanilla* `{}` target. Used
|
||||
// standalone when no enclosing entry is derivable, and as the runtime
|
||||
// fallback inside the entry-driven harness when the fixture cannot be
|
||||
// loaded. NOTE: this drives the sink directly and therefore bypasses
|
||||
// any caller-side mitigation — it must run only when the fixture's own
|
||||
// entry could not be driven.
|
||||
let synthetic_sink = r#" const _lodashMerge = require('lodash').merge;
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(payload); } catch (e) { parsed = {}; }
|
||||
const target = {};
|
||||
try {
|
||||
_lodashMerge(target, parsed);
|
||||
} catch (e) {
|
||||
// lodash.merge can throw on weird inputs; the canary observation
|
||||
// already wrote any probe before the throw.
|
||||
}
|
||||
"#;
|
||||
|
||||
let tail = r#"console.log('__NYX_SINK_HIT__');
|
||||
console.log(JSON.stringify({
|
||||
canary_present: Object.prototype.hasOwnProperty(NYX_PP_CANARY),
|
||||
}));
|
||||
"#;
|
||||
|
||||
let (body, entry_subpath) = if spec.entry_is_derivable() {
|
||||
let entry_subpath = if is_typescript { "entry.ts" } else { "entry.js" };
|
||||
let entry_name = &spec.entry_name;
|
||||
let call_args = pp_entry_call_args(spec);
|
||||
// TypeScript fixtures use ES-module imports + type annotations the
|
||||
// bare CommonJS `require` cannot parse, and Node's native `.ts`
|
||||
// loading applies ESM-namespace interop (so `import * as _ from
|
||||
// 'lodash'` would not expose `_.merge`). Load TS through the
|
||||
// type-stripping + ESM→CJS shim so `esModuleInterop`-style fixtures
|
||||
// run as the author intended. JS fixtures are CommonJS — require
|
||||
// them directly.
|
||||
let loader_defs = if is_typescript { TS_ENTRY_LOADER_JS } else { "" };
|
||||
let entry_load_expr = if is_typescript {
|
||||
format!("nyxLoadTsEntry('./{entry_subpath}')")
|
||||
} else {
|
||||
format!("require('./{entry_subpath}')")
|
||||
};
|
||||
let body = format!(
|
||||
r#"{preamble}
|
||||
{loader_defs}// Tier-(a): drive the fixture's enclosing entry `{entry_name}` so a
|
||||
// caller-side mitigation (a merge target built with `Object.create(null)`,
|
||||
// an allowlist, …) runs *before* the merge sink. The Object.prototype
|
||||
// canary trap above observes any write that reaches the shared prototype,
|
||||
// so a benign fixture that builds a prototype-less target produces no
|
||||
// probe even under the `__proto__` payload.
|
||||
let _drove = false;
|
||||
let _entry;
|
||||
try {{
|
||||
_entry = {entry_load_expr};
|
||||
}} catch (e) {{
|
||||
// load failed (missing dep / unparseable source) — tier-(b) below.
|
||||
}}
|
||||
const _fn = _entry && (typeof _entry === 'function'
|
||||
? _entry
|
||||
: (typeof _entry['{entry_name}'] === 'function'
|
||||
? _entry['{entry_name}']
|
||||
: (typeof _entry.run === 'function' ? _entry.run : null)));
|
||||
if (typeof _fn === 'function') {{
|
||||
try {{
|
||||
_fn({call_args});
|
||||
}} catch (e) {{
|
||||
// The fixture threw after we drove it (e.g. JSON.parse failure or a
|
||||
// guard that raises). We still drove the entry, so do not fall back.
|
||||
}}
|
||||
_drove = true;
|
||||
}}
|
||||
if (!_drove) {{
|
||||
// Tier-(b): the enclosing entry could not be driven at runtime — fall
|
||||
// back to the synthetic direct-sink merge so the harness still emits a
|
||||
// signal. Recorded as a direct-sink fallback in the VerifyTrace.
|
||||
{synthetic_sink}}}
|
||||
{tail}"#
|
||||
);
|
||||
(body, Some(entry_subpath.to_owned()))
|
||||
} else {
|
||||
// No derivable enclosing entry — drive the sink directly.
|
||||
let body = format!("{preamble}\n{synthetic_sink}{tail}");
|
||||
(body, None)
|
||||
};
|
||||
|
||||
HarnessSource {
|
||||
source: body,
|
||||
filename: "harness.js".to_owned(),
|
||||
|
|
@ -2743,7 +2859,23 @@ console.log(JSON.stringify({{
|
|||
"#
|
||||
.to_owned(),
|
||||
)],
|
||||
entry_subpath: None,
|
||||
entry_subpath,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the JS argument list for invoking the prototype-pollution entry
|
||||
/// with the payload routed to its tainted parameter. `PayloadSlot::Param(n)`
|
||||
/// places the payload at position `n` (earlier positions filled with
|
||||
/// `undefined`); every other slot passes the payload as the sole argument
|
||||
/// (the fixture reads its own channel — env / argv — for the rest).
|
||||
fn pp_entry_call_args(spec: &HarnessSpec) -> String {
|
||||
match &spec.payload_slot {
|
||||
crate::dynamic::spec::PayloadSlot::Param(n) => {
|
||||
let mut parts = vec!["undefined"; *n];
|
||||
parts.push("payload");
|
||||
parts.join(", ")
|
||||
}
|
||||
_ => "payload".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1210,15 +1210,21 @@ fn should_stage_framework_dependency_files(spec: &HarnessSpec) -> bool {
|
|||
|
||||
/// Phase 03 — Track J.1 deserialize harness for Ruby.
|
||||
///
|
||||
/// Wraps a call to `Marshal.load(input)` with a const-lookup
|
||||
/// instrumentation that asserts the requested constant is on the
|
||||
/// allowlist (`Integer`, `String`, `Array`). When the marker class
|
||||
/// is outside the allowlist the shim writes a
|
||||
/// [`crate::dynamic::probe::ProbeKind::Deserialize`] probe with
|
||||
/// `gadget_chain_invoked: true`.
|
||||
pub fn emit_deserialize_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
/// Forges a Marshal v4.8 class-reference blob for the corpus
|
||||
/// `NYX_GADGET_CLASS:<cls>` marker and observes whether the gadget class
|
||||
/// is resolved. When the finding's enclosing entry is derivable
|
||||
/// ([`HarnessSpec::entry_is_derivable`]) the harness drives that function
|
||||
/// with the forged blob (tier-a) so a caller-side mitigation — a
|
||||
/// const-name allowlist before `Marshal.load`, a restricted loader — runs
|
||||
/// first and a guarded fixture produces no
|
||||
/// [`crate::dynamic::probe::ProbeKind::Deserialize`] probe. When no entry
|
||||
/// is derivable (or the fixture cannot be loaded at runtime) it falls back
|
||||
/// to driving `Marshal.load` directly (tier-b), which bypasses any
|
||||
/// caller-side guard; that fallback is recorded on the VerifyTrace.
|
||||
pub fn emit_deserialize_harness(spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let body = format!(
|
||||
// Shared helper definitions: probe writer + Marshal class-ref forger.
|
||||
let preamble = format!(
|
||||
r#"# Nyx dynamic harness — deserialize (Phase 03 / Track J.1).
|
||||
require 'json'
|
||||
|
||||
|
|
@ -1256,33 +1262,82 @@ def _nyx_forge_marshal_class_ref(name)
|
|||
"\x04\x08c".b + len_byte + name.b
|
||||
end
|
||||
|
||||
allowlist = ['Integer', 'String', 'Array']
|
||||
payload = ENV['NYX_PAYLOAD'] || ''
|
||||
if payload.start_with?('NYX_GADGET_CLASS:')
|
||||
cls = payload[('NYX_GADGET_CLASS:'.length)..]
|
||||
begin
|
||||
Marshal.load(_nyx_forge_marshal_class_ref(cls))
|
||||
rescue ArgumentError => e
|
||||
# `undefined class/module <ns>` — the Marshal class-resolution
|
||||
# boundary refused the lookup. Real hardening would surface this
|
||||
# via a `Marshal.const_defined?` pre-check + reject; we record the
|
||||
# gadget-class invocation here.
|
||||
if e.message.start_with?('undefined class/module')
|
||||
_nyx_deserialize_probe(true)
|
||||
"#
|
||||
);
|
||||
|
||||
// Tier-(b) synthetic direct-sink: hand the forged blob straight to the
|
||||
// real `Marshal.load`. Bypasses any caller-side guard, so it runs only
|
||||
// when the fixture's own entry could not be driven.
|
||||
let synthetic_sink = r#" if payload.start_with?('NYX_GADGET_CLASS:')
|
||||
cls = payload[('NYX_GADGET_CLASS:'.length)..]
|
||||
begin
|
||||
Marshal.load(_nyx_forge_marshal_class_ref(cls))
|
||||
rescue ArgumentError => e
|
||||
# `undefined class/module <ns>` — Marshal's class-resolution
|
||||
# boundary refused the lookup; record the gadget-class invocation.
|
||||
_nyx_deserialize_probe(true) if e.message.start_with?('undefined class/module')
|
||||
rescue TypeError, NameError
|
||||
# Allow-listed class that resolves cleanly (e.g. `Integer`) — no probe.
|
||||
end
|
||||
rescue TypeError, NameError
|
||||
# Allow-listed class that exists at load time (e.g. `Integer`)
|
||||
# resolves cleanly via `Object.const_get` and Marshal returns the
|
||||
# class object — no rescue path. Other unexpected errors fall
|
||||
# through without writing a probe.
|
||||
end
|
||||
end
|
||||
# Sink-reachability sentinel — runner's `vuln_fired && sink_hit`
|
||||
"#;
|
||||
|
||||
let tail = r#"# Sink-reachability sentinel — runner's `vuln_fired && sink_hit`
|
||||
# gate consumes this; without it differential confirmation cannot
|
||||
# fire even when the probe was written.
|
||||
STDOUT.puts '__NYX_SINK_HIT__'
|
||||
"#
|
||||
);
|
||||
"#;
|
||||
|
||||
let body = if spec.entry_is_derivable() {
|
||||
let entry_basename = derive_entry_basename(&spec.entry_file);
|
||||
let entry_name = &spec.entry_name;
|
||||
format!(
|
||||
r#"{preamble}
|
||||
drove = false
|
||||
if payload.start_with?('NYX_GADGET_CLASS:')
|
||||
cls = payload[('NYX_GADGET_CLASS:'.length)..]
|
||||
blob = _nyx_forge_marshal_class_ref(cls)
|
||||
# Tier-(a): drive the fixture's enclosing entry `{entry_name}` so a
|
||||
# caller-side guard (const-name allowlist, restricted loader) runs
|
||||
# before Marshal.load. A guarded fixture refuses the gadget blob with
|
||||
# its own error and never reaches the unresolved-class boundary, so no
|
||||
# probe is written.
|
||||
loaded = false
|
||||
begin
|
||||
require_relative './{entry_basename}'
|
||||
loaded = true
|
||||
rescue Exception
|
||||
loaded = false
|
||||
end
|
||||
if loaded && Object.new.respond_to?(:'{entry_name}', true)
|
||||
drove = true
|
||||
begin
|
||||
Object.new.__send__(:'{entry_name}', blob)
|
||||
rescue ArgumentError => e
|
||||
# Vanilla Marshal.load reached the gadget class but could not
|
||||
# resolve it → unrestricted deserialization. A caller-side guard
|
||||
# that raises (e.g. "blocked: ...") also lands here but with a
|
||||
# different message, so it does not write a probe.
|
||||
_nyx_deserialize_probe(true) if e.message.start_with?('undefined class/module')
|
||||
rescue TypeError, NameError
|
||||
# Allow-listed class that resolves cleanly — no probe.
|
||||
rescue Exception
|
||||
# Any other failure inside the fixture — no probe.
|
||||
end
|
||||
end
|
||||
end
|
||||
unless drove
|
||||
# Tier-(b): the enclosing entry could not be driven — synthetic
|
||||
# direct-sink fallback (recorded as direct-sink on the VerifyTrace).
|
||||
{synthetic_sink}end
|
||||
{tail}"#
|
||||
)
|
||||
} else {
|
||||
// No derivable enclosing entry — drive Marshal.load directly.
|
||||
format!("{preamble}\n{synthetic_sink}{tail}")
|
||||
};
|
||||
|
||||
HarnessSource {
|
||||
source: body,
|
||||
filename: "harness.rb".to_owned(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue