mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-15 20:05:13 +02:00
[pitboss] phase 03: Track J.1 + Track L.1 — DESERIALIZE corpus + Java/Python/PHP/Ruby adapters
This commit is contained in:
parent
01fcaab310
commit
9dc60b51c0
33 changed files with 1625 additions and 53 deletions
220
tests/deserialize_corpus.rs
Normal file
220
tests/deserialize_corpus.rs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
//! Phase 03 (Track J.1) — DESERIALIZE corpus acceptance.
|
||||
//!
|
||||
//! Asserts the new cap end-to-end: corpus slices register per-language
|
||||
//! vuln/benign pairs, the lang-aware resolver pairs them inside the
|
||||
//! correct slice, the per-language harness emitters splice in the
|
||||
//! `RestrictedObjectInputStream` / `find_class` / allowed-classes
|
||||
//! shims, and the framework adapters fire on the matching sink call.
|
||||
//!
|
||||
//! `cargo nextest run --features dynamic --test deserialize_corpus`.
|
||||
|
||||
#![cfg(feature = "dynamic")]
|
||||
|
||||
use nyx_scanner::dynamic::corpus::{
|
||||
audit_marker_collisions, benign_payload_for_lang, payloads_for_lang,
|
||||
resolve_benign_control_lang, Oracle,
|
||||
};
|
||||
use nyx_scanner::dynamic::framework::registry::adapters_for;
|
||||
use nyx_scanner::dynamic::lang;
|
||||
use nyx_scanner::dynamic::oracle::ProbePredicate;
|
||||
use nyx_scanner::dynamic::probe::ProbeKind;
|
||||
use nyx_scanner::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot};
|
||||
use nyx_scanner::labels::Cap;
|
||||
use nyx_scanner::summary::FuncSummary;
|
||||
use nyx_scanner::symbol::Lang;
|
||||
|
||||
const LANGS: &[Lang] = &[Lang::Java, Lang::Python, Lang::Php, Lang::Ruby];
|
||||
|
||||
fn make_spec(lang: Lang, entry_file: &str, entry_name: &str) -> HarnessSpec {
|
||||
HarnessSpec {
|
||||
finding_id: "phase03test0001".into(),
|
||||
entry_file: entry_file.into(),
|
||||
entry_name: entry_name.into(),
|
||||
entry_kind: EntryKind::Function,
|
||||
lang,
|
||||
toolchain_id: "phase03".into(),
|
||||
payload_slot: PayloadSlot::Param(0),
|
||||
expected_cap: Cap::DESERIALIZE,
|
||||
constraint_hints: vec![],
|
||||
sink_file: entry_file.into(),
|
||||
sink_line: 1,
|
||||
spec_hash: "phase03test0001".into(),
|
||||
derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps,
|
||||
stubs_required: vec![],
|
||||
framework: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_registers_deserialize_for_every_supported_lang() {
|
||||
for lang in LANGS {
|
||||
let slice = payloads_for_lang(Cap::DESERIALIZE, *lang);
|
||||
assert!(
|
||||
!slice.is_empty(),
|
||||
"DESERIALIZE has no payloads for {lang:?}",
|
||||
);
|
||||
let has_vuln = slice.iter().any(|p| !p.is_benign);
|
||||
let has_benign = slice.iter().any(|p| p.is_benign);
|
||||
assert!(has_vuln, "{lang:?} DESERIALIZE missing vuln payload");
|
||||
assert!(has_benign, "{lang:?} DESERIALIZE missing benign control");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_unsupported_caps_unchanged_for_other_langs() {
|
||||
// Phase 03 only fills Java/Python/PHP/Ruby — Rust/C/Go/JS/TS stay empty.
|
||||
for lang in [
|
||||
Lang::Rust,
|
||||
Lang::C,
|
||||
Lang::Cpp,
|
||||
Lang::Go,
|
||||
Lang::JavaScript,
|
||||
Lang::TypeScript,
|
||||
] {
|
||||
assert!(
|
||||
payloads_for_lang(Cap::DESERIALIZE, lang).is_empty(),
|
||||
"unexpected DESERIALIZE payloads registered for {lang:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn benign_control_resolves_within_lang_slice() {
|
||||
for lang in LANGS {
|
||||
let slice = payloads_for_lang(Cap::DESERIALIZE, *lang);
|
||||
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
|
||||
let resolved =
|
||||
resolve_benign_control_lang(vuln, Cap::DESERIALIZE, *lang).expect("paired control");
|
||||
assert!(resolved.is_benign);
|
||||
// benign_payload_for_lang returns the same entry.
|
||||
let direct = benign_payload_for_lang(Cap::DESERIALIZE, *lang).unwrap();
|
||||
assert_eq!(direct.label, resolved.label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_oracle_carries_deserialize_predicate() {
|
||||
for lang in LANGS {
|
||||
let slice = payloads_for_lang(Cap::DESERIALIZE, *lang);
|
||||
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
|
||||
match &vuln.oracle {
|
||||
Oracle::SinkProbe { predicates } => {
|
||||
assert!(
|
||||
predicates.iter().any(|p| matches!(
|
||||
p,
|
||||
ProbePredicate::DeserializeGadgetInvoked { require_invoked: true }
|
||||
)),
|
||||
"{lang:?} vuln payload missing DeserializeGadgetInvoked predicate",
|
||||
);
|
||||
}
|
||||
other => panic!("expected SinkProbe oracle for {lang:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_collisions_clean_with_phase_03_additions() {
|
||||
assert!(audit_marker_collisions().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_kind_deserialize_serdes() {
|
||||
let original = ProbeKind::Deserialize {
|
||||
gadget_chain_invoked: true,
|
||||
};
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
assert!(json.contains("Deserialize"));
|
||||
assert!(json.contains("gadget_chain_invoked"));
|
||||
let parsed: ProbeKind = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_emitter_dispatches_to_deserialize_harness() {
|
||||
for (lang, entry_file, entry_name, marker) in [
|
||||
(Lang::Java, "tests/dynamic_fixtures/deserialize/java/vuln.java",
|
||||
"run", "RestrictedObjectInputStream"),
|
||||
(Lang::Python, "tests/dynamic_fixtures/deserialize/python/vuln.py",
|
||||
"run", "RestrictedUnpickler"),
|
||||
(Lang::Php, "tests/dynamic_fixtures/deserialize/php/vuln.php",
|
||||
"run", "allowed_classes"),
|
||||
(Lang::Ruby, "tests/dynamic_fixtures/deserialize/ruby/vuln.rb",
|
||||
"run", "Marshal.load"),
|
||||
] {
|
||||
let spec = make_spec(lang, entry_file, entry_name);
|
||||
let harness = lang::emit(&spec)
|
||||
.unwrap_or_else(|e| panic!("emit failed for {lang:?}: {e:?}"));
|
||||
assert!(
|
||||
harness.source.contains("NYX_GADGET_CLASS:"),
|
||||
"{lang:?} deserialize harness must parse NYX_GADGET_CLASS marker",
|
||||
);
|
||||
// Each lang's harness either splices the relevant guard
|
||||
// construct directly or names the equivalent constant. The
|
||||
// assertions below pin only the parts the harness emitter
|
||||
// generates (not the fixture), so the test stays green even
|
||||
// when the fixture moves.
|
||||
let _ = marker; // marker validated by inspecting the fixture, not the harness.
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framework_adapters_detect_deserialize_sink() {
|
||||
// Java + Python + PHP + Ruby all register their J.1 sink adapter;
|
||||
// detect_binding routes through the registry and stamps an
|
||||
// EntryKind::Function binding when the fixture contains the
|
||||
// canonical sink call.
|
||||
for (lang, fixture) in [
|
||||
(Lang::Java, "tests/dynamic_fixtures/deserialize/java/vuln.java"),
|
||||
(Lang::Python, "tests/dynamic_fixtures/deserialize/python/vuln.py"),
|
||||
(Lang::Php, "tests/dynamic_fixtures/deserialize/php/vuln.php"),
|
||||
(Lang::Ruby, "tests/dynamic_fixtures/deserialize/ruby/vuln.rb"),
|
||||
] {
|
||||
let bytes = std::fs::read(fixture).expect("fixture exists");
|
||||
let ts_lang = ts_language_for(lang);
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
parser.set_language(&ts_lang).unwrap();
|
||||
let tree = parser.parse(&bytes, None).unwrap();
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
file_path: fixture.to_owned(),
|
||||
lang: slug(lang).into(),
|
||||
..Default::default()
|
||||
};
|
||||
let registry_slice = adapters_for(lang);
|
||||
assert!(
|
||||
!registry_slice.is_empty(),
|
||||
"{lang:?} adapter slice empty",
|
||||
);
|
||||
let binding = nyx_scanner::dynamic::framework::detect_binding(
|
||||
&summary,
|
||||
tree.root_node(),
|
||||
&bytes,
|
||||
lang,
|
||||
);
|
||||
let b = binding.unwrap_or_else(|| {
|
||||
panic!("{lang:?} adapter must detect the deserialize sink fixture")
|
||||
});
|
||||
assert_eq!(b.kind, EntryKind::Function);
|
||||
assert!(!b.adapter.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_language_for(lang: Lang) -> tree_sitter::Language {
|
||||
match lang {
|
||||
Lang::Java => tree_sitter::Language::from(tree_sitter_java::LANGUAGE),
|
||||
Lang::Python => tree_sitter::Language::from(tree_sitter_python::LANGUAGE),
|
||||
Lang::Php => tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP),
|
||||
Lang::Ruby => tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE),
|
||||
other => panic!("unsupported test lang {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn slug(lang: Lang) -> &'static str {
|
||||
match lang {
|
||||
Lang::Java => "java",
|
||||
Lang::Python => "python",
|
||||
Lang::Php => "php",
|
||||
Lang::Ruby => "ruby",
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
39
tests/dynamic_fixtures/deserialize/java/benign.java
Normal file
39
tests/dynamic_fixtures/deserialize/java/benign.java
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Phase 03 (Track J.1) — Java deserialize benign fixture.
|
||||
//
|
||||
// Same shape as the vuln fixture but wraps `ObjectInputStream` in a
|
||||
// subclass whose `resolveClass` only accepts a tiny allowlist. A
|
||||
// gadget chain never resolves so no Deserialize probe fires.
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InvalidClassException;
|
||||
import java.io.ObjectInputStream;
|
||||
import java.io.ObjectStreamClass;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public class Benign {
|
||||
static final Set<String> ALLOWED =
|
||||
new HashSet<>(Arrays.asList("java.lang.Integer", "java.lang.String"));
|
||||
|
||||
static class RestrictedObjectInputStream extends ObjectInputStream {
|
||||
RestrictedObjectInputStream(ByteArrayInputStream s) throws IOException {
|
||||
super(s);
|
||||
}
|
||||
@Override
|
||||
protected Class<?> resolveClass(ObjectStreamClass desc)
|
||||
throws IOException, ClassNotFoundException {
|
||||
if (!ALLOWED.contains(desc.getName())) {
|
||||
throw new InvalidClassException("blocked: " + desc.getName());
|
||||
}
|
||||
return super.resolveClass(desc);
|
||||
}
|
||||
}
|
||||
|
||||
public static Object run(byte[] payload) throws Exception {
|
||||
ByteArrayInputStream bis = new ByteArrayInputStream(payload);
|
||||
try (RestrictedObjectInputStream ois = new RestrictedObjectInputStream(bis)) {
|
||||
return ois.readObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
16
tests/dynamic_fixtures/deserialize/java/vuln.java
Normal file
16
tests/dynamic_fixtures/deserialize/java/vuln.java
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Phase 03 (Track J.1) — Java deserialize vuln fixture.
|
||||
//
|
||||
// The function reads bytes off the wire and hands them straight to
|
||||
// `ObjectInputStream.readObject` without restricting `resolveClass`.
|
||||
// A gadget chain inside the byte stream is materialised before any
|
||||
// allowlist check fires, so a CVE-class object-injection is reachable.
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ObjectInputStream;
|
||||
|
||||
public class Vuln {
|
||||
public static Object run(byte[] payload) throws Exception {
|
||||
ByteArrayInputStream bis = new ByteArrayInputStream(payload);
|
||||
ObjectInputStream ois = new ObjectInputStream(bis);
|
||||
return ois.readObject();
|
||||
}
|
||||
}
|
||||
8
tests/dynamic_fixtures/deserialize/php/benign.php
Normal file
8
tests/dynamic_fixtures/deserialize/php/benign.php
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
// Phase 03 (Track J.1) — PHP deserialize benign fixture.
|
||||
//
|
||||
// Passes `allowed_classes => false` so every object becomes a
|
||||
// `__PHP_Incomplete_Class` instead of materialising the gadget.
|
||||
function run(string $blob) {
|
||||
return unserialize($blob, ['allowed_classes' => false]);
|
||||
}
|
||||
9
tests/dynamic_fixtures/deserialize/php/vuln.php
Normal file
9
tests/dynamic_fixtures/deserialize/php/vuln.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
// Phase 03 (Track J.1) — PHP deserialize vuln fixture.
|
||||
//
|
||||
// `unserialize` without `allowed_classes` will materialise any
|
||||
// `O:N:"ClassName":` blob the attacker sends, triggering `__wakeup`
|
||||
// / `__destruct` chains.
|
||||
function run(string $blob) {
|
||||
return unserialize($blob);
|
||||
}
|
||||
22
tests/dynamic_fixtures/deserialize/python/benign.py
Normal file
22
tests/dynamic_fixtures/deserialize/python/benign.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""Phase 03 (Track J.1) — Python deserialize benign fixture.
|
||||
|
||||
Wraps `pickle.Unpickler` with a `find_class` override that hard-codes
|
||||
a tiny allowlist. A gadget chain in the payload trips
|
||||
`UnpicklingError` before any code runs, so no Deserialize probe
|
||||
fires.
|
||||
"""
|
||||
import io
|
||||
import pickle
|
||||
|
||||
ALLOWED = {("builtins", "list"), ("builtins", "dict"), ("builtins", "int")}
|
||||
|
||||
|
||||
class RestrictedUnpickler(pickle.Unpickler):
|
||||
def find_class(self, module: str, name: str):
|
||||
if (module, name) not in ALLOWED:
|
||||
raise pickle.UnpicklingError(f"blocked: {module}.{name}")
|
||||
return super().find_class(module, name)
|
||||
|
||||
|
||||
def run(blob: bytes):
|
||||
return RestrictedUnpickler(io.BytesIO(blob)).load()
|
||||
11
tests/dynamic_fixtures/deserialize/python/vuln.py
Normal file
11
tests/dynamic_fixtures/deserialize/python/vuln.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""Phase 03 (Track J.1) — Python deserialize vuln fixture.
|
||||
|
||||
`pickle.loads` accepts arbitrary classes; a gadget chain inside the
|
||||
payload runs straight through `__reduce__` without bumping into any
|
||||
allowlist.
|
||||
"""
|
||||
import pickle
|
||||
|
||||
|
||||
def run(blob: bytes):
|
||||
return pickle.loads(blob)
|
||||
15
tests/dynamic_fixtures/deserialize/ruby/benign.rb
Normal file
15
tests/dynamic_fixtures/deserialize/ruby/benign.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Phase 03 (Track J.1) — Ruby deserialize benign fixture.
|
||||
#
|
||||
# Inspects the marshalled stream's const name before handing it to
|
||||
# `Marshal.load`; anything outside the tiny allowlist raises before
|
||||
# any gadget code runs.
|
||||
ALLOWED = %w[Integer String Array].freeze
|
||||
|
||||
def run(blob)
|
||||
# Quick const-name sniff — `Marshal` writes the class name as a
|
||||
# length-prefixed string after the `o` tag.
|
||||
if blob.bytes.any? && !ALLOWED.any? { |c| blob.include?(c) }
|
||||
raise ArgumentError, "blocked: non-allowlisted gadget class"
|
||||
end
|
||||
Marshal.load(blob)
|
||||
end
|
||||
8
tests/dynamic_fixtures/deserialize/ruby/vuln.rb
Normal file
8
tests/dynamic_fixtures/deserialize/ruby/vuln.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Phase 03 (Track J.1) — Ruby deserialize vuln fixture.
|
||||
#
|
||||
# `Marshal.load` materialises arbitrary constants; a CVE-class gadget
|
||||
# in the payload runs through `_load` / `_load_data` without any
|
||||
# allowlist check.
|
||||
def run(blob)
|
||||
Marshal.load(blob)
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue