mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss/grind] deferred session-0022 (20260516T052512Z-20f8)
This commit is contained in:
parent
c051f58647
commit
1d1975a2ea
4 changed files with 264 additions and 14 deletions
|
|
@ -125,18 +125,21 @@ const PROFILE_SOURCES: &[(&str, &str)] = &[
|
|||
),
|
||||
("ssrf", include_str!("../sandbox_profiles/ssrf.sb")),
|
||||
("deserialize", include_str!("../sandbox_profiles/deserialize.sb")),
|
||||
("xxe", include_str!("../sandbox_profiles/xxe.sb")),
|
||||
];
|
||||
|
||||
/// Cap → profile-name dispatch. The most restrictive matching profile
|
||||
/// wins: filesystem caps outrank network caps outrank CODE_EXEC outranks
|
||||
/// DESERIALIZE. Filesystem-shaped caps (`FILE_IO`, `SQL_QUERY` — DBs are
|
||||
/// files in WORKDIR) map to `path_traversal`; outbound-network-shaped caps
|
||||
/// (`SSRF`, `HEADER_INJECTION`, `OPEN_REDIRECT`, `UNVALIDATED_REDIRECT`,
|
||||
/// `LDAP_INJECTION`, `XPATH_INJECTION`) map to `ssrf` since they share the
|
||||
/// "outbound allowed; host secrets denied" shape. Caps with no shared
|
||||
/// shape (CRYPTO, AUTH, RACE, MEMORY_SAFETY, XSS, XXE) fall back to `base`
|
||||
/// — XXE in particular would want a network-deny profile for entity
|
||||
/// resolution, which the bundled `.sb` set does not yet ship.
|
||||
/// DESERIALIZE outranks XXE. Filesystem-shaped caps (`FILE_IO`,
|
||||
/// `SQL_QUERY` — DBs are files in WORKDIR) map to `path_traversal`;
|
||||
/// outbound-network-shaped caps (`SSRF`, `HEADER_INJECTION`,
|
||||
/// `OPEN_REDIRECT`, `UNVALIDATED_REDIRECT`, `LDAP_INJECTION`,
|
||||
/// `XPATH_INJECTION`) map to `ssrf` since they share the "outbound
|
||||
/// allowed; host secrets denied" shape. `XXE` maps to its own profile
|
||||
/// which denies non-loopback outbound (entity fetch) on top of the
|
||||
/// shared secret-file denylist. Remaining caps with no shared shape
|
||||
/// (CRYPTO, AUTH, RACE, MEMORY_SAFETY, XSS) fall back to `base` because
|
||||
/// they are code-path bugs rather than sandbox-boundary sinks.
|
||||
pub fn profile_for_caps(caps: u32) -> &'static str {
|
||||
// Mirror the bit positions declared in `src/labels/mod.rs`.
|
||||
const FILE_IO: u32 = 1 << 5;
|
||||
|
|
@ -149,6 +152,7 @@ pub fn profile_for_caps(caps: u32) -> &'static str {
|
|||
const HEADER_INJECTION: u32 = 1 << 16;
|
||||
const OPEN_REDIRECT: u32 = 1 << 17;
|
||||
const UNVALIDATED_REDIRECT: u32 = 1 << 18;
|
||||
const XXE: u32 = 1 << 19;
|
||||
|
||||
const FS_SHAPED: u32 = FILE_IO | SQL_QUERY;
|
||||
const NET_SHAPED: u32 =
|
||||
|
|
@ -162,6 +166,8 @@ pub fn profile_for_caps(caps: u32) -> &'static str {
|
|||
"cmdi"
|
||||
} else if caps & DESERIALIZE != 0 {
|
||||
"deserialize"
|
||||
} else if caps & XXE != 0 {
|
||||
"xxe"
|
||||
} else {
|
||||
"base"
|
||||
}
|
||||
|
|
@ -371,14 +377,42 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn profile_for_caps_falls_back_to_base_for_unmapped_caps() {
|
||||
// CRYPTO / AUTH / RACE / MEMORY_SAFETY / XSS / XXE do not yet
|
||||
// have a cap-specific .sb profile. XXE in particular would want
|
||||
// a network-deny profile (entity resolution), but the bundled .sb
|
||||
// set does not ship one — track in deferred.md.
|
||||
// CRYPTO / AUTH / RACE / MEMORY_SAFETY / XSS are code-path bugs
|
||||
// without a sandbox-boundary kill path, so they fall back to the
|
||||
// baseline secret-file denylist.
|
||||
const CRYPTO: u32 = 1 << 11;
|
||||
const XXE: u32 = 1 << 19;
|
||||
const AUTH: u32 = 1 << 12;
|
||||
const RACE: u32 = 1 << 20;
|
||||
const MEMORY_SAFETY: u32 = 1 << 21;
|
||||
const XSS: u32 = 1 << 6;
|
||||
assert_eq!(profile_for_caps(CRYPTO), "base");
|
||||
assert_eq!(profile_for_caps(XXE), "base");
|
||||
assert_eq!(profile_for_caps(AUTH), "base");
|
||||
assert_eq!(profile_for_caps(RACE), "base");
|
||||
assert_eq!(profile_for_caps(MEMORY_SAFETY), "base");
|
||||
assert_eq!(profile_for_caps(XSS), "base");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_for_caps_routes_xxe_to_xxe_profile() {
|
||||
// XXE entity resolution kills via an outbound HTTP / DNS fetch
|
||||
// against an attacker-controlled SYSTEM URL. The dedicated
|
||||
// profile denies non-loopback outbound so the entity fetch faults
|
||||
// before the parser hands the leaked data back.
|
||||
const XXE: u32 = 1 << 19;
|
||||
const DESERIALIZE: u32 = 1 << 8;
|
||||
assert_eq!(profile_for_caps(XXE), "xxe");
|
||||
// DESERIALIZE outranks XXE in the dispatch chain (gadget chains
|
||||
// commonly subsume entity-style payloads).
|
||||
assert_eq!(profile_for_caps(XXE | DESERIALIZE), "deserialize");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_path_materialises_xxe_profile_source() {
|
||||
let path = profile_path("xxe").expect("xxe profile");
|
||||
let contents = std::fs::read_to_string(&path).expect("read .sb");
|
||||
assert!(contents.contains("(version 1)"));
|
||||
assert!(contents.contains("(deny network-outbound)"));
|
||||
assert!(contents.contains("/etc/passwd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
43
src/dynamic/sandbox_profiles/xxe.sb
Normal file
43
src/dynamic/sandbox_profiles/xxe.sb
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
;; Phase 18 (Track E.2) — XXE profile.
|
||||
;;
|
||||
;; XML eXternal Entity (XXE) payloads ship malicious DOCTYPE blocks
|
||||
;; that declare a parameter entity whose SYSTEM identifier points at
|
||||
;; an attacker-controlled URL (`http://attacker.example/leak.dtd`) or
|
||||
;; a host secret (`file:///etc/passwd`). When the parser resolves the
|
||||
;; entity it issues an outbound HTTP request or opens the local file,
|
||||
;; either of which surfaces the leak. This profile blocks both
|
||||
;; kill paths while keeping the harness itself reachable:
|
||||
;;
|
||||
;; * Outbound non-loopback network is denied so the entity fetch
|
||||
;; against `http://attacker.example/...` cannot leave the host.
|
||||
;; Loopback stays open so `StubHarness` endpoints bound on
|
||||
;; 127.0.0.1 / ::1 / localhost remain reachable from the harness.
|
||||
;; * `file://` reads of host secrets (`/etc/passwd` etc.) are
|
||||
;; denied via the standard filesystem denylist. WORKDIR-local
|
||||
;; reads stay open so the harness can read its own XML input.
|
||||
;;
|
||||
;; The denylist mirrors the other per-cap profiles' shape; only the
|
||||
;; `(deny network-outbound)` block is XXE-specific.
|
||||
|
||||
(version 1)
|
||||
(allow default)
|
||||
|
||||
;; Outbound network: deny by default, re-allow loopback so the
|
||||
;; harness ↔ stub IPC over 127.0.0.1 / ::1 keeps working.
|
||||
(deny network-outbound)
|
||||
(allow network-outbound (remote ip "localhost:*"))
|
||||
|
||||
;; Standard filesystem-escape denylist — shared shape with the other
|
||||
;; per-cap profiles. `file://`-scheme entity reads of these paths
|
||||
;; will fault out before the parser hands the contents back.
|
||||
(deny file-read*
|
||||
(literal "/etc/passwd")
|
||||
(literal "/etc/master.passwd")
|
||||
(literal "/etc/shadow")
|
||||
(literal "/etc/sudoers")
|
||||
(literal "/private/etc/passwd")
|
||||
(literal "/private/etc/master.passwd")
|
||||
(literal "/private/etc/shadow")
|
||||
(literal "/private/etc/sudoers")
|
||||
(subpath "/Users")
|
||||
(subpath "/Library/Keychains"))
|
||||
73
tests/dynamic_fixtures/hardening/xxe_probe.py
Normal file
73
tests/dynamic_fixtures/hardening/xxe_probe.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"""Phase 18 (Track E.2) — XXE sandbox-profile probe.
|
||||
|
||||
Simulates the kill path of an XML external-entity payload: the parser
|
||||
sees a SYSTEM identifier pointing at an attacker-controlled URL and
|
||||
issues an outbound HTTP fetch to resolve it. Under the dedicated
|
||||
`xxe.sb` profile the outbound connect is denied at the kernel level
|
||||
and surfaces as `EPERM` (errno=1); under the baseline `(allow
|
||||
default)` the connect proceeds (and times out or hits the reserved
|
||||
TEST-NET-1 unreachable, which is a distinct error class).
|
||||
|
||||
The probe deliberately targets `http://192.0.2.1/leak.dtd` so DNS is
|
||||
out of the picture — `192.0.2.1` is part of TEST-NET-1 (RFC 5737)
|
||||
and never has a route on a real network, so the failure mode is the
|
||||
sandbox EPERM vs. an OS-level connect-fail rather than a DNS lookup
|
||||
quirk.
|
||||
|
||||
Markers printed on stdout:
|
||||
|
||||
xxe:network-denied errno=1 … ← sandbox-exec EPERM (acceptance)
|
||||
xxe:network-attempted <reason> ← sandbox allowed the connect
|
||||
xxe:probe-error <class> <message> ← probe-internal failure
|
||||
|
||||
Exit codes:
|
||||
|
||||
0 — outbound attempt was permitted by the sandbox layer
|
||||
7 — outbound attempt was denied at the kernel (acceptance)
|
||||
9 — probe-internal error before a marker could be emitted
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import socket
|
||||
import sys
|
||||
|
||||
TEST_NET_HOST = "192.0.2.1" # RFC 5737 TEST-NET-1 — never routed.
|
||||
TEST_NET_PORT = 80
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(2.0)
|
||||
try:
|
||||
sock.connect((TEST_NET_HOST, TEST_NET_PORT))
|
||||
except OSError as exc:
|
||||
code = getattr(exc, "errno", None)
|
||||
if code == errno.EPERM:
|
||||
print(f"xxe:network-denied errno={code} {exc}")
|
||||
return 7
|
||||
print(
|
||||
f"xxe:network-attempted errno={code} {type(exc).__name__} {exc}"
|
||||
)
|
||||
return 0
|
||||
# The connect actually succeeded — extraordinarily unlikely on
|
||||
# an unrouted host, but treat it as `network-attempted` too:
|
||||
# the sandbox did not short-circuit the outbound.
|
||||
print(f"xxe:network-attempted connect-succeeded {TEST_NET_HOST}")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"xxe:probe-error {type(exc).__name__} {exc}")
|
||||
return 9
|
||||
finally:
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -107,6 +107,39 @@ except Exception as exc:
|
|||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// XXE probe: simulates an XML parser issuing the outbound HTTP
|
||||
/// fetch for an external SYSTEM entity. Targets TEST-NET-1 so the
|
||||
/// DNS layer is sidestepped; under the `xxe.sb` profile the
|
||||
/// outbound connect is denied with EPERM and the probe exits 7.
|
||||
/// Under a default-allow sandbox the connect attempt proceeds and
|
||||
/// the probe exits 0 with the `network-attempted` marker.
|
||||
///
|
||||
/// The probe source is read in at compile time and written into
|
||||
/// the harness workdir at run time so the sandbox-exec
|
||||
/// `(subpath "/Users")` deny does not block the script load.
|
||||
const XXE_PROBE_SOURCE: &str =
|
||||
include_str!("dynamic_fixtures/hardening/xxe_probe.py");
|
||||
|
||||
fn write_xxe_probe(workdir: &Path) -> PathBuf {
|
||||
let path = workdir.join("xxe_probe.py");
|
||||
std::fs::write(&path, XXE_PROBE_SOURCE).expect("write xxe probe");
|
||||
path
|
||||
}
|
||||
|
||||
fn build_xxe_harness(workdir: &Path) -> BuiltHarness {
|
||||
let probe = write_xxe_probe(workdir);
|
||||
BuiltHarness {
|
||||
workdir: workdir.to_path_buf(),
|
||||
command: vec![
|
||||
"/usr/bin/python3".to_owned(),
|
||||
probe.to_string_lossy().into_owned(),
|
||||
],
|
||||
env: vec![],
|
||||
source: String::new(),
|
||||
entry_source: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Profile selection: `FILE_IO` selects `path_traversal`, etc.
|
||||
#[test]
|
||||
fn profile_for_caps_matches_phase18_table() {
|
||||
|
|
@ -114,9 +147,11 @@ except Exception as exc:
|
|||
const DESERIALIZE: u32 = 1 << 8;
|
||||
const SSRF: u32 = 1 << 9;
|
||||
const CODE_EXEC: u32 = 1 << 10;
|
||||
const XXE: u32 = 1 << 19;
|
||||
assert_eq!(profile_for_caps(FILE_IO), "path_traversal");
|
||||
assert_eq!(profile_for_caps(SSRF), "ssrf");
|
||||
assert_eq!(profile_for_caps(CODE_EXEC), "cmdi");
|
||||
assert_eq!(profile_for_caps(XXE), "xxe");
|
||||
assert_eq!(profile_for_caps(DESERIALIZE), "deserialize");
|
||||
assert_eq!(profile_for_caps(0), "base");
|
||||
}
|
||||
|
|
@ -233,6 +268,71 @@ except Exception as exc:
|
|||
unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) };
|
||||
}
|
||||
|
||||
/// Phase 18 acceptance (c): the XXE entity-resolution kill path
|
||||
/// runs the probe under the `xxe.sb` profile and asserts the
|
||||
/// outbound TCP connect against TEST-NET-1 is denied at the
|
||||
/// kernel layer (EPERM). Sanity-cross-checked against the
|
||||
/// `standard` profile run: without the wrap, the same probe gets
|
||||
/// a non-EPERM error class (or a stub-loopback connect succeeds)
|
||||
/// and exits 0 with the `network-attempted` marker.
|
||||
#[test]
|
||||
fn xxe_outbound_blocked_under_strict_xxe_profile() {
|
||||
unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) };
|
||||
if !sandbox_exec_available() {
|
||||
eprintln!("SKIP: /usr/bin/sandbox-exec missing — cannot exercise xxe profile");
|
||||
return;
|
||||
}
|
||||
const XXE: u32 = 1 << 19;
|
||||
let tmp = workdir();
|
||||
let harness = build_xxe_harness(tmp.path());
|
||||
let opts = strict_opts(XXE);
|
||||
let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run");
|
||||
let stdout = stdout_string(&result);
|
||||
eprintln!("stdout under xxe profile:\n{stdout}");
|
||||
let outcome = macos_outcome(&result).expect("hardening outcome recorded");
|
||||
assert_eq!(outcome.level, HardeningLevel::Sandboxed);
|
||||
assert_eq!(outcome.profile, "xxe");
|
||||
assert!(
|
||||
stdout.contains("xxe:network-denied"),
|
||||
"expected sandbox-exec to deny outbound connect with EPERM; stdout:\n{stdout}"
|
||||
);
|
||||
assert_eq!(
|
||||
result.exit_code,
|
||||
Some(7),
|
||||
"probe should exit 7 on EPERM-denied connect; stdout:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cross-check: the same probe under the `standard` profile (no
|
||||
/// sandbox-exec wrap) does not receive EPERM on the outbound
|
||||
/// connect. This guards against a future regression where every
|
||||
/// fixture starts surfacing EPERM and the `xxe` test passes
|
||||
/// vacuously.
|
||||
#[test]
|
||||
fn xxe_probe_under_standard_does_not_surface_eperm() {
|
||||
unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) };
|
||||
let tmp = workdir();
|
||||
let harness = build_xxe_harness(tmp.path());
|
||||
let opts = standard_opts();
|
||||
let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run");
|
||||
let stdout = stdout_string(&result);
|
||||
eprintln!("stdout under standard:\n{stdout}");
|
||||
assert!(
|
||||
result.hardening_outcome.is_none(),
|
||||
"standard profile should not produce a hardening outcome",
|
||||
);
|
||||
// The probe should NOT report EPERM under the unwrapped run —
|
||||
// it should report `network-attempted` (typical) or
|
||||
// `probe-error` (extremely unlikely). EPERM here would mean
|
||||
// a host-level firewall is independently denying the syscall,
|
||||
// which would mask the sandbox effect.
|
||||
assert!(
|
||||
!stdout.contains("xxe:network-denied"),
|
||||
"standard profile produced an EPERM signal — host firewall \
|
||||
may be masking the sandbox effect; stdout:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Companion to the case above: with `sandbox-exec` reachable the
|
||||
/// flag stays `false` so filesystem oracles run normally.
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue