the syscall the kernel refused
The first call returned a file descriptor. The second returned Operation not permitted.
BEFORE sandbox:
open(/private/tmp/.../secret.txt): ok fd=3
sandbox_init rc=0 err=(none)
AFTER sandbox:
open(/private/tmp/.../secret.txt): errno=1 (Operation not permitted)
Both calls came from the same line of C:
int fd = open(path, O_RDONLY);
The path did not change. The bytes in the file did not change. The process kept the same user ID. Its working directory stayed where it was. The permission bits were still 0600, which allowed the owner to read and write. One operation occurred between the calls. The process installed a policy on itself.
The second result looks simple only after several layers have compressed it. The CPU crossed a privilege boundary. The kernel decoded a number. A pathname walker turned characters into filesystem objects. Two authorization systems considered the requested read. One returned the integer 1. The kernel put that integer in a register and set one condition flag. A twelve-instruction C library wrapper noticed the flag, wrote a thread-local variable, and returned -1.
Operation not permitted is the last line of that path. I wanted the first one.
The measurements below came from an Apple M4 running macOS 15.7.7, build 24G720, with Darwin 24.6.0 and Apple clang 15.0.0. The installed kernel identifies its source line as XNU 11417.140.69.710.16~1. Apple publishes the nearby open-source release as XNU 11417.140.69. Where the installed binary and published source meet, I name both versions rather than pretending they are identical files.
forty seven bytes hid the first mistake
The old probe tried to avoid the C library and call write with one arm64 instruction:
const char *msg = " wrote this with no libc, straight through svc\n";
register long x0 __asm__("x0") = 1;
register const char *x1 __asm__("x1") = msg;
register long x2 __asm__("x2") = 47;
register long x16 __asm__("x16") = 4;
__asm__ volatile("svc #0x80"
: "+r"(x0)
: "r"(x1), "r"(x2), "r"(x16)
: "cc", "memory");
It appeared to work:
wrote this with no libc, straight through svc
The appearance was misleading. The string contains 48 bytes before its terminating zero. The literal 47 omitted the newline. A following shell prompt happened to make the output look complete in an earlier transcript. That is a small bug, but it belongs in the investigation because syscall interfaces do not know what a C string is. write receives an address and a count. It will not scan for a terminating zero and it will not repair a wrong count.
The corrected probe lets the compiler count the array:
static const char msg[] =
" wrote this with no libc, straight through svc\n";
register long x0 __asm__("x0") = 1;
register const char *x1 __asm__("x1") = msg;
register size_t x2 __asm__("x2") = sizeof(msg) - 1;
register long x16 __asm__("x16") = 4;
__asm__ volatile("svc #0x80"
: "+r"(x0)
: "r"(x1), "r"(x2), "r"(x16)
: "cc", "memory");
sizeof(msg) is 49 because the array also contains the zero byte. Subtracting one requests all 48 visible bytes. Standard output is file descriptor 1, so x0 begins as 1. x1 holds the address of the first byte. x2 holds 48. XNU’s arm64 BSD syscall number for write is 4, so x16 holds 4.
The compiler constraint on x0 is "+r" rather than "r". The plus means that the assembly both reads and changes the register. On entry it contains the descriptor. On return it contains either the byte count or an error number. Failing to describe that mutation gives the optimizer permission to reuse a value that no longer exists in the register.
Disassembly contains the expected trap:
svc #0x80
svc means supervisor call. The instruction raises a synchronous exception. The processor stops following the user program’s ordinary instruction stream and enters an exception vector selected by the operating system. The immediate 0x80 is part of the Darwin arm64 convention. The syscall number is still in x16; the immediate is not the number 4.
Calling this “one instruction” can conceal most of the contract. Four registers must be prepared. The inline assembly must state which values can change. The ABI determines where arguments live. The kernel must preserve or replace specific state before returning. The output count must already be correct. The one instruction is the crossing, not the whole request.
open was not the function on the other side
The source program calls open, yet the installed libsystem_kernel.dylib does not put svc at the public _open symbol. _open is variadic because O_CREAT adds a mode argument. It handles that C interface and eventually calls an internal symbol named ___open.
I extracted only that internal symbol from the installed binary:
___open:
0000000000001670 mov x16, #0x5
0000000000001674 svc #0x80
0000000000001678 b.lo 0x1698
000000000000167c pacibsp
0000000000001680 stp x29, x30, [sp, #-0x10]!
0000000000001684 mov x29, sp
0000000000001688 bl _cerror
000000000000168c mov sp, x29
0000000000001690 ldp x29, x30, [sp], #0x10
0000000000001694 retab
0000000000001698 ret
The first instruction puts 5 in x16. XNU’s syscall table assigns number 5 to open. The second instruction crosses into the kernel.
The third instruction is where success and failure separate. b.lo branches when the unsigned lower condition is true, which here means the carry flag is clear. A successful kernel return therefore jumps straight to ret. A failed return leaves carry set, falls through to _cerror, and only then returns to the caller.
Pointer authentication instructions such as pacibsp and retab protect the saved return address on this arm64e-flavored system library. They are needed only on the error path because the successful path is a leaf that does not create a stack frame or call another function. The refusal costs more user-space instructions than success, although that difference is irrelevant beside pathname lookup and policy evaluation.
This disassembly corrected a loose mental model. The kernel does not return the public C convention directly. It returns a Darwin arm64 kernel convention. The library translates it.
the carry bit crossed the boundary
The translation becomes visible by issuing open directly and reading the condition flag before any C wrapper can touch it:
struct raw_result {
uint64_t x0;
uint32_t carry;
};
static struct raw_result raw_open(const char *path) {
register uint64_t x0 __asm__("x0") = (uint64_t)path;
register uint64_t x1 __asm__("x1") = O_RDONLY;
register uint64_t x2 __asm__("x2") = 0;
register uint64_t x16 __asm__("x16") = 5;
uint32_t carry;
__asm__ volatile(
"svc #0x80\n\t"
"cset %w[carry], cs"
: "+r"(x0), [carry] "=r"(carry)
: "r"(x1), "r"(x2), "r"(x16)
: "cc", "memory");
return (struct raw_result){.x0 = x0, .carry = carry};
}
cset converts one condition into an ordinary integer. cs means carry set. It writes 1 after an error and 0 after success.
Against the readable file, the direct request returned:
raw open: carry=0 fd=3
With the selective file policy already installed, the same binary returned:
raw open: carry=1 x0=1
There is no errno in this probe. There is no _cerror. The kernel returned the error number 1 in x0 and marked it as an error by setting carry. Calling libc’s open with the same policy produced:
open(/private/tmp/.../secret.txt): errno=1 (Operation not permitted)
The two results expose the translation:
kernel success: x0 = file descriptor, carry = 0
libc success: return file descriptor
kernel failure: x0 = positive error number, carry = 1
libc failure: return -1, errno = error number
The published XNU source says the same thing in executable form. In arm_prepare_u64_syscall_return, a nonzero error is copied into saved register x0, x1 is cleared, and PSR64_CF is added to the saved processor status. The comment says that the carry bit triggers the C error routine.
The raw call also disproves a tempting escape. Replacing libc with inline assembly does not bypass the policy. Libc did not make the denial. It formatted the denial into the interface C programs expect.
two kernel results never became errno
The XNU return function has two branches before its ordinary error handling:
if (error == ERESTART) {
add_user_saved_state_pc(regs, -4);
} else if (error != EJUSTRETURN) {
if (error) {
/* put error in x0 and set carry */
} else {
/* put successful result in x0 */
}
}
ERESTART and EJUSTRETURN are internal control results. They are not ordinary positive error numbers intended for an application to print.
For ERESTART, XNU subtracts four from the saved program counter. An arm64 instruction is four bytes, so returning to user mode reaches the syscall instruction again. This supports restart behavior around interrupted operations. The kernel does not send ERESTART through _cerror for the application to decode.
EJUSTRETURN tells the common return preparation to leave saved state alone. A path that already constructed the complete user return state can use it to avoid being overwritten by the normal result machinery.
Signals make syscall interruption observable at the C interface. Depending on the operation, signal disposition, restart flags, and OS semantics, a blocking call may resume automatically or return EINTR. Code still needs to handle partial progress. A read that transferred bytes before interruption can return a short positive count rather than -1.
These branches matter to the phrase “the kernel returned.” User space does not receive every internal kernel result as errno. The kernel can adjust the saved instruction pointer, preserve a specially prepared register state, convert an internal condition, or arrange a signal before resuming the thread.
The selective file denial takes the ordinary error path. It returns EPERM, becomes x0=1 plus carry, and reaches _cerror. The internal branches explain why that path should be demonstrated rather than assumed for every unsuccessful syscall.
errno was already old
errno often gets described as the error from the last system call. That sentence is unsafe. A successful function does not generally reset it.
This probe deliberately puts 33, EDOM, in errno before a successful open:
errno = EDOM;
int fd = open(existing_path, O_RDONLY);
printf("successful open: return=%d errno-after=%d\n", fd, errno);
fd = open(missing_path, O_RDONLY);
int saved_errno = errno;
printf("failed open: return=%d errno=%d (%s)\n",
fd, saved_errno, strerror(saved_errno));
The result was:
successful open: return=3 errno-after=33
failed open: return=-1 errno=2 (No such file or directory)
The successful open left the unrelated 33 in place. Only the return value says whether errno is meaningful. For open, any nonnegative result is a descriptor. -1 means the library has stored the failure number.
The probe copies errno immediately after failure because even an error-reporting function can perform work. Modern library implementations preserve errno in many common paths, but code that diagnoses a failure should not make the diagnostic call part of the evidence chain. Capture the integer, then turn it into text.
errno is thread-local on this system. If two threads fail at the same time, each needs its own value. That design follows from the wrapper path: _cerror runs on the calling thread and writes that thread’s error slot. A single process-global integer would race on nearly every concurrent program.
Most importantly, errno is an interface outcome. It is not a signed note from the exact kernel subsystem that produced it. Several paths can return EACCES. Several can return EPERM. A policy can intentionally choose an error that looks like another failure. The same internal failure can be translated on its way upward. The integer narrows the investigation, but it does not identify the author by itself.
the kernel had more than one door
The phrase “every syscall reaches one handler” is too smooth for XNU. XNU combines a Mach kernel with BSD process and Unix interfaces. The arm64 exception machinery classifies traps and dispatches different classes of request. Mach traps and BSD system calls do not share one flat numbering table merely because user code can enter both with svc.
For the open probe, the relevant path is the BSD side. Published XNU reaches unix_syscall. That function reads the syscall number from saved state, bounds-checks it against the syscall table, obtains a sysent entry, copies arguments into the thread’s BSD syscall area, and calls the selected function pointer:
syscode = (code < nsysent) ? code : SYS_invalid;
callp = &sysent[syscode];
error = (*(callp->sy_call))(
proc,
&uthread->uu_arg[0],
&uthread->uu_rval[0]);
The table is more than a number-to-name list. Each entry carries argument and return information used by dispatch. An out-of-range number selects SYS_invalid rather than indexing arbitrary kernel memory. A valid number reaches a function whose signature matches the generated syscall machinery.
Before calling that function, current XNU can perform a process-wide syscall filter check through the Mandatory Access Control Framework:
if (proc_syscall_filter_mask(proc) != NULL &&
!bitstr_test(proc_syscall_filter_mask(proc), syscode)) {
error = mac_proc_check_syscall_unix(proc, syscode);
if (error) {
goto skip_syscall;
}
}
That is one possible refusal point. If it returns an error, the actual open implementation is skipped. It is not the only point, and the experiment does not establish that this early mask produced the selective file denial. A filter at this level sees that syscall 5 was requested. It cannot distinguish one allowed path from one forbidden path without inspecting more state elsewhere.
If dispatch proceeds, syscall number 5 reaches XNU’s BSD open function. The public source calls a cancellation check and then open_nocancel. That reaches openat_internal, which creates a nameidata object and starts the file path:
unix_syscall
sysent[5].sy_call
open
open_nocancel
openat_internal
open1at
open1
vn_open_auth
The function names matter less than what changes between them. User registers become typed kernel arguments. A user-space address becomes a copied pathname. A pathname becomes a vnode, XNU’s in-memory representation of a filesystem object. Requested flags become authorization actions such as read data or write data. A successful vnode becomes an entry in the process’s descriptor table.
The file descriptor returned at the top is not the file. It is a small integer naming one descriptor-table entry. That entry refers to a kernel file object, which refers to a vnode and holds state such as flags and current offset. This is why descriptor 3 can be reused after it is closed. The number belongs to the process table slot, not permanently to secret.txt.
the path was a request, not an object
The direct syscall passes a pointer in x0. The pointer names bytes in the caller’s virtual address space. The kernel cannot safely treat it as one of its own pointers. User memory may be unmapped, may cross a page boundary, or may change while the operation is in progress.
openat_internal initializes a pathname lookup:
NDINIT(ndp, LOOKUP, OP_OPEN, FOLLOW | AUDITVNPATH1,
segflg, path, ctx);
Later namei walks the components. For /private/tmp/run/secret.txt, the operation is not one dictionary lookup. It begins at the process’s root, resolves private, searches that directory for tmp, searches tmp for run, and then finds secret.txt. Every directory traversal needs search permission. Mount points can change the filesystem implementation under the walk. Symbolic links can replace the remaining text with another path. .. can move upward subject to the process’s root and lookup rules.
That gives open several failure classes before file data is considered:
EFAULT pathname bytes could not be copied from user memory
ENOENT a required component did not exist
ENOTDIR an intermediate component was not a directory
ELOOP symbolic-link resolution exceeded its bound
ENAMETOOLONG pathname or a component exceeded an accepted size
EACCES traversal or requested access was refused
EPERM another policy refused the operation
EMFILE this process had no free descriptor slot
ENFILE the system-wide file table could not accept another open
Some of these arise before a target vnode exists. Others arise after lookup. EMFILE can occur while allocating the descriptor structure, before the path completes. A filesystem can return implementation-specific errors from its own vnode operation. A cancellation point can turn an interruption into EINTR.
One open therefore crosses three resource namespaces: the process’s virtual memory supplies the string, the filesystem namespace resolves the string, and the process descriptor table receives the result. A denial at any of them is compressed into -1 plus an integer.
The lookup code has its own MAC hook. In XNU 11417.140.69, lookup_authorize_search first calls normal vnode authorization for directory search and then mac_vnode_check_lookup. A sandbox can therefore refuse traversal before the final file’s ordinary mode bits decide whether its data may be read.
the policy check came before the old permission check
Once namei has found the target vnode, XNU converts the open flags into access questions. O_RDONLY becomes the internal FREAD. A write-only or read-write open adds a write action. O_APPEND changes the requested write into append data. Opening a directory for search is distinct from executing a regular file.
The ordering in the pinned source is revealing. The vnode open path first invokes mac_vnode_check_open. If that returns an error, it returns immediately. Only afterward does it construct traditional KAUTH_VNODE_READ_DATA, KAUTH_VNODE_WRITE_DATA, or KAUTH_VNODE_EXECUTE actions and call vnode_authorize.
In compressed form:
error = mac_vnode_check_open(ctx, vp, fmode);
if (error) {
return error;
}
if (fmode & FREAD) {
action |= KAUTH_VNODE_READ_DATA;
}
if (fmode & FWRITE) {
action |= KAUTH_VNODE_WRITE_DATA;
}
error = vnode_authorize(vp, NULL, action, ctx);
The MAC hook is an extension point. XNU’s open source contains the framework and dispatch code, not Apple’s complete Seatbelt policy implementation. The framework iterates registered policy checks and returns an error. The measured selective denial, the open MAC hook, and the returned EPERM support the inference that the installed sandbox policy participates through this framework. They do not expose the closed policy evaluator’s internal instruction path.
The traditional authorization code contains a translation that makes any errno-based origin story more fragile. vnode_authorize asks the kernel authorization framework to check an action. If that framework returns EPERM, vnode_authorize converts it to EACCES for traditional behavior:
result = kauth_authorize_action(...);
if (result == EPERM) {
result = EACCES;
}
That conversion explains why old file permission refusals commonly surface as 13. It also proves that the outward integer can be chosen by a layer above the original refusal.
thirteen and one were evidence, not identities
I created a file owned by the test user, removed all of its mode permissions with chmod 000, and called open:
open(/private/tmp/.../noperm.txt): errno=13 (Permission denied)
I restored ordinary permissions, installed the selective sandbox policy, and opened its target:
open(/private/tmp/.../secret.txt): errno=1 (Operation not permitted)
In this exact XNU path, source order and controls give a good explanation. The mode-bit control passed the MAC policy and failed ordinary vnode authorization, which reports EACCES. The readable file passed its mode-bit check but matched the sandbox refusal, which propagated EPERM.
The conclusion is intentionally local:
this chmod control on this XNU path -> EACCES
this Seatbelt file denial on this system -> EPERM
It does not follow that EACCES always means mode bits or that EPERM always means Seatbelt. An access control list can cause EACCES. Directory search can fail with EACCES. Immutable file flags can produce EPERM. A capability check can produce EPERM. A Linux seccomp filter can be configured to return either number without executing the syscall. Filesystems may translate errors. Frameworks may translate them again.
The useful diagnostic procedure is therefore comparative. Hold the operation and path constant. Remove or add one policy. Inspect credentials, mode bits, ACLs, flags, process policy, and namespace. Read the implementation for the relevant version when the distinction matters. The number is one observation among those controls.
the profile was an experiment interface
The opening program uses sandbox_init because it creates an unusually clean experiment. It performs one open, installs a policy in the same process, then performs the same open again. Almost every environmental variable is held constant.
The named profile is Apple’s kSBXProfilePureComputation:
char *error_buffer = NULL;
int rc = sandbox_init(kSBXProfilePureComputation,
SANDBOX_NAMED,
&error_buffer);
The call is irreversible for the lifetime of the process. There is no corresponding sandbox_remove that restores authority. That monotonic behavior is a security property. If compromised code could undo the policy, installing it before parsing untrusted input would provide little containment.
There is a second property hidden in the SDK header. If the process already has a sandbox, sandbox_init ignores the new profile and returns an error. I encountered that while first running the probe from inside an already restricted parent process:
sandbox_init rc=-1 err=Operation not permitted
Running the same binary without that parent policy produced rc=0. A sandbox cannot assume it is the first policy on the process, and a test runner can change the result merely by launching the test inside its own containment. The parent environment belongs in the experiment record.
This interface is not current production guidance. The installed macOS SDK marks sandbox_init and the named profiles deprecated since macOS 10.8. Its header directs application developers to App Sandbox. The current App Sandbox documentation describes a code-signing entitlement and capability entitlements for files, network connections, and hardware. It also describes using separate helpers when components need different authority.
The distinction matters for two reasons. First, a three-line profile used with sandbox-exec is valuable as a mechanism probe and a poor recommendation for shipping a modern Mac app. Second, Seatbelt profile syntax is not a stable public application API merely because the command remains present on this machine. The article uses it to isolate the refusal, not to promise compatibility.
For the selective path test, the profile was:
(version 1)
(allow default)
(deny file-read-data (literal (param "TARGET")))
Everything is allowed unless the operation is file-read-data and its resource matches the supplied target. That gives a negative control one directory entry away:
open(/private/tmp/.../secret.txt):
errno=1 (Operation not permitted)
open(/private/tmp/.../sibling.txt):
ok fd=3
The sibling matters. A broken process, unreadable directory, malformed profile, or exhausted descriptor table could make both opens fail. One allowed neighbor shows that the policy is selective at the resource level.
tmp was not private tmp
The first selective run did not deny anything:
policy target: /tmp/.../secret.txt
open(/tmp/.../secret.txt): ok fd=3
The profile looked correct. The file existed. The target string printed by the program matched the string passed to the profile. The failure was in a pathname assumption.
On this macOS system, /tmp is a symbolic link to /private/tmp:
/tmp -> private/tmp
The temporary-directory function returned a convenient /tmp/... spelling. Resolving the directory with pwd -P returned /private/tmp/.... Supplying that physical path to the policy changed the result to EPERM.
This was not evidence that the kernel compared the characters from open against the profile’s characters at the syscall boundary. If it had, /tmp/... should have matched /tmp/.... The file policy acted on resource identity after pathname resolution had done meaningful work.
The distinction between a name and the object named is the same one file descriptors already exposed. Several pathnames can reach one vnode. One pathname can reach different vnodes at different times after a rename, mount change, or symbolic-link update. A policy that protected only one superficial spelling would be easy to evade.
The corrected verifier canonicalizes its private temporary directory before constructing the profile:
run_dir_alias=$(mktemp -d /tmp/asquare-refused.XXXXXX)
run_dir=$(CDPATH= cd -- "$run_dir_alias" && pwd -P)
sandbox-exec -D "TARGET=$run_dir/secret.txt" \
-f deny-read.sb \
./openit "$run_dir/secret.txt"
This command is local evidence for this deprecated test interface. It is not a general rule that every sandbox profile should contain a canonical string. Real file authorization also deals with file-system identities, extension tokens, container paths, and policy-specific matching forms. The failed /tmp run establishes only that literal resource matching was not the raw string comparison I first assumed.
the alias reached the same refusal
The next prediction followed from that correction. If the policy ultimately protects the resolved resource, a fresh symbolic-link alias to the forbidden file should not help.
I created:
alias.txt -> /private/tmp/.../secret.txt
Then I opened the alias while the profile targeted the physical path of secret.txt:
open(/private/tmp/.../alias.txt):
errno=1 (Operation not permitted)
The prediction held. The pathname walker followed alias.txt, reached the target, and authorization refused the read.
This test does not prove that every Seatbelt rule is vnode-based. Policy operations can match subpaths, extensions, services, sockets, process operations, and other resource types. It proves something narrower and useful: this file-read-data literal rule was not bypassed by presenting the protected file through this symlink.
There is a race hidden behind nearly every pathname policy. A process can ask about a path and then use it after another process renames a component or swaps a symlink. Security-sensitive code that checks a pathname in user space and later calls open creates a time-of-check to time-of-use gap. Kernel pathname resolution and authorization can bind the check to the object reached during the operation. Interfaces such as directory-relative opens and flags that constrain symlink traversal exist because paths are mutable programs for finding objects, not permanent object identifiers.
The article’s opening line says “same path” because that is what the C argument showed. The kernel did more than compare that path.
the descriptor crossed the policy boundary
I expected the pure-computation profile to make an already open file descriptor unreadable. It did not.
The probe opened the file first, installed the same named profile, then read one byte from the existing descriptor:
int fd = open(path, O_RDONLY);
printf("open before policy: fd=%d\n", fd);
int rc = sandbox_init(kSBXProfilePureComputation,
SANDBOX_NAMED,
&error_buffer);
char byte = '\0';
ssize_t count = read(fd, &byte, 1);
The result:
open before policy: fd=3
sandbox_init: rc=0
read after policy: return=1 byte=0x73
Byte 0x73 is the s at the start of secret. A new open after the policy returned EPERM, yet a read through the previously acquired descriptor succeeded.
The result changes the model. The profile did not retroactively erase every resource the process already held. At least for this file, profile, and OS version, the descriptor carried authority across policy installation.
A file descriptor has capability-like properties. Possessing it is both a reference to a kernel object and evidence that acquisition once succeeded. Passing a descriptor over a Unix-domain socket can grant another process access without giving that process a pathname it can open. Opening a directory and using relative operations can preserve access to objects below it. Inheriting descriptors across fork and exec can accidentally leak authority into children.
That makes ordering part of a sandbox design:
acquire broad resources
drop authority
process untrusted input using only retained descriptors
The sequence is useful only if retained descriptors are intentional. A service that opens its database, log socket, configuration, and listening socket before dropping privileges can continue working. An accidental descriptor for a secret file can survive the same transition.
Close-on-exec exists partly to control that leak. Setting O_CLOEXEC atomically with open causes the descriptor to close when an exec succeeds. Setting it in a later fcntl call creates a window where another thread can fork and exec between acquisition and the flag update. The atomic flag closes that race.
The experiment does not establish a universal “open checks, read never checks” rule. XNU contains both mac_vnode_check_open and mac_vnode_check_read hooks. Policies can consider credentials attached to files and current processes. Network sockets, devices, and IPC objects have different operations. The observed rule is exact: a descriptor acquired before kSBXProfilePureComputation remained readable for this regular file on macOS 15.7.7.
That one byte earned a larger place in the explanation than the original profile. Policy can control acquisition while leaving deliberately retained capabilities usable.
two integers shared one position in the file
A descriptor can be duplicated without opening the path again:
int first = open(path, O_RDONLY);
int second = dup(first);
The two integers name two descriptor-table entries, but those entries refer to the same open file description. That shared kernel object contains the current file offset and file status flags.
The test file contains secret\n. I read one byte through first, then one through second:
descriptors: first=3 second=4
first before policy: return=1 byte=s
second before policy: return=1 byte=e
If dup had performed a fresh open, both descriptors would have begun at offset zero and both reads would have returned s. The e shows that the first read advanced shared open state from offset zero to offset one.
I then installed the pure-computation profile and alternated them again:
sandbox_init: rc=0
first after policy: return=1 byte=c
second after policy: return=1 byte=r
new open after policy:
return=-1 errno=1 (Operation not permitted)
Both retained descriptor entries remained useful. Their shared offset advanced through s, e, c, and r. A third attempt to acquire the file through its path failed.
Three layers are now distinct:
descriptor number
one process-local table index
open file description
shared offset, status flags, reference to file operations
vnode
filesystem object reached by the original path
Closing first removes one table entry and decreases the reference count. It does not invalidate second. The shared open description survives until its last reference is closed. The vnode can also have references from other opens, mappings, directory caches, and kernel subsystems.
dup2 and dup3 can choose the destination descriptor number. fork copies the parent’s descriptor table so parent and child entries can refer to the same open descriptions. Unix-domain SCM_RIGHTS messages can install references in another process. None of these operations needs to resolve the original pathname again.
This changes the shape of a broker. A privileged process can open one approved resource and pass a descriptor to a restricted worker. The worker receives access to that open object without receiving general pathname authority. The worker can also duplicate the descriptor, so a lifecycle protocol cannot assume that closing the originally transferred integer revokes every reference.
Revocation is difficult precisely because descriptors are capabilities that can be copied. A broker can close its own reference, but it cannot generally force arbitrary recipient copies to disappear. Revocable designs add an indirection the broker controls, use a protocol instead of transferring the underlying descriptor, or rely on resource-specific shutdown behavior.
Shared offsets can also be undesirable. Two threads reading through duplicated descriptors race over one position even if they use different integers. Separate calls to open create separate open descriptions and therefore separate offsets, but the policy can distinguish those fresh acquisitions. pread avoids the shared current offset by supplying an explicit position while retaining the same descriptor authority.
The descriptor is a small number. The authority and mutable state behind it are larger than the number suggests.
the socket existed before the network was denied
The network profile exposed the same split inside a different resource:
(version 1)
(allow default)
(deny network*)
The probe first created an IPv4 stream socket, then connected it to loopback port 9:
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in address = {
.sin_family = AF_INET,
.sin_port = htons(9),
};
inet_pton(AF_INET, "127.0.0.1", &address.sin_addr);
int rc = connect(fd,
(struct sockaddr *)&address,
sizeof(address));
Without the profile, socket succeeded and connect returned ECONNREFUSED because nothing listened on that port:
socket: fd=3
connect: return=-1 errno=61 (Connection refused)
The refusal is a useful baseline. It proves the kernel accepted the network operation far enough to consult the local transport state.
Under the deny-network profile:
socket: fd=3
connect: return=-1 errno=1 (Operation not permitted)
Socket creation still succeeded. The policy refused connection of that socket to an address. “Network denied” did not mean that the first socket-related syscall had to fail.
A socket begins as a kernel object with a protocol family, type, and protocol. It has no remote peer. connect supplies an address and requests a relationship. A meaningful policy may allow local socket bookkeeping while forbidding outbound flows, or distinguish inbound listening, accepted connections, Unix-domain IPC, loopback, and remote addresses.
The experiment also shows why filtering only syscall numbers is less expressive than resource-aware policy. A number-level filter can reject every connect. Distinguishing one destination from another requires inspecting address data, associating kernel socket state, or asking a policy hook at a layer where the address has been copied and parsed.
the child never received a number
The process policy for fork is smaller:
(version 1)
(allow default)
(deny process-fork)
The probe calls fork, lets the child exit with status 42, and has the parent wait:
pid_t child = fork();
if (child < 0) {
int saved_errno = errno;
printf("fork: return=-1 errno=%d (%s)\n",
saved_errno, strerror(saved_errno));
return 1;
}
if (child == 0) {
_exit(42);
}
waitpid(child, &status, 0);
Without policy:
fork: child=73128 exit=42
The exact child PID changes on every run and carries no meaning. The exit status shows that both sides of fork followed their intended branch.
With the policy:
fork: return=-1 errno=1 (Operation not permitted)
No child existed to print an error or exit with 42. The parent remained alive and received the refusal.
The shell transcript in the old version blurred this by showing /bin/sh: fork: Operation not permitted. That message came from the shell after its fork wrapper failed. The dedicated probe removes shell implementation choices from the result.
fork itself has another portability trap. On Linux, the C library can implement process creation through clone or clone3 machinery while preserving the POSIX fork interface. A filter written only around the word “fork” can miss the call that a particular libc and architecture actually issue. Security filters are versioned against executable behavior, not source-level intentions.
the filter chose how no returned
The old comparison said that macOS Seatbelt returns an errno while Linux seccomp kills by default. That was too broad. A seccomp filter chooses an action for each evaluated syscall. There is no universal filter posture that turns every forbidden call into one particular response.
The official Linux seccomp filter documentation defines actions with an explicit precedence. The important ones for this investigation are:
SECCOMP_RET_KILL_PROCESS terminate the process
SECCOMP_RET_KILL_THREAD terminate the triggering thread
SECCOMP_RET_TRAP deliver SIGSYS
SECCOMP_RET_ERRNO return a chosen errno, skip the syscall
SECCOMP_RET_USER_NOTIF ask a supervising process
SECCOMP_RET_TRACE notify a ptrace tracer
SECCOMP_RET_LOG log, then execute
SECCOMP_RET_ALLOW execute normally
If multiple filters are installed, the kernel evaluates all of them and applies the highest-precedence result. A later permissive filter cannot override an earlier kill. This preserves monotonic restriction, the same general property seen when sandbox_init refused to replace an existing sandbox.
SECCOMP_RET_ERRNO | EACCES makes a filtered Linux syscall appear to fail with EACCES without running the syscall implementation. SECCOMP_RET_KILL_PROCESS ends the process. SECCOMP_RET_TRAP sends SIGSYS and gives a signal handler information about the attempted syscall. SECCOMP_RET_USER_NOTIF can pause the task and delegate a decision to a supervisor.
The choice reflects the threat model and the compatibility requirement:
unknown syscall means compromise -> kill
optional feature lacks authority -> errno
supervisor must emulate or approve -> user notification
policy is being learned in development -> log
Killing can be safer when reaching a forbidden number implies control-flow corruption. Returning an errno can be necessary when applications already handle absent capabilities. A notification can implement a container manager’s mediated operation, but it introduces a second program whose races and privileges become part of the security boundary.
I did not run the seccomp paths in this cycle because the available machine is macOS. The action list and semantics above come from current kernel documentation, not a local Linux transcript. The distinction is more accurate than an invented core dump.
the pointer was the argument the filter could not read
The seccomp BPF program receives a struct seccomp_data containing the architecture, syscall number, instruction pointer, and six raw argument values. A raw pointer argument is only a number in that structure. The filter cannot safely dereference arbitrary user memory.
That restriction prevents a subtle race. Suppose a filter tried to read the pathname bytes for openat and allowed /safe/input. After the check, another thread could change the bytes before the actual syscall copied them. The policy would have authorized one string and the kernel would have opened another. Keeping classic seccomp filters to stable scalar arguments avoids presenting unsafe pointer chasing as a secure primitive.
The architecture field is mandatory evidence. Syscall numbers differ across architectures and calling conventions. Filtering number 5 without checking architecture can mean open on one ABI and something unrelated on another. The kernel documentation calls this out as a major pitfall.
Resource-aware policy needs a different location or mechanism. XNU’s vnode hooks run after pathname lookup has produced kernel objects. Linux Security Module hooks can authorize kernel objects. Landlock rules refer to filesystem hierarchies. A seccomp user-notification supervisor can inspect another process’s memory, but current documentation warns about time-of-check to time-of-use races and requires careful copying plus identity validation.
This places policy mechanisms on a spectrum:
syscall number filter
sees: number, architecture, raw scalar arguments
strength: small, early attack-surface reduction
weakness: poor semantic knowledge
kernel object authorization hook
sees: resolved vnode, socket, credential, requested action
strength: resource-aware decision at point of use
weakness: broader kernel policy integration
user-space supervisor
sees: rich process and business context
strength: can emulate and broker
weakness: races, latency, another privileged component
No one position replaces the others. A container may use namespace isolation, capability removal, an LSM, cgroups, and seccomp because each constrains a different question.
the empty room and the locked door were different
The selective file experiment leaves secret.txt visible by name. The process can try to open it and receive EPERM. Another isolation design gives the process a filesystem view in which that pathname does not exist.
Those outcomes are observably different:
policy denial:
pathname resolves to a protected object
requested action is refused
open returns EPERM or EACCES
different filesystem view:
pathname walk cannot reach the host object
open commonly returns ENOENT
One resembles a locked door. The other resembles a room with no door. Both can be useful, and neither makes the other redundant.
Linux namespaces create private views of resources that were historically global. The maintained namespaces(7) documentation describes a namespace as an abstraction that makes a process see its own instance of a global resource. A mount namespace changes the set and arrangement of mounted filesystems. A PID namespace changes process-number visibility. A network namespace has its own interfaces, routes, ports, firewall state, and sockets. UTS namespaces isolate hostname and domain name. IPC namespaces separate System V IPC and POSIX message queues. User namespaces remap user and group identities plus capabilities.
These are views, not a single security switch. A process in a new mount namespace can still read everything mounted into that namespace. A process in a new network namespace can still send traffic if an interface and route connect it outward. PID isolation does not stop a process from exhausting memory. A user namespace can give a process UID 0 inside while mapping it to an ordinary UID outside, but the scope of its capabilities follows namespace ownership rules.
The namespace API also exposes why creating a container is more complicated than calling chroot. A mount namespace can isolate mount changes. chroot only changes the starting root used for pathname lookup. It does not create a new mount table, PID view, network stack, or resource controller. A privileged process with suitable handles can escape a careless chroot arrangement. Treating it as a full container confuses one pathname property with a system boundary.
I ran the local macOS command:
$ chroot / /bin/echo hi
chroot: /: Operation not permitted
That is a direct macOS result, not a Linux capability measurement. It says the current process lacks authority to change its root. On Linux, the maintained capabilities documentation assigns chroot to CAP_SYS_CHROOT. Linux divided the old all-or-nothing root privilege into per-thread capability bits so a process can hold one administrative power without receiving every root power.
The two facts should not be collapsed into “chroot needs root.” The actual kernel check depends on OS, credentials, namespaces, and capabilities. On Linux a thread can have CAP_SYS_CHROOT in the governing user namespace without having UID 0 in the initial namespace. On this Mac, the observed command failed under the local credential and policy environment.
the number zero had already been split apart
Traditional Unix explanations give root a magical user ID, zero, that bypasses ordinary permission checks. Modern Linux still supports that compatibility model, but capabilities split much of its power into named units.
Examples make the split concrete:
CAP_DAC_OVERRIDE bypass ordinary file read, write, execute checks
CAP_NET_BIND_SERVICE bind Internet ports below 1024
CAP_SYS_CHROOT call chroot and change mount namespaces with setns
CAP_SYS_PTRACE trace processes subject to additional checks
CAP_BPF perform privileged BPF operations
CAP_SYS_ADMIN a broad set of administrative operations
A server that only needs to bind port 443 should not need authority to mount filesystems, change the clock, trace unrelated processes, or bypass every file permission. It can acquire the listening socket in a privileged launcher, receive the socket descriptor, and run without the broad credential. Alternatively, a carefully scoped executable capability can grant the bind operation. The descriptor-transfer design is often narrower because the worker receives one already selected socket rather than the general power to bind more sockets.
Capabilities have several sets because authority changes across exec and because possession is different from current activation. The permitted set bounds what the thread may make effective. The effective set is checked for current operations. The inheritable and ambient mechanisms govern selected transitions. A bounding set can permanently remove capabilities from future executable transitions.
That last operation resembles sandbox installation: dropping a capability from the bounding set constrains what later code can regain. Security designs prefer one-way transitions because the code that parses hostile input should not keep a dormant switch that restores broader authority.
Capabilities also interact with user namespaces. A thread can hold broad authority over resources owned by its user namespace and no corresponding authority over resources in the initial namespace. “Has CAP_SYS_ADMIN” is incomplete without “in which user namespace, for which governed object.”
The EPERM returned by a missing capability does not announce which bit was absent. Diagnosis needs the operation, kernel documentation, current capability sets, namespace relationships, and audit evidence. Again, the error number reports an API result rather than a full policy proof.
the box did not limit how much air it consumed
Namespaces change what resources look like. Capabilities and policy hooks change which operations succeed. Neither inherently limits how much CPU time or memory a process consumes.
Linux cgroups answer that third question. In cgroup v2, processes are organized in a hierarchy where controllers distribute or cap resources. The official cgroup v2 documentation defines interfaces such as:
cpu.max CPU bandwidth limit
cpu.weight proportional CPU weight
memory.max hard memory limit
memory.high throttling and reclaim boundary
pids.max limit on process count
io.max I/O bandwidth or operation limits
The exact semantics matter. memory.max does not create a smaller virtual address space whose end is visible to malloc. Allocations can succeed until pages are touched and charged. Reclaim may occur. The cgroup may reach an out-of-memory condition and select a victim. A user-space allocation failure, a killed process, and memory-pressure latency are different possible observations.
cpu.max does not make instructions execute more slowly. It budgets runnable time over a period. A task can run at normal core speed, exhaust its quota, then wait until replenishment. Average CPU utilization may look correct while request latency develops a periodic tail.
pids.max limits tasks, which includes threads under the kernel’s accounting. A C++ runtime that starts one worker per core, a library that starts hidden helper threads, and a program that forks subprocesses all spend from that resource. Failure can surface as EAGAIN from thread or process creation rather than EPERM.
These consequences explain why “the container refused it” is not a diagnosis. A launch might fail because:
the pathname is absent from its mount namespace
ordinary credentials cannot read the file
an LSM or sandbox policy denies the read
a needed capability was removed
a seccomp filter rejects the syscall
the cgroup hit its process limit
the process descriptor table is full
the host is globally out of a kernel resource
They can reach nearby source lines and different error numbers. Some kill the task. Some delay it. Some make an object invisible. A useful investigation changes one boundary at a time.
I did not run namespace or cgroup experiments on the Mac and have not presented the examples above as terminal output. They are mechanisms specified by current Linux documentation. A later Linux investigation needs its own kernel version, distribution policy, controller configuration, and measured results.
a container was several arguments that happened to travel together
Container runtimes assemble these mechanisms into one process environment. A simplified launch path might:
create or join namespaces
prepare mounts and a root filesystem
configure user and group mappings
place the process in cgroups
drop capability sets
set no_new_privs
install an LSM label or profile
install a seccomp filter
exec the application
The order can alter both security and behavior. Mount setup needs authority that the final application should not retain. User namespace mappings affect which mount operations are permitted. no_new_privs prevents exec from gaining privilege through set-user-ID bits or file capabilities. A seccomp filter that forbids a setup syscall must be installed after setup or by another process. File descriptors intentionally opened before restrictions can survive into the application unless close-on-exec removes them.
An OCI configuration describes much of this state, but the kernel still enforces individual mechanisms. There is no single “container mode” bit in the CPU. A container process is a process with a particular combination of namespaces, cgroup membership, credentials, capabilities, policy labels, filters, mounts, and inherited descriptors.
That composition has a practical debugging consequence. Reproducing a denial requires more than the executable and its command line. It may require:
container image digest
runtime and runtime version
host kernel and configuration
namespace mappings
mount list and propagation
cgroup controller values
capability sets
security profile and labels
seccomp program
inherited descriptors
parent process policy
Changing the host kernel can change supported syscalls. Changing libc can change which syscall implements one source-level function. Changing the runtime can change the generated seccomp allowlist. Changing a mount from read-write to read-only can turn a policy-looking failure into EROFS. Reproduction is an environment claim.
the page was allowed to hold data or instructions
The original article treated writable-executable memory as a refusal wired into silicon. The measurements were real:
mmap(RWX): errno=13 (Permission denied)
mprotect(RW to RWX): errno=13 (Permission denied)
mmap(RWX, MAP_JIT): ok
The explanation was not precise. The processor enforces permissions encoded by the operating system in virtual-memory translation structures. The kernel decides which mappings and protection transitions to allow. Code-signing policy and process entitlements influence that decision. Hardware supplies the enforcement on access.
Start with one virtual page. A page-table entry does more than map a virtual page number to a physical page frame. It carries permission and state bits. On arm64, the architecture supports access permissions that distinguish privileged and unprivileged reads and writes, plus execute-never controls for privileged and unprivileged execution. XNU constructs entries from the protections it accepts for a mapping.
When the CPU fetches an instruction, translation checks whether execution is allowed for the current exception level. When a store retires, translation checks whether writing is allowed. A forbidden access raises an exception. The kernel handles that exception and may deliver a signal to the process.
There are therefore two different refusal times:
request time:
mmap or mprotect asks the kernel for a protection
kernel rejects the request
function returns -1 and sets errno
access time:
mapping exists without the needed permission
CPU attempts a write or instruction fetch
hardware raises a fault
kernel turns it into process-visible exception behavior
The local probe observed request-time refusal. It never reached an instruction fetch from the rejected mapping because no such mapping was created.
W^X is the policy that writable memory should not simultaneously be executable. Its purpose is to narrow a common exploit step. If an attacker finds a memory corruption bug and can write chosen bytes into a data buffer, executable permission on that same buffer turns the write into a direct code-injection path. If writable pages cannot execute, the attacker needs another technique such as reusing existing code, corrupting control data, or finding a legitimate transition mechanism.
W^X is a reduction in available exploit paths, not a proof that code is safe. Return-oriented programming reuses executable instructions already in the process. A JIT compiler deliberately creates new instructions. Logic bugs can call dangerous existing functions. The policy removes one dangerous combination of permissions.
the second mapping tested a different transition
The probe used two ordinary mapping paths:
void *p = mmap(NULL, 4096,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANON,
-1, 0);
void *q = mmap(NULL, 4096,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANON,
-1, 0);
int rc = mprotect(q, 4096,
PROT_READ | PROT_WRITE | PROT_EXEC);
The first asks for all three permissions during creation. The second creates ordinary data memory and later asks to add execute while retaining write. Both returned EACCES on the Apple M4.
The second control prevents a narrow explanation that only mmap flag validation rejected the first call. The system also rejected a transition of an existing anonymous mapping into the same final protection.
Another useful control would alternate RW and RX, never requesting both together:
mmap as RW
write code
mprotect to RX
execute
mprotect back to RW
modify code
That pattern avoids simultaneous write and execute in the page table, but Apple platforms add code-signing and JIT rules beyond the abstract W^X property. A portable JIT cannot assume that generic mprotect transitions are sufficient. It must use platform interfaces and account for hardened runtime policy.
The local probe also runs under the surrounding execution environment. The same binary may behave differently when code signed with hardened runtime options and entitlements. The experiment record therefore includes signing context, not only hardware and OS.
the exception was another controlled path
The third mapping adds MAP_JIT:
void *page = mmap(NULL, 4096,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANON | MAP_JIT,
-1, 0);
The ad hoc local executable received a mapping. That does not mean MAP_JIT universally bypasses code-signing policy.
Apple’s current JIT porting documentation distinguishes two cases. On Apple silicon, JIT write protection applies to applications even without hardened runtime. When an application adopts hardened runtime, using MAP_JIT also requires the com.apple.security.cs.allow-jit entitlement. The entitlement is not required for the same operation in an application that has not adopted hardened runtime.
That exactly fits the local result. The probe was an ad hoc command-line executable without hardened runtime. Its success cannot be used to predict a distributed hardened application.
The MAP_JIT region is special because the process needs to produce instructions and later execute them. Apple silicon provides a per-thread write-protection control. While the current thread has JIT write protection disabled, it can modify the region and cannot use it as executable memory in the same way. Re-enabling protection makes it executable for that thread and removes its write access.
Per-thread state matters when a JIT compiles and executes concurrently. One compiler thread may need write access while another application thread executes previously generated code. A global page-table flip would require coordination and translation invalidation across cores. A thread-scoped mechanism can constrain each thread’s view while supporting that concurrency model.
Apple also provides pthread_jit_write_with_callback_np. With the JIT callback allowlist entitlement, a process declares which callbacks may perform writes. The interface switches protection, invokes an allowed callback, then restores executable protection. The callback must not escape through exceptions or longjmp, because that could leave protection in the writable state. These rules are part of the security contract, not ceremony around memcpy.
eight bytes became a function
Mapping a JIT page proves allocation. It does not prove that the writable and executable phases produce working code. The stronger probe writes one arm64 function and calls it.
The generated function is two 32-bit instructions:
const uint32_t code[] = {
0x52800540, /* mov w0, #42 */
0xd65f03c0, /* ret */
};
The AArch64 return convention places an integer result in w0. mov w0, #42 supplies the result. ret branches to the link register saved by the caller.
The probe performs the required transitions:
int toggles = pthread_jit_write_protect_supported_np();
if (toggles) {
pthread_jit_write_protect_np(0);
}
memcpy(page, code, sizeof(code));
sys_icache_invalidate(page, sizeof(code));
if (toggles) {
pthread_jit_write_protect_np(1);
}
int (*generated)(void) = page;
int answer = generated();
The local result:
per-thread JIT write protection supported: 1
generated function returned: 42
The cache invalidation between writing and executing is not decorative. The data cache can contain newly written bytes while the instruction side still holds older contents for those addresses. Apple’s JIT guidance explicitly requires sys_icache_invalidate before executing recently generated instructions on Apple silicon.
This is another place where “memory is bytes” needs one more layer. The same physical bytes are observed through load-store and instruction-fetch machinery. Coherence rules determine when one side can trust modifications made through the other. A JIT must publish code to the instruction side before calling it.
The function-pointer conversion is platform-specific C territory. ISO C does not promise that an object pointer and a function pointer are interchangeable on every implementation. This probe targets macOS arm64, uses the platform’s JIT facility, and compiles with its ABI. Presenting it as portable C would be false.
The two instruction encodings are also architecture-specific. On x86-64 the bytes, calling convention, cache behavior, and transition interface differ. Shape is portable:
obtain authorized JIT memory
enter writable state
emit instructions for the target ISA
publish instruction bytes
enter executable state
call through the target ABI
The implementation is not.
the processor enforced a decision it did not invent
The successful JIT run makes the policy layering visible:
application intent
asks mmap for MAP_JIT
kernel policy
checks platform, code-signing state, flags, entitlements
creates a mapping with controlled JIT behavior
thread runtime interface
changes current thread's write-protection state
page tables and processor
enforce allowed reads, writes, and instruction fetches
The CPU does not parse MAP_JIT. It does not know the name of a code-signing entitlement. The kernel and runtime turn those policies into translation and thread state the CPU can enforce.
Saying that W^X is “wired into the chip” erases the software decisions that explain the observed exceptions. Saying it is “only software policy” erases the hardware check that stops a load, store, or instruction fetch even after arbitrary user code has begun running. Both layers are necessary.
This division repeats at the syscall boundary. The svc instruction provides a controlled transition. Kernel code decides what number means open, what credentials and policies apply, and what error to return. Hardware prevents ordinary user instructions from directly editing kernel page tables or descriptor tables.
The physical mechanism and the policy are different questions:
mechanism: how is a forbidden access stopped?
policy: who decided that this access is forbidden?
An accurate refusal explanation needs both.
the process kept more than stdout
The self-sandboxed process continued printing after installing a pure-computation profile. That initially looked like a special allowance for printf. The descriptor experiment offers a simpler explanation: standard output was already open.
printf formats characters in user space and eventually writes to descriptor 1. The process inherited that descriptor from its parent before main began. If the policy allows use of retained descriptors, output can continue even while new file opens fail.
The same reasoning applies to standard input and standard error, pipes from a supervisor, logging sockets, shared-memory descriptors, and listening sockets. A restricted worker can have a small operational surface without being unable to communicate:
descriptor 0 request input pipe
descriptor 1 structured response pipe
descriptor 2 diagnostic stream
descriptor 3 already bound listening socket
descriptor 4 read-only model file
descriptor 5 metrics socket
That list is an authority graph. Each descriptor points to a kernel resource with operations the worker may still perform. Auditing a sandbox while ignoring inherited descriptors misses a large part of its effective privilege.
The graph can be smaller than a pathname allowlist. Instead of allowing a worker to open any file below /models, a launcher can open one immutable model version and pass one descriptor. A rename after launch cannot redirect that descriptor to another file. Instead of granting network access to any metrics endpoint, a supervisor can pass one connected socket.
There are costs. Restart and reload behavior needs a broker. A descriptor does not carry a human-readable configuration path for diagnostics. Some libraries insist on opening by name. Passing a directory descriptor grants a wider family of relative operations than passing one regular file. The design must describe operations, not count descriptors and declare victory.
the compromised parser borrowed existing authority
Policy becomes valuable when code above the kernel boundary fails. A parser can contain an out-of-bounds write. A deserializer can deliberately invoke constructors with behavior. A JIT can mistranslate bounds checks. None of these failures needs to attack the syscall dispatcher.
Once unintended control flow runs inside the process, it inherits the process’s existing authority:
readable files and directories
open descriptors
network destinations
process creation
debug or inspection rights
memory mapping permissions
device access
credentials and capabilities
Least privilege reduces that list before the bug is reached. If the worker only has a connected input descriptor and one output descriptor, arbitrary code execution is still serious, but its direct paths to home-directory files, new network peers, and child processes are smaller.
This is why the already-open descriptor result cuts both ways. Retained resources let a restricted service do useful work. They also remain valuable to an attacker. The right set is not empty by definition. It is the minimum set needed for the operation, with precise lifecycle and ownership.
Sandboxing also does not validate data. A process allowed to write legitimate responses can write malicious responses. A process allowed to read one model can exploit a bug in that model parser. A network-denied process can attack its parent through an inherited IPC protocol. Authority reduction contains consequences; it does not turn untrusted bytes into trusted bytes.
The boundary between two local processes therefore needs protocol validation, message-size limits, timeouts, backpressure, and identity checks. A privileged broker that accepts arbitrary path strings from a sandboxed worker simply moves open into the broker and restores the worker’s filesystem reach through RPC. The broker must expose narrower operations, such as “open model digest X from the configured store,” and validate X against its own state.
The technical unit of least privilege is an operation over an identified resource, not a broad component label such as “worker” or “container.”
the refusal was observable without watching the trap live
The old ending treated the unavailable privileged syscall trace as the only clean proof. It is one kind of evidence, not the only kind.
The current path is supported by four independent artifacts:
direct call result
normal: carry=0, x0=file descriptor
denied: carry=1, x0=1
installed wrapper disassembly
syscall number 5
svc #0x80
branch on carry clear
call _cerror on carry set
pinned XNU source
dispatch through sysent
open and pathname path
MAC vnode hook before traditional authorization
error copied to x0 and carry set
controlled policy experiment
target denied
sibling allowed
symbolic-link alias denied
pre-acquired descriptor readable
Each artifact has a different failure mode. Inline assembly could encode the ABI incorrectly, but the installed wrapper corroborates the number and trap. Open source could differ from the installed binary, but the local raw return and library branch corroborate its return convention. The policy experiment could be matching the wrong path, but the sibling and symlink controls constrain that explanation. Disassembly could show a wrapper never called by public open, but public and raw results agree, and _open calls ___open in the installed binary.
This is triangulation. None of the evidence alone shows every dynamic instruction. Together they make the opening result predictable.
A live trace would still add information. It could timestamp entry and return, identify the actual thread, show the arguments copied at the boundary, expose retries or cancellation, and correlate policy logs. Kernel probes at the right locations could identify which authorization hook returned the error rather than inferring it from source order and controls.
The absence of that trace should be recorded as a limit, not expanded into absence of knowledge.
the observer needed authority too
Tracing changes the security question because a syscall observer can see sensitive data. Pathnames reveal user files. Buffers may contain secrets. Socket addresses reveal services. Process IDs and timing reveal workload structure. A tracer that can alter registers or memory can control the observed process.
The local tools reflected that boundary:
dtruss:
System Integrity Protection warning and unavailable tracing path
ktrace:
requires root for the requested system trace
lldb attach to pid 1:
attach refused for an unprivileged user
These were direct observations on this machine. They do not imply that macOS has no supported observability. Instruments, Endpoint Security, unified logging, application-level signposts, and appropriately entitled debugging workflows cover different cases. They imply that the ordinary unprivileged shell used for this experiment could not attach an unrestricted kernel syscall observer to the target environment.
The refusal is consistent with the threat model. A tool that reads another process’s syscall buffers can read credentials and private file contents. A debugger with a task port can alter control flow. System Integrity Protection and task-port policy protect those processes from the observer.
Linux strace is often available for a process owned by the same user, but Linux also applies ptrace access checks, dumpability rules, Yama policy, namespace relationships, capabilities, and seccomp interactions. “Use strace” is a useful diagnostic step only after asking whether the tracing process is permitted to observe the target.
Production tracing creates another authority graph:
collector
can observe which processes?
can read which arguments?
can retain data for how long?
can export to which destination?
operator
can query which tenants?
can retrieve raw paths or only classified events?
can alter probes?
A security tool that diagnoses one denied file read by exporting every successful pathname may create the larger privacy problem.
the useful denial record did not need the secret path
The opening output prints the full temporary path because it is a local controlled file. That practice does not scale to user documents, model names, tenant IDs, tokens in URLs, or database socket paths.
A production refusal record can preserve diagnostic structure without preserving raw resource text:
timestamp
process image digest
process policy digest
operation class
resource class
stable keyed resource hash
errno or signal outcome
kernel and runtime version
container or workload identity
trace correlation ID
A keyed hash lets operators group repeated denials for the same resource inside one trust domain without publishing the pathname to every telemetry consumer. The key needs rotation and access control. An ordinary unsalted hash is vulnerable when path candidates are guessable.
The policy digest matters because “same application version” does not mean same authority. A deployment can alter a seccomp profile, App Sandbox entitlements, Kubernetes security context, or runtime default without rebuilding the binary. Correlating denial rate only with application commits can blame innocent code.
The process image digest matters for the reverse reason. A stable policy can begin denying after a libc upgrade changes fork into a different clone path or a runtime adopts a new syscall. Configuration and executable behavior form the compatibility pair.
Cardinality needs a bound. Emitting one metric label per raw pathname can overload a metrics system and leak identifiers. Counts should use bounded dimensions such as operation class, policy version, and outcome. Sampled or access-controlled event logs can carry a keyed resource identity for investigation.
A denial counter also needs a denominator:
denied opens / attempted opens
denied connects / attempted connects
SIGSYS terminations / process starts
One hundred denials may be a regression in a workload that attempts one hundred opens, or harmless probing in one that attempts a billion. Rates, affected workload count, and user-visible consequences belong beside raw totals.
retrying the refusal could make the incident worse
Many transient system errors deserve retries. EINTR can indicate that a signal interrupted a blocking operation. EAGAIN can indicate temporary unavailability. Connection failures can recover after backoff. A policy refusal is usually different.
Retrying the same open after EPERM without changing credentials, policy, namespace, or resource identity repeats the same authorization question. A tight retry loop converts one deterministic denial into CPU use and log volume:
for (;;) {
int fd = open(path, O_RDONLY);
if (fd >= 0) {
break;
}
if (errno == EPERM) {
continue; /* deterministic busy loop */
}
}
The correct response depends on whether the operation is optional:
required resource denied
fail the request or process with specific context
optional feature denied
disable that feature and continue through a tested fallback
authority expected to arrive asynchronously
wait for the authority event, not blind time
brokered resource denied
return a typed broker error and preserve policy identity
Blind fallback can weaken security. If opening a protected configuration fails and the program silently uses permissive defaults, denial changes behavior in the dangerous direction. If a TLS key is unavailable and the server starts plaintext, a locked file becomes a protocol downgrade. Failure behavior belongs in the security design.
The stale-errno probe also matters in error handling. A wrapper that logs errno after a successful retry can report the previous denial as if the current operation failed. Check the return, capture the error on the failing path, and attach context from that attempt.
Signals and cancellation add more state. XNU’s public open is a cancellation point and calls open_nocancel after testing for cancellation. A thread cancellation can interrupt control flow around acquisition. Code that allocates an object before open and expects normal return-path cleanup needs RAII or an explicit cleanup handler. Security policy is not the only reason a syscall wrapper may fail to reach the next source line.
a failed open could still have changed state elsewhere
The selective O_RDONLY probe is close to side-effect free. Other syscalls and open flags are not.
open with O_CREAT can create a directory entry. O_TRUNC can reduce an existing file to zero length. A remote filesystem can send protocol messages before returning an error. A blocking operation can partially transfer bytes and report a short count. A write can update a page cache and fail later during writeback. Retrying without understanding those semantics can duplicate effects.
Security mediation can also be multi-stage. Path traversal may update caches. Audit machinery can record the attempt. A user-notification supervisor can receive and act on a request before the final result. A broker can open a descriptor and then fail to inject it into the target process.
The general rule “failed syscall did nothing” is false. Each interface defines its own commit point and partial-result semantics.
For read and write, a nonnegative return count can be smaller than requested. That is success for some bytes, not failure. For close, retrying after EINTR is famously portability-sensitive because the descriptor may already be gone on some systems. For fork, a returned error means no child was created for that call, but library at-fork handlers may have run in the parent before the kernel refusal.
The refusal probe controls this complexity by using O_RDONLY on an existing local regular file and checking one direct result. Extending its conclusion to all denied operations would discard the very interface details the syscall boundary exists to define.
policy evaluation had a cost, but this run did not measure it
Every additional authorization check executes code. A pathname policy may inspect operation type, credentials, vnode labels, rule tables, extension state, and audit configuration. A seccomp BPF program evaluates instructions on each filtered syscall. User notification performs queueing and context switches to a supervisor. Logging allocates or writes records.
That does not mean security policy is necessarily the bottleneck. An open that walks directories and reaches storage can dwarf a small in-kernel rule check. A hot getpid loop can make even a small per-call filter visible. A user-notification broker can dominate a formerly local syscall.
The required experiment begins with a prediction and separates workloads:
case A: allowed open of cached local file
case B: denied open at early syscall-number filter
case C: denied open after pathname resolution
case D: brokered open through user notification
Useful measurements would include operations per second, median and tail latency, CPU time in application and supervisor, context switches, policy rule count, pathname depth, cache state, and audit logging mode. Comparing one allowed operation with one denied operation cannot isolate policy overhead because the kernel work differs.
This article does not report such timing numbers. The verifier checks behavior, not performance. Claiming that one policy is cheap or expensive from these transcripts would turn mechanism evidence into a benchmark it was never designed to be.
The same restraint applies to attack-surface claims. Reducing allowed syscall numbers reduces reachable kernel interfaces under the assumed control flow. It does not provide a simple measured percentage of safety. One complex allowed ioctl can expose more code than many simple calls. Policy quality depends on reachable operations and argument constraints, not allowlist length alone.
the policy and the executable formed one versioned interface
A restrictive policy encodes assumptions about future program behavior. That makes deployment compatibility a two-sided problem.
Suppose version A uses:
openat
read
mmap
close
Version B changes a library and begins using:
openat
statx
io_uring_setup
io_uring_enter
close
A seccomp profile generated for A can kill or deny B even if application source did not add a visible system feature. A macOS entitlement change can remove access to a directory an update now uses. A container runtime update can tighten its default profile. A kernel update can add a syscall that old userspace filters do not recognize.
Safe rollout treats policy as a deployable artifact:
policy source in version control
compiled policy digest
test corpus of expected allowed operations
negative corpus of expected refusals
application and library version matrix
canary deployment
denial telemetry
rollback of code and policy as a compatible pair
The negative corpus is important. A policy that stops denying anything will make every positive compatibility test pass. The selective target, allowed sibling, denied alias, denied connect, and denied fork in the local verifier are small positive and negative controls.
Learning mode can help discover required operations, but capture must represent production behavior. A test suite may never exercise signal recovery, rare file rotation, DNS fallback, GPU error handling, or crash reporting. Shipping the observed allowlist without those paths can make the policy fail only during an incident.
Conversely, learning from an unconstrained developer environment can over-allow editors, debuggers, package managers, and unrelated background libraries. The recorded workload and its parent environment determine the policy.
the final return could now be predicted
The first open begins in the public C wrapper. _open prepares its variadic mode argument and calls the installed ___open stub. That stub puts 5 in x16 and executes svc #0x80.
XNU’s arm64 exception path classifies the BSD syscall. unix_syscall looks up entry 5, copies the arguments, and calls the generated open implementation. open_nocancel initializes a pathname lookup. namei walks /private/tmp/.../secret.txt, resolving directories and the final vnode. The MAC framework and traditional vnode authorization allow the first request. XNU installs the opened file in the process descriptor table, puts its descriptor number in saved x0, and returns with carry clear. The stub’s b.lo takes the success branch and returns descriptor 3.
Between calls, sandbox_init attaches the named process policy. The file’s mode bits and the process user ID stay unchanged.
The second _open prepares the same syscall number and arguments. Pathname lookup reaches the same target. This time the file operation matches the policy’s denied read. The authorization path returns EPERM. XNU writes 1 to saved x0 and sets carry. The stub does not take b.lo; it calls _cerror. _cerror stores 1 in the calling thread’s errno and returns -1.
The raw probe sees the state before that last translation:
carry=1 x0=1
The C probe sees it afterward:
return=-1 errno=1
The sibling predicts success because it does not match the resource rule. The symlink predicts refusal because resolution reaches the protected target. A new open predicts refusal after policy installation. The descriptor acquired before installation predicts a successful one-byte read in this measured profile. A network socket predicts successful creation followed by refused connection. A fork predicts no child and an EPERM returned to the parent.
The second call was never the same operation merely because the C line stayed the same. Its arguments met a process with less authority. The difference traveled back through one kernel integer and one processor flag, then a library routine gave that difference the name the program printed.