atproto relay implementation in zig zlay.waow.tech
9
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix UAF: dupe FrameWork.hostname per submit instead of borrowing

FrameWork.hostname was a borrowed slice from sub.options.hostname,
documented as "stable lifetime". it isn't: slurper.runWorker frees
sub.options.hostname after sub.run() returns, but FrameWorks for
that subscriber may still be queued in the frame pool. once the
allocator reuses that memory, pool workers read garbage when
logging chain breaks, host authority decisions, etc.

repro: zlay-reconnect cronjob spawns ~1839 hosts in 134s. some
subscribers churn within that window. corrupted hostnames appear
in logs as DIDs (the freed slot got reused for a DID dup) or with
stack-pointer-shaped bytes overlaying the suffix.

fix: dupe hostname alongside data when submitting to the pool,
free both in processFrame. one extra alloc/free per frame.

+12 -2
+2 -1
src/frame_worker.zig
··· 27 27 pub const FrameWork = struct { 28 28 data: []u8, // raw frame bytes (heap-duped by reader, freed by worker) 29 29 host_id: u64, 30 - hostname: []const u8, // borrowed from subscriber (stable lifetime) 30 + hostname: []const u8, // owned (heap-duped at submit, freed by worker) 31 31 allocator: Allocator, 32 32 io: Io, 33 33 // shared references (all thread-safe, all outlive the work item) ··· 41 41 pub fn processFrame(work: *FrameWork) void { 42 42 _ = work.bc.stats.pool_queued_bytes.fetchSub(work.data.len, .monotonic); 43 43 defer work.allocator.free(work.data); 44 + defer work.allocator.free(work.hostname); 44 45 45 46 var arena = std.heap.ArenaAllocator.init(work.allocator); 46 47 defer arena.deinit();
+10 -1
src/subscriber.zig
··· 478 478 break :blk if (d) |s| std.hash.Wyhash.hash(0, s) else sub.options.host_id; 479 479 }; 480 480 const duped = sub.allocator.dupe(u8, data) catch return; 481 + // dupe hostname per-frame: subscriber teardown (slurper.runWorker) 482 + // frees sub.options.hostname after sub.run() returns, but FrameWorks 483 + // can still be queued in the pool. borrowing the slice would be a 484 + // use-after-free (corrupt hostnames in chain-break logs, etc.). 485 + const hostname_dup = sub.allocator.dupe(u8, sub.options.hostname) catch { 486 + sub.allocator.free(duped); 487 + return; 488 + }; 481 489 const t0 = nanoTimestamp(io); 482 490 if (pool.submit(did_key, .{ 483 491 .data = duped, 484 492 .host_id = sub.options.host_id, 485 - .hostname = sub.options.hostname, 493 + .hostname = hostname_dup, 486 494 .allocator = sub.allocator, 487 495 .io = sub.pool_io orelse sub.io, // pool_io (Threaded) for worker-safe ops 488 496 .bc = sub.bc, ··· 500 508 } else { 501 509 // shutdown requested — don't advance cursor so reconnect replays this frame 502 510 sub.allocator.free(duped); 511 + sub.allocator.free(hostname_dup); 503 512 } 504 513 return; 505 514 }