diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-21 14:23:27 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-21 15:20:27 -0300 |
| commit | 0d7e295efee1fca0935cf4a8bee9629c007dd2b6 (patch) | |
| tree | d371eb028c63da5ab5b506bd25ac6579cf8a93fc /9proc | |
| parent | 3a23f6a29e47ace901bd4d82b9db4055fcc12bb9 (diff) | |
| download | cloud9-0d7e295efee1fca0935cf4a8bee9629c007dd2b6.tar.gz cloud9-0d7e295efee1fca0935cf4a8bee9629c007dd2b6.zip | |
9ns --mntgen: registry subdirectories are mount points too
A registry entry that is a directory is now served the way the root is:
a synthetic directory listing the real one, dialing the sockets inside
it on walk and recursing into further directories, to max_synth_depth
(8) levels across max_synth_dirs (64) synthetic nodes. That is the
plan9port mntgen shape and the layout zmx now posts under, so a live
session reads at /mnt/9p/zmx/<name>. Before this a directory in the
registry was dialed like a socket and answered EIO for good.
post gains the two entry points the traversal needs: postedDir (the
registry scan, against any directory) and dialPath (a dial by composed
path, no name validation).
Hardening, each from an attack that broke the code:
- BATCH_FORGET carries entries for many owners and puts 0 in the header
nodeid, so routing it by the header dropped all of them: 32 of 64
synthetic slots leaked in one close burst and the subdirectories that
held them answered EIO forever. distributeForgets unpacks the body and
hands each entry to its owner.
- probe() and connectBlocking() copied a caller's path into the kernel
address with no bound: a path past sun_path overran the 110-byte stack
sockaddr (a panic in Debug, silent corruption in ReleaseFast). Both
refuse it now, probe as `.live` so a claim never deletes what it could
not inspect.
- That bound then caught 9proc's own listener, which handed probe() the
whole 108-byte sun_path array instead of the path inside it. The probe
reads `.live` for anything it cannot ask about, so every stale socket
became AlreadyListening and no server could ever take a dead
predecessor's name back. It passes the path now.
Suites: 87/87 root (+7 post/serve attack regressions), 48/48 9ns,
51+88 9ns integration (+4 traversal and slot-recycling checks), 213/0
9ns adversarial, 60/60 9proc plus its adversarial suites with a new
stale-socket takeover check, freestanding green.
Diffstat (limited to '9proc')
| -rw-r--r-- | 9proc/src/linux/probe.zig | 7 | ||||
| -rwxr-xr-x | 9proc/test/adv_linux_probe.py | 34 |
2 files changed, 40 insertions, 1 deletions
diff --git a/9proc/src/linux/probe.zig b/9proc/src/linux/probe.zig index c2f1026..00c256e 100644 --- a/9proc/src/linux/probe.zig +++ b/9proc/src/linux/probe.zig @@ -542,7 +542,12 @@ pub fn Probe(comptime Srv: type) type { const st = unixStat(@ptrCast(&sa.path)) catch return error.Occupied; if (st) |s| { if (s.mode & linux.S.IFMT != linux.S.IFSOCK) return error.Occupied; - if (cloud9.post.probe(@ptrCast(&sa.path)) != .stale) return error.AlreadyListening; + // The probe wants the path, not the whole `sun_path` + // array: a 108-byte slice is past the address budget, and + // `probe` owns that uncertainty as `.live` — which would + // make every stale socket look like a live server. + const probe_path: [:0]const u8 = sa.path[0..path.len :0]; + if (cloud9.post.probe(probe_path) != .stale) return error.AlreadyListening; _ = linux.unlink(@ptrCast(&sa.path)); } try p.check(linux.bind(lfd, @ptrCast(&sa), @sizeOf(linux.sockaddr.un))); diff --git a/9proc/test/adv_linux_probe.py b/9proc/test/adv_linux_probe.py index 186f008..c921ebb 100755 --- a/9proc/test/adv_linux_probe.py +++ b/9proc/test/adv_linux_probe.py @@ -289,6 +289,40 @@ def attack_signals(server): ok("SIGTERM unlinks the unix socket (clean stop path)", not os.path.exists(path)) s.stop() + # A server killed outright leaves its socket behind. The next server + # of the same name must recognise the corpse and take the name over: + # the listener probes the path, and a probe that cannot tell answers + # "live", so a caller that hands it the whole 108-byte sun_path array + # instead of the path turns every stale socket into AlreadyListening. + s = Srv(server) + client(s.path, timeout=5) + stale = s.path + s.proc.send_signal(signal.SIGKILL) + s.proc.wait(timeout=5) + ok("SIGKILL leaves the socket behind", os.path.exists(stale)) + taker = subprocess.Popen([server, "--unix", stale], stderr=subprocess.PIPE) + try: + # The path exists throughout (the corpse, then the new socket), so + # the connect itself is the readiness signal. + err, c = None, None + for _ in range(250): + try: + c = client(stale, timeout=10) + break + except OSError as e: + err = e + time.sleep(0.02) + ok("a fresh server takes over a stale socket path", + c is not None and rd(c, [b"build", b"zig_version"])[0] == Rread, + err or taker.poll()) + except Exception as e: + ok("a fresh server takes over a stale socket path", False, e) + finally: + taker.kill() + taker.wait(timeout=5) + if os.path.exists(stale): + os.unlink(stale) + def attack_probe(ns, server): print("# probe loop and admission") |
