# Where must an instance of the egress-taint correlator draw the syscall-completion boundary, and why the naive "post-return" hook is unsound **Date of Record:** 2026 November 12 **Scope:** Applies to sandboxed tool-execution runtimes that combine (a) syscall-level confinement (seccomp-bpf / ptrace-based interception / user-space syscall brokers) with (b) egress-taint correlation, i.e. the propagation of a "this process has touched sensitive data" mark to outbound network operations. Does not apply to pure capability-model sandboxes with no data-flow taint tracking, nor to correlators that operate on whole-process snapshots rather than per-syscall events. ## Core Distinction The correlator has exactly one job that matters at the boundary: decide, for each outbound `connect`/`sendto`/`sendmsg`, whether the calling thread's taint state was established *before* the bytes being sent were materialized. The naive design hooks the syscall *after* the kernel returns to user space (a ptrace `PTRACE_SYSCALL` stop on exit, or a seccomp user-notification `SECCOMP_USER_NOTIF_FLAG_CONTINUE` followed by a post-hoc audit) and then reads the thread's taint bitmap. That design is unsound because the boundary it observes is the *completion* of the send, not the *decision point* of the send. Between the moment the kernel copies the user buffer into the socket and the moment the tracer resumes the thread, the data has already left the sandbox. Taint correlation at that point is forensic, not preventive. The correct boundary is the syscall *entry* stop, evaluated against a taint state that is itself committed at a well-defined prior point: the completion of the syscall that introduced the sensitive data into the address space (typically a `read`/`recvfrom`/`mmap` of a protected fd). The invariant is: **taint(read-completion) happens-before taint(send-entry) for any send whose source buffer overlaps the read's destination range.** Any correlator that evaluates taint at send-*exit* violates this ordering and will pass tainted egress that should have been denied. ## What Was Observed A tool sandbox running a filesystem-reading tool (`read_file` → protected fd → `read()` into a heap buffer → later `writev()` to a logging socket) was instrumented with a ptrace tracer that marked taint on `read` exit and checked taint on `sendmsg` **exit**. Under load, a small but reproducible fraction of tainted sends were *allowed* even though the read had completed and the taint mark was set. The window was not a race in the taint bitmap; it was structural: - The tracer's `sendmsg` exit stop fires *after* `sock_sendmsg` has already enqueued the skb. - The taint check at exit therefore races the network stack, not the user thread. On loopback with `MSG_DONTWAIT`, the skb was often already delivered to the peer before the tracer's check ran. - Worse, a second class of miss appeared: a thread that did `read(protected_fd, buf)` then `sendmsg(sock, buf)` in the *same* syscall-stop window (no intervening stop) could have its `read` exit stop coalesced with the `sendmsg` entry by the tracer's single-step logic under `PTRACE_SYSCALL`, so the taint mark was applied only after the `sendmsg` was already in flight. The observable signature was a taint-correlation log where `taint_set(read)` and `egress_allowed(sendmsg)` shared a timestamp to the microsecond, with `egress_allowed` ordered *before* `taint_set` in the trace ring — an impossible ordering under the intended invariant, and a direct witness of the boundary being drawn at the wrong syscall edge. ## Condition of Validity The entry-stop boundary is only sound if all three of the following hold: 1. **No coalescing.** The tracer must guarantee a distinct stop on the entry of every `send*` syscall, and must not resume the thread past a `send*` entry until the taint check has committed a verdict. Under ptrace this requires `PTRACE_SYSCALL` with no `PTRACE_SINGLESTEP` shortcuts and a per-thread stop queue; under seccomp user-notification it requires the listener to *not* set `SECCOMP_USER_NOTIF_FLAG_CONTINUE` on a tainted send and to instead return `-EPERM`. 2. **Taint commit on read completion, not read entry.** Marking taint at `read` entry is unsound in the other direction: a `read` that returns `-EFAULT` or `0` (EOF) has not introduced data, and marking it taints the thread spuriously, causing false denials. The commit point is the `read` *exit* with a positive return value. 3. **Buffer-range attribution.** The taint check must map the `send*` source buffer (`buf`, `iov`, `msg_iov`) to the address ranges recorded at prior tainted reads. A whole-thread taint bit is sufficient only if the sandbox also forbids the thread from ever holding untainted and tainted data simultaneously; otherwise per-range attribution is required, and the check becomes a range-overlap test at send entry. If any of these fails, the correlator is not providing confinement — it is providing an audit trail that happens to be ordered incorrectly. ## Decision Boundary At `send*` entry, the correlator must choose between three verdicts, and the choice must be made *before* the kernel is allowed to proceed: - **Deny** if the source range overlaps any committed tainted range and the destination is outside the sandbox's egress allowlist. Return `-EPERM` via the user-notification path; never `CONTINUE`. - **Allow-with-mark** if the source range overlaps a tainted range but the destination is on the allowlist *and* the sandbox policy permits tainted egress to that sink (e.g. the internal audit sink). The mark must be recorded so downstream sinks inherit it. - **Allow-clean** only if no overlap. This is the only verdict that may use `SECCOMP_USER_NOTIF_FLAG_CONTINUE` without a recorded mark. The boundary is *not* "did the send succeed." A denied send and a failed send are indistinguishable to the caller except by errno, and conflating them is how the post-return design smuggles tainted egress through: it observes success and treats success as permission. ## Operational Effect Drawing the boundary at send entry with taint committed at read exit eliminated the observed misses and, as a side effect, made the correlator's log total-ordered: every `egress_allowed` event now has a preceding `taint_set` event with a strictly smaller sequence number, which is the property an auditor actually needs. The cost is that the sandbox can no longer use the fast `CONTINUE` path for any send whose source range is not provably disjoint from all tainted ranges — range bookkeeping must be maintained per thread, and the tracer must handle `writev`/`sendmsg` iovec arrays as a set of ranges rather than a single pointer. In practice the range table is small (the tool sandbox bounds protected reads to a fixed number of buffers), so the overlap test is a handful of interval comparisons at entry. The alternative — keeping the post-return hook and calling it "best-effort correlation" — is a category error: it measures egress after the fact and cannot confine it. ```python from __future__ import annotations from dataclasses import dataclass, field class TaintBoundaryError(RuntimeError): """Raised when the correlator is asked to evaluate egress at an unsound boundary.""" @dataclass(frozen=True) class Range: start: int length: int def overlaps(self, other: "Range") -> bool: return self.start < other.start + other.length and other.start < self.start + self.length @dataclass class ThreadTaint: committed: list[Range] = field(default_factory=list) def commit_read(self, dest: Range, ret: int) -> None: # Taint is committed only on read EXIT with a positive return value. if ret <= 0: return self.committed.append(Range(dest.start, min(dest.length, ret))) def is_tainted(self, src: Range) -> bool: return any(src.overlaps(r) for r in self.committed) def check_egress( taint: ThreadTaint, src_ranges: list[Range], dest_allowed: bool, *, boundary: str, ) -> str: if boundary != "entry": # The post-return hook observes completion, not the decision point. raise TaintBoundaryError( f"egress evaluated at boundary={boundary!r}; taint must be checked at syscall entry" ) tainted = any(taint.is_tainted(r) for r in src_ranges) if not tainted: return "allow-clean" if dest_allowed: return "allow-with-mark" # Deny before the kernel enqueues the skb; never CONTINUE a tainted send. raise TaintBoundaryError( "tainted egress to non-allowlisted destination: return -EPERM at entry, do not CONTINUE" ) ```