[pitboss/grind] deferred session-0002 (20260520T233019Z-6958)

This commit is contained in:
pitboss 2026-05-20 20:26:13 -05:00
parent 3b49b4d4b5
commit a1a8a2140c
5 changed files with 335 additions and 36 deletions

View file

@ -674,20 +674,33 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
/// Phase 03 — Track J.1 deserialize harness for Java.
///
/// Emits a `NyxHarness.java` whose `main` wraps the sink in a
/// `RestrictedObjectInputStream` style guard. The shim parses the
/// payload (`NYX_GADGET_CLASS:<class>`); any class outside the
/// allowlist (`java.lang.Integer`, `java.lang.String`) writes a
/// Forges a minimal valid Java serialization stream for the marker
/// class name carried by `NYX_PAYLOAD`, then runs it through a
/// `RestrictedObjectInputStream` subclass whose `resolveClass` override
/// enforces a static allowlist (`java.lang.Integer`, `java.lang.String`).
/// When `resolveClass` sees a non-allowlisted class it writes a
/// [`crate::dynamic::probe::ProbeKind::Deserialize`] probe with
/// `gadget_chain_invoked: true` to `NYX_PROBE_PATH` and aborts the
/// chain — this is the resolveClass-driven boundary the brief calls
/// out.
/// `gadget_chain_invoked: true` and throws `InvalidClassException` to
/// abort — matching the JEP-290 / Look-Ahead-OIS hardening pattern
/// real applications use. The blob is built from raw stream bytes
/// (TC_OBJECT → TC_CLASSDESC → class name → SUID → flags → no
/// fields → TC_ENDBLOCKDATA → TC_NULL super) so the resolveClass
/// 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 {
let shim = probe_shim();
let source = format!(
r#"// Nyx dynamic harness — deserialize (Phase 03 / Track J.1).
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InvalidClassException;
import java.io.ObjectInputStream;
import java.io.ObjectStreamClass;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
@ -720,16 +733,59 @@ public class NyxHarness {{
}}
}}
static class NyxRestrictedOIS extends ObjectInputStream {{
NyxRestrictedOIS(InputStream in) throws IOException {{ super(in); }}
@Override
protected Class<?> resolveClass(ObjectStreamClass desc)
throws IOException, ClassNotFoundException {{
String name = desc.getName();
if (!NYX_ALLOWLIST.contains(name)) {{
nyxDeserializeProbe(true);
throw new InvalidClassException(
"Nyx restricted-OIS blocked " + name);
}}
return super.resolveClass(desc);
}}
}}
static byte[] nyxForgeClassDescriptor(String className) throws IOException {{
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(baos);
dos.writeShort((short) 0xACED); // STREAM_MAGIC
dos.writeShort((short) 0x0005); // STREAM_VERSION
dos.writeByte(0x73); // TC_OBJECT
dos.writeByte(0x72); // TC_CLASSDESC
dos.writeUTF(className);
dos.writeLong(0L); // serialVersionUID
dos.writeByte(0x02); // SC_SERIALIZABLE
dos.writeShort(0); // 0 fields
dos.writeByte(0x78); // TC_ENDBLOCKDATA
dos.writeByte(0x70); // TC_NULL (no super class)
return baos.toByteArray();
}}
public static void main(String[] args) {{
String payload = System.getenv("NYX_PAYLOAD");
if (payload == null) payload = "";
String prefix = "NYX_GADGET_CLASS:";
if (payload.startsWith(prefix)) {{
String cls = payload.substring(prefix.length());
if (!NYX_ALLOWLIST.contains(cls)) {{
// RestrictedObjectInputStream.resolveClass would refuse
// here; record the gadget invocation before aborting.
nyxDeserializeProbe(true);
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.
}}
}}
// Sink-reachability sentinel — runner's `vuln_fired && sink_hit`

View file

@ -522,12 +522,18 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
/// Phase 03 — Track J.1 deserialize harness for PHP.
///
/// Wraps a call to `unserialize($input, ['allowed_classes' => false])`.
/// The shim parses the payload's `NYX_GADGET_CLASS:<class>` marker;
/// when the marker class is outside the allowlist (`__primitive_int`)
/// the shim writes a [`crate::dynamic::probe::ProbeKind::Deserialize`]
/// probe with `gadget_chain_invoked: true` — simulating the
/// `__wakeup` observer firing on a `__PHP_Incomplete_Class`.
/// Forges a minimal valid PHP serialized object blob
/// (`O:<len>:"<class>":0:{{}}`) from the marker carried by
/// `NYX_PAYLOAD`, then runs it through `unserialize` with the
/// `allowed_classes` option set to a static allowlist
/// (`__primitive_int`, `__primitive_string`). When the resulting
/// object is `__PHP_Incomplete_Class` and its preserved class name is
/// outside the allowlist, the shim writes a
/// [`crate::dynamic::probe::ProbeKind::Deserialize`] probe with
/// `gadget_chain_invoked: true` — matching the PHP 7+ hardening
/// pattern (`unserialize($s, ['allowed_classes' => […]])`). Both
/// vuln and benign payloads reach the real `unserialize` call; the
/// allowlist post-check distinguishes them.
pub fn emit_deserialize_harness(_spec: &HarnessSpec) -> HarnessSource {
let shim = probe_shim();
let body = format!(
@ -549,15 +555,32 @@ function _nyx_deserialize_probe(bool $invoked): void {{
@file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND);
}}
function _nyx_incomplete_class_name(object $o): string {{
// __PHP_Incomplete_Class stores the original class name on a
// private-named property; casting to array surfaces it under the
// documented `__PHP_Incomplete_Class_Name` key.
$arr = (array) $o;
return (string) ($arr['__PHP_Incomplete_Class_Name'] ?? '');
}}
$payload = (string) (getenv('NYX_PAYLOAD') ?: '');
$prefix = 'NYX_GADGET_CLASS:';
if (strncmp($payload, $prefix, strlen($prefix)) === 0) {{
$cls = substr($payload, strlen($prefix));
$allowed = ['__primitive_int', '__primitive_string'];
if (!in_array($cls, $allowed, true)) {{
_nyx_deserialize_probe(true);
$blob = 'O:' . strlen($cls) . ':"' . $cls . '":0:{{}}';
$result = @unserialize($blob, ['allowed_classes' => $allowed]);
if (is_object($result) && $result instanceof __PHP_Incomplete_Class) {{
$name = _nyx_incomplete_class_name($result);
if (!in_array($name, $allowed, true)) {{
_nyx_deserialize_probe(true);
}}
}}
}}
// Sink-reachability sentinel — runner's `vuln_fired && sink_hit`
// gate consumes this; without it differential confirmation cannot
// fire even when the probe was written.
echo "__NYX_SINK_HIT__\n";
"#
);
HarnessSource {

View file

@ -1265,24 +1265,36 @@ fn indent_lines(src: &str, prefix: &str) -> String {
/// Phase 03 — Track J.1 deserialize harness for Python.
///
/// Reads the payload (`NYX_GADGET_CLASS:<class>`), constructs a
/// `pickle.Unpickler` whose `find_class` override checks the requested
/// module/class against a static allowlist (`builtins.list`,
/// `builtins.dict`, `builtins.int`). Disallowed classes cause the
/// shim to write a [`crate::dynamic::probe::ProbeKind::Deserialize`]
/// probe with `gadget_chain_invoked: true` before aborting. Wraps the
/// probe shim so the probe channel infrastructure works uniformly
/// Reads the payload (`NYX_GADGET_CLASS:<module>.<class>`), forges a
/// minimal real pickle stream containing a `GLOBAL` opcode for that
/// class, and runs it through a `pickle.Unpickler` subclass whose
/// `find_class` override enforces a static allowlist (`builtins.list`,
/// `builtins.dict`, `builtins.int`, `builtins.str`). When the
/// override sees a non-allowlisted class it writes a
/// [`crate::dynamic::probe::ProbeKind::Deserialize`] probe with
/// `gadget_chain_invoked: true` and raises `UnpicklingError` to abort
/// the load — matching real-world `RestrictedUnpickler` hardening
/// (e.g. RestrictedPython, MITRE-CWE-502 mitigation guidance). Wraps
/// the probe shim so the probe channel infrastructure works uniformly
/// across caps.
pub fn emit_deserialize_harness(_spec: &HarnessSpec) -> HarnessSource {
let probe = probe_shim();
let body = format!(
r#"#!/usr/bin/env python3
"""Nyx dynamic harness — deserialize (Phase 03 / Track J.1)."""
import os, json, time
import io
import os
import pickle
import time
{probe}
_NYX_ALLOWLIST = {{"builtins.list", "builtins.dict", "builtins.int", "builtins.str"}}
_NYX_ALLOWLIST = {{
("builtins", "list"),
("builtins", "dict"),
("builtins", "int"),
("builtins", "str"),
}}
def _nyx_deserialize_probe(invoked):
rec = {{
@ -1295,16 +1307,48 @@ def _nyx_deserialize_probe(invoked):
}}
__nyx_emit(rec)
class _NyxRestrictedUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if (module, name) not in _NYX_ALLOWLIST:
_nyx_deserialize_probe(invoked=True)
raise pickle.UnpicklingError(
"Nyx restricted-unpickler blocked %s.%s" % (module, name)
)
return super().find_class(module, name)
def _nyx_forge_pickle_blob(qualified_class):
# GLOBAL (op `c`) is the protocol-0 instruction that drives
# `find_class(module, name)` lookup. Encoding: `c<module>\n<name>\n.`
# the trailing `.` is STOP. rpartition on the last `.` splits a
# qualified name like `nyx.gadget.RCE` into module=`nyx.gadget`,
# name=`RCE`; a bare name without a dot lands in `builtins`.
module, sep, name = qualified_class.rpartition(".")
if not sep:
module, name = "builtins", qualified_class
return (
b"c"
+ module.encode("utf-8")
+ b"\n"
+ name.encode("utf-8")
+ b"\n."
)
def _nyx_run():
payload = os.environ.get("NYX_PAYLOAD", "")
if not payload.startswith("NYX_GADGET_CLASS:"):
return
cls = payload[len("NYX_GADGET_CLASS:"):]
if cls in _NYX_ALLOWLIST:
return
# Non-allowlisted class the RestrictedUnpickler.find_class
# equivalent records the gadget invocation before aborting.
_nyx_deserialize_probe(invoked=True)
qualified = payload[len("NYX_GADGET_CLASS:"):]
blob = _nyx_forge_pickle_blob(qualified)
try:
_NyxRestrictedUnpickler(io.BytesIO(blob)).load()
except pickle.UnpicklingError:
# Restricted block probe already written above.
pass
except (AttributeError, ModuleNotFoundError, ImportError):
# Allow-listed class that doesn't actually resolve at runtime
# (e.g. a stale benign payload) still reaches find_class but
# cannot import; treat as a non-probe path.
pass
if __name__ == "__main__":
_nyx_run()

View file

@ -856,12 +856,43 @@ def _nyx_deserialize_probe(invoked)
File.open(p, 'a') {{ |f| f.write(rec.to_json + "\n") }}
end
# Forge a Marshal v4.8 class-reference blob for `name` (opcode `c`
# followed by a long-encoded symbol). Marshal.load resolves the class
# via `Object.const_get`-style lookup before any instantiation; an
# unknown class raises `ArgumentError: undefined class/module ...`
# the same boundary `Marshal.const_defined?`-style hardening checks.
def _nyx_forge_marshal_class_ref(name)
bytes = name.bytesize
raise ArgumentError, 'class name too long' if bytes >= 256
if bytes == 0
len_byte = "\x00".b
elsif bytes < 123
len_byte = [bytes + 5].pack('C')
else
len_byte = "\x01".b + [bytes].pack('C')
end
"\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)..]
unless allowlist.include?(cls)
_nyx_deserialize_probe(true)
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)
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`