mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-12 19:55:14 +02:00
[pitboss/grind] deferred session-0002 (20260520T233019Z-6958)
This commit is contained in:
parent
3b49b4d4b5
commit
a1a8a2140c
5 changed files with 335 additions and 36 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue