#!/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, the msize floor, 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, MSIZE_MIN, E_UNKNOWN_FID, E_FID_IN_USE, E_TOO_MANY_FIDS, E_BAD_USE, E_ALREADY_OPEN, E_BAD_OFFSET, E_PERM, E_WSTAT, E_INVAL, ) 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 for I/O'", 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("