From ba996acfcad1698adbf4a1834fe50e73b1c6cab9 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sat, 19 Sep 2026 23:28:22 -0300 Subject: Rename programs: 9player -> 9ns, introspect -> 9proc, app -> web (9web) Directories, binaries, build options (-D9ns, -D9proc), step names, module name (9proc), thread and fs names, env var NINEPLAYER_MOUNT -> NINE_MOUNT, docs and test scripts. Browser assets move to web/static. Co-Authored-By: Claude Fable 5.1 --- 9proc/test/adv_core_hostile.py | 1018 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1018 insertions(+) create mode 100755 9proc/test/adv_core_hostile.py (limited to '9proc/test/adv_core_hostile.py') diff --git a/9proc/test/adv_core_hostile.py b/9proc/test/adv_core_hostile.py new file mode 100755 index 0000000..464876f --- /dev/null +++ b/9proc/test/adv_core_hostile.py @@ -0,0 +1,1018 @@ +#!/usr/bin/env python3 +"""Hostile raw-9P2000 client aimed at the 9proc *core* (stdlib only). + +Complements adv_9proc_hostile.py in this directory (framing, tags, scratch, floods) +with attacks on the freestanding engine's own paths: the /vars tree and its +comptime renderers, snapshot slots, the static tree, the fid table at its +configured maximum, directory-read offsets, msize 24, the ctl staging rule, +and the demo's debug providers driven as black boxes. + +Usage: + adv_core_hostile.py --server zig-out/bin/9proc-demo # spawns it on a temp unix socket + adv_core_hostile.py --socket PATH # attacks a running server + +Exit status is non-zero if any check fails or the server dies. +""" +import argparse +import os +import signal +import struct +import subprocess +import sys +import tempfile +import threading +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import adv_9proc_hostile as base # noqa: E402 +from adv_9proc_hostile import ( # noqa: E402 + NOTAG, Tversion, Tflush, Rflush, Twalk, Rwalk, Topen, Ropen, Rcreate, + Tread, Rread, Twrite, Rwrite, Tclunk, Rclunk, Tremove, Rremove, Tstat, Rstat, Twstat, Rwstat, + Rerror, OREAD, OWRITE, ORDWR, OEXEC, OTRUNC, ORCLOSE, DMDIR, + Nine, frame, s16, mkstat, parse_stat, ok, healthy, expect_dead, +) + +MAX_FIDS = 32768 # demo/main.zig cfg.max_fids +SNAPSHOT_SLOTS = 8 # demo/main.zig cfg.snapshot_slots (per connection) +SCRATCH_BUDGET = 512 << 20 +SCRATCH_MAX_FILE = 64 << 20 + + +def records(d): + """Splits a directory read into (name, raw-record) pairs.""" + out = [] + while d: + n, = struct.unpack_from("/name.""" + c.walk_ok(0, 40, [b"threads"]) + c.open(40, OREAD) + d = c.read_all(40) + c.clunk(40) + for name, _ in records(d): + if c.path_read([b"threads", name, b"name"], fid=41) == b"worker": + return name + return None + + +# --------------------------------------------------------------------------- /vars + + +def attack_vars(path): + print("# /vars: deep walks, hostile names, renderer edge cases, hostile writes") + c = Nine(path) + c.session(1 << 20) + deep = [b"vars", b"state", b"f", b"last_job", b"f", b"id", b".", b"..", b"id", b".", b"..", b"id", b".", b"..", b"id", b"value"] + assert len(deep) == 16 + ok("16-element walk deep into /vars/state/f/... succeeds", c.walk_ok(0, 1, deep) == 16) + rt, _, _ = c.open(1, OREAD) + ok("deep walk lands on a readable value file", rt == Ropen, rt) + c.clunk(1) + up = [b"vars", b"state", b"f", b"inner"] if False else [b"vars", b"state", b"f", b"last_job"] + [b".."] * 12 + n = c.walk_ok(0, 1, up) + ok("12 x '..' from inside /vars climbs to the root and stays there", n == 16, n) + rt, st = c.stat(1) + ok("fid after the climb is the root directory", rt == Rstat and st["qid"][2] == 0xFF << 56, st) + c.clunk(1) + # names that are hex/decimal edge cases or otherwise hostile: never anything but Rerror/partial walk + for nm in (b"0", b"-1", b"0x", b"0x0", b"state\x00", b"State", b" state", b"state ", b"a" * 255, b"a" * 65535, b"\xff\xfe", b"..\x00", b"f", b"value"): + n = c.walk_ok(0, 1, [b"vars", nm]) + ok(f"walk /vars/{nm[:12]!r}{'...' if len(nm) > 12 else ''} is a partial walk (1)", n == 1, n) + ok(" and newfid stays unbound", c.err(Tclunk, struct.pack(" state -> /vars -> /", len(q) == 4 and q[3][2] == 0xFF << 56 and (q[1][0] & 0x80), q) + c.clunk(1) + c.clunk(2) + # every file under /vars/state reads; raw reads beyond @sizeOf are empty + size = int(c.path_read([b"vars", b"state", b"size"])) + ok("/vars/state/size is a number", size > 0, size) + raw = c.path_read([b"vars", b"state", b"raw"]) + ok("/vars/state/raw has exactly @sizeOf bytes", raw is not None and len(raw) == size, (len(raw) if raw else raw, size)) + c.walk_ok(0, 1, [b"vars", b"state", b"raw"]) + c.open(1, OREAD) + rt, d = c.read(1, size, 100) + ok("raw read at offset @sizeOf is empty", rt == Rread and d == b"", (rt, d)) + rt, d = c.read(1, size - 1, 100) + ok("raw read at @sizeOf-1 returns one byte", rt == Rread and len(d) == 1, (rt, d)) + rt, d = c.read(1, (1 << 64) - 1, 100) + ok("raw read at 2^64-1 is empty", rt == Rread and d == b"") + rt, d = c.read(1, 0, 0xFFFFFFFF) + ok("raw read with count 2^32-1 is clamped", rt == Rread and len(d) == size, (rt, len(d) if d else d)) + rt, st = c.stat(1) + ok("raw stat length is @sizeOf and mode 0444", rt == Rstat and st["length"] == size and st["mode"] == 0o444, st) + ok("raw is read-only", c.err(Twrite, struct.pack(" the refused one now opens; clunk via Tremove (denied) also frees the slot + c.clunk(100) + rt, _, _ = c.open(100 + opened, OREAD) + ok("after one clunk the refused open succeeds", rt == Ropen, rt) + ok("remove of an open dynamic file is denied", c.err(Tremove, struct.pack("/stack/x is 'not a directory'", c.walk_ok(0, 1, [b"threads", tid, b"stack"]) == 3 and c.err(Twalk, struct.pack("/stack is 'not a directory'", c.err(Twalk, struct.pack("/../..//name walks", c.walk_ok(0, 1, [b"threads", tid, b"..", b"..", b"threads", tid, b"name"]) == 7) + c.clunk(1) + c.walk_ok(0, 1, [b"threads", tid]) + ok("create under /threads/ is denied", c.err(base.Tcreate, struct.pack(" is denied", c.err(Twstat, struct.pack(" is denied", c.err(Tremove, struct.pack(" reads @sizeOf bytes", rt == Rread and len(d) == size, (rt, len(d) if d else d)) + rt, d = c.read(1, 0, 0xFFFFFFFF) + ok("/mem read with count 2^32-1 is clamped and answered", rt in (Rread, Rerror), rt) + c.clunk(1) + hexd = c.path_read([b"hex", hx]) + ok("/hex/ is a hexdump", hexd is not None and len(hexd) > 64, hexd[:40] if hexd else hexd) + # a value written through /mem must render, not trap: corrupt the phase enum and read /vars/state/value + phase_addr = int(c.path_read([b"vars", b"state", b"f", b"phase", b"addr"]), 16) + c.walk_ok(0, 1, [b"mem", b"%x" % phase_addr]) + c.open(1, OWRITE) + rt, _, _ = c.write(1, 0, b"\xee") + ok("write a corrupt enum byte through /mem", rt == Rwrite, rt) + c.clunk(1) + v = c.path_read([b"vars", b"state", b"value"]) + ok("/vars/state/value renders the corrupt enum as a number instead of trapping", v is not None and b"phase: 238" in v, v) + pv = c.path_read([b"vars", b"state", b"f", b"phase", b"value"]) + ok("/vars/state/f/phase/value renders 238", pv == b"238", pv) + c.walk_ok(0, 1, [b"vars", b"state", b"f", b"phase", b"value"]) + c.open(1, OWRITE) + rt, _, _ = c.write(1, 0, b"idle") + ok("the enum can be repaired through /vars", rt == Rwrite, rt) + c.clunk(1) + # /panic: ctl refuses reads and garbage; message/stack read + ok("/panic/message reads (empty, no panic)", c.path_read([b"panic", b"message"]) == b"") + ok("/panic/stack reads", c.path_read([b"panic", b"stack"]) is not None) + c.walk_ok(0, 1, [b"panic", b"ctl"]) + ok("/panic/ctl refuses OREAD", c.err(Topen, struct.pack("= 1, (len(held), err)) + for f in held: + c.clunk(f) + ok("after clunking, /addr opens again", c.path_read([b"addr", b"1000"]) is not None) + # Tversion with debug files open (hexdumps of the exposed state: mapped memory) + base_addr = int(addr, 16) if addr else 0 + opened = 0 + for i in range(4): + c.walk_ok(0, 300 + i, [b"hex", b"%x" % (base_addr + i)]) + opened += c.open(300 + i, OREAD)[0] == Ropen + ok("four /hex snapshots open", opened == 4, opened) + rt, _, _ = c.version(65536) + ok("Tversion with debug snapshots open", rt == base.Rversion) + c.attach() + ok("/hex still opens after the reset", c.path_read([b"hex", b"%x" % base_addr]) is not None) + ok("/hex of unmapped memory is an Rerror at open, not a crash", c.walk_ok(0, 1, [b"hex", b"3000"]) == 2 and c.open(1, OREAD)[0] == Rerror) + c.clunk(1) + c.close() + ok("server healthy after debug provider attacks", healthy(path)) + + +# --------------------------------------------------------------------------- fids at the maximum + + +def flood(c, ids, names): + """Pipelines one Twalk per id and counts the Rwalk replies; returns (ok_count, error_count, seconds).""" + got = [0, 0] + dead = [False] + + def reader(): + try: + for _ in ids: + rt, _, _ = c.recv_frame() + if rt == Rwalk: + got[0] += 1 + else: + got[1] += 1 + except (EOFError, OSError): + dead[0] = True + + t = threading.Thread(target=reader) + t.start() + t0 = time.time() + body = b"".join(frame(Twalk, i & 0xFFFE, struct.pack(" the handler's writer fails + rt2, d = c.read(1, 0, 100) + rt3, st5 = c.stat(1) + ok("an over-long result is an Rerror and the previous result survives", rt == Rerror and d == b"y" * 100 and st5["length"] == 60000, (rt, d[:10] if d else d, st5["length"])) + c.clunk(1) + c.close() + ok("server healthy after ctl attacks", healthy(path)) + + +# --------------------------------------------------------------------------- fid state machine on scratch + + +def attack_fid_states(path): + print("# fid state machine on /scratch") + c = Nine(path) + c.session() + tag = os.urandom(3).hex().encode() + root = b"fs-" + tag + c.walk_ok(0, 1, [b"scratch"]) + c.create(1, root, DMDIR | 0o755, OREAD) + c.clunk(1) + S = [b"scratch", root] + c.walk_ok(0, 1, S) + rt, _, _ = c.create(1, b"f", 0o644, ORDWR) + ok("create f", rt == Rcreate) + ok("open of an open fid is 'file already open'", c.err(Topen, struct.pack("> 20} MiB fill the {SCRATCH_BUDGET >> 20} MiB budget exactly", made == count, made) + print(f" filled the budget in {time.time() - t0:.1f}s") + c.walk_ok(0, 1, S) + c.create(1, b"one-more", 0o644, OWRITE) + ok("one more byte is 'no space left on device'", c.err(Twrite, struct.pack("