Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Do not rely on this file for a full inventory. The detailed public and contribut
| `crates/openshell-server/` | Gateway server | Control-plane API, sandbox lifecycle, auth boundary |
| `crates/openshell-sandbox/` | Sandbox runtime | Capability-free workload launcher, process identity, and seccomp-mediated I/O |
| `crates/openshell-supervisor/` | Supervisor runtime | Gateway session, policy evaluation, credentials, and upstream networking |
| `crates/openshell-accept-shim/` | Peer-address compatibility shim | Preloadable library that rewrites address-bearing `accept` for seccomp listeners without `WAIT_KILLABLE_RECV` |
| `crates/openshell-binary-identity/` | Binary identity | Shared trusted procfs executable identity resolution for isolation backends |
| `crates/openshell-isolation-interface/` | Isolation backend interface | RFC 0012 `IsolationBackend` trait and types; the supervisor-facing runtime contract |
| `crates/openshell-sandbox-backend/` | OpenShell sandbox backend | `OpenShellRuntimeBackend` and the authenticated OpenShell Sandbox Protocol shared with `openshell-sandbox` |
Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions crates/openshell-accept-shim/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

[package]
name = "openshell-accept-shim"
description = "Preloadable peer-address shim for seccomp listeners without WAIT_KILLABLE_RECV"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
build = "build.rs"

[dependencies]
libc = "0.2"

[build-dependencies]
# Resolves the target C compiler through the standard cross-compilation
# conventions, including the per-target wrappers cargo-zigbuild installs.
cc = "1"

[dev-dependencies]
object = { version = "0.37.3", default-features = false, features = ["read_core", "elf", "std"] }

[lints]
workspace = true
149 changes: 149 additions & 0 deletions crates/openshell-accept-shim/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<!--
SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-License-Identifier: Apache-2.0
-->

# openshell-accept-shim

Peer-address compatibility library for seccomp listeners that lack
`SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV`.

The crate carries three things: the freestanding C source for the library, a
build script that compiles and embeds it, and the Rust helpers the sandbox uses
to materialize it and compose `LD_PRELOAD`.

## Why it exists

`SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV` arrived in Linux 5.19. Without it the
broker cannot hold a notified workload thread in a kill-only wait, so it cannot
safely write into workload memory and fails closed with `EOPNOTSUPP` on every
mediated syscall whose result is an output buffer. `accept(fd, &addr, &len)` is
exactly that shape, so a server workload on RHEL 9.x or RHCOS sees `EOPNOTSUPP`
where it expects a connection.

The library rewrites that call into two syscalls the broker can satisfy in this
mode:

1. `accept4(fd, NULL, NULL, flags)` — no output buffer, so the broker injects
the accepted descriptor without touching workload memory.
2. `getpeername(accepted, addr, addrlen)` — the broker answers this with
`SECCOMP_USER_NOTIF_FLAG_CONTINUE` for a directly connected socket, so the
*kernel* writes the address into the caller's buffer.

Both halves are required. Step 2 depends on the broker's `CONTINUE` for
`getpeername`; without it, step 2 fails closed for the same reason step 1 did.

Because the workload writes its own memory, the cross-process TOCTOU that
`WAIT_KILLABLE_RECV` exists to close does not apply here.

## Not a security control

Nothing in OpenShell trusts this library's output.

The broker's loopback and authorization decisions use the kernel's own peer
address, obtained from the broker's own `accept4`. The library's result never
re-enters OpenShell's trust domain. A workload can unset `LD_PRELOAD`, link
statically, or issue raw syscalls, and gains nothing it did not already have —
it only loses the compatibility benefit. The enforcement boundary remains the
broker's fail-closed behavior.

## Coverage

`LD_PRELOAD` interposes library symbols, not syscalls.

| Runtime | Address-bearing `accept` | Reason |
| --- | --- | --- |
| Bun | Covered | Calls `accept4` through libc with a peer buffer |
| CPython | Covered | `sock_accept` passes a buffer, through libc |
| Node.js | Not needed | libuv always passes `NULL`; it resolves the peer lazily via `getpeername`, which the broker fix handles |
| Go | Not covered | `net` issues the syscall instruction directly |
| Static / `AT_SECURE` binaries | Not covered | No dynamic loader, or the preload is ignored |

On directly connected sockets, `getpeername` reports the true peer for every
runtime, including Go, because the kernel answers it. In legacy mode the
broker also continues `getpeername` on relayed outbound sockets, allowing the
query to succeed with the loopback relay's address rather than the upstream
destination. This fallback does not need the shim and does not change outbound
authorization. Modern listeners still substitute the original upstream address
through the broker's safe task-memory write path.

## Build invariants

The source is freestanding C rather than a Rust `cdylib`, because a `cdylib`
would carry a `DT_NEEDED` entry on either glibc or musl and be unloadable in the
other. The build script compiles with `-shared -fPIC -O2 -nostdlib
-fno-stack-protector`. Four properties must hold, and a regression in any of
them is a bug:

| Invariant | Why |
| --- | --- |
| No `DT_NEEDED` | One build per architecture loads under both glibc and musl |
| No `TEXTREL` | Avoids requiring SELinux `execmod`, the permission most likely denied to `container_t` |
| Required `__errno_location` and `dl_iterate_phdr` references | Resolve from glibc or musl without a loader dependency |
| Exactly two exported `FUNC` symbols, `accept` and `accept4` | Prevents accidental interposition of unrelated symbols such as `memcpy` |
| ELF machine matches the Rust target | A host object loads nowhere, and the loader reports it as `cannot open shared object file` — indistinguishable from a policy denial |

Supported architectures are `x86_64` and `aarch64`; the source fails to compile
on anything else rather than silently producing a non-functional object.

The build script resolves the C compiler through the `cc` crate, so the
standard `CC_<target>`, `TARGET_CC`, and `CC` overrides apply, as do the
per-target wrappers `cargo-zigbuild` installs when the release binaries are
cross-compiled from a non-Linux host. It then checks the emitted object's ELF
header against `CARGO_CFG_TARGET_ARCH` and fails the build on a mismatch,
because a misresolved compiler otherwise produces a valid object for the wrong
architecture.

The shim resolves libc's `accept4` from the already-loaded ELF image that
provides `__errno_location`, using `dl_iterate_phdr`. Delegating the blocking
phase preserves libc's cancellation handling, including the accepted-fd race
addressed by glibc BZ #12683. Peer lookup and error-path close use raw syscalls,
which are not cancellation points. A failed peer lookup closes the descriptor
and returns the kernel error; `ENOTCONN` becomes `ECONNABORTED` so a reset peer
does not produce a successful accept with an unusable address. Invalid output
pointers return `EFAULT` without userspace dereferences.

## Installation and child environments

The sandbox probes installation only for a legacy read-only listener. It
creates a sealed executable memfd instead of writing into the image's `/run`.
This works with a non-root identity, a read-only rootfs, and noexec temporary
mounts. There are no workload-selected pathname components or symlinks. Write,
grow, shrink, and seal seals prevent changing its bytes. The boundary's private
probe descriptor is close-on-exec.

Each entrypoint or exec launch gets a fresh sealed memfd inode. Its descriptor
remains close-on-exec in the boundary and is made inheritable only in its own
forked child. The loader opens `/proc/self/fd/N`.
A workload can close or chmod its own inode, but cannot replace the object or
change permissions on the inode used by a later operator exec session.
Anonymous inodes need no extra Landlock admission, so the shim does not create
a restrictive user ruleset when the authored policy has none.

Installation checks executable mapping before setting `LD_PRELOAD`. It requests
`MFD_EXEC` where supported and falls back to the pre-6.3 ABI on older kernels.
A host that forbids executable memfds keeps the broker's fail-closed behavior;
installation failure is non-fatal and logged.

Both launch paths compose the shim after all environment sources have been
applied, preserving the final provider, workload, or per-session override.
Directly launched ELF binaries for a different class or architecture do not
receive the shim. Descendants inherit ordinary `LD_PRELOAD` semantics: a child
that closes the descriptor, hides `/proc`, or invokes a foreign-architecture
loader must remove the shim entry itself. The boundary cannot check arbitrary
descendant execs, and those loaders may otherwise emit a preload warning.
Static and secure-execution loaders do not use the shim.

## Validation

Tests check the embedded target-compiler output for ELF target, no `DT_NEEDED`,
no text relocations, a non-executable stack, exactly `accept` and `accept4`
exports, and the expected libc references. Behavioral tests cover IPv4, IPv6,
truncation, reset peers, invalid length pointers, accepted streams, and pthread
cancellation, including coexistence with a provider preload defining `accept4`.
C fixtures use the same resolved target compiler as the shim.

Sandbox tests exercise a forced legacy listener with the real broker and
preloaded CPython: a rejected raw address-bearing accept must leave its client
queued for a subsequent shimmed accept. They also check connected DNS peer
queries and shim access under unrestricted and restricted Landlock policies.
140 changes: 140 additions & 0 deletions crates/openshell-accept-shim/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Compile the preloadable peer-address shim for Linux workload targets.
//!
//! The object is deliberately freestanding: `-nostdlib` keeps any `DT_NEEDED`
//! entry out of the result so a single per-architecture build loads under both
//! glibc and musl. `-fPIC` without text relocations also keeps `SELinux` from
//! requiring `execmod` on the materialized file.

use std::path::{Path, PathBuf};
use std::process::Command;

const SOURCE: &str = "src/accept_shim.c";

fn main() {
println!("cargo:rerun-if-changed={SOURCE}");

let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "linux" {
// Other hosts still build and lint the workspace; the shim is only
// ever loaded inside a Linux workload.
return;
}

let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR"));
let object = out_dir.join("accept_shim.so");

let mut command = compiler_command();
command
.args([
"-shared",
"-fPIC",
"-O2",
"-nostdlib",
"-fno-stack-protector",
// Undefined `__errno_location` is resolved from the workload's
// own libc at load time, so do not demand definitions here.
"-Wall",
"-Wextra",
"-Werror",
SOURCE,
"-o",
])
.arg(&object);

let status = command
.status()
.unwrap_or_else(|error| panic!("run C compiler {command:?}: {error}"));
assert!(status.success(), "compile {SOURCE}: {status}");

println!("cargo:rerun-if-changed=tests/fixtures/cancellation.c");
let helper = out_dir.join("cancellation");
let status = compiler_command()
.args([
"-std=c11",
"-Wall",
"-Wextra",
"-Werror",
"-pthread",
"-lc",
"tests/fixtures/cancellation.c",
"-o",
])
.arg(&helper)
.status()
.expect("compile cancellation helper");
assert!(status.success(), "compile cancellation helper: {status}");
println!(
"cargo:rustc-env=OPENSHELL_CANCELLATION_HELPER={}",
helper.display()
);

println!("cargo:rerun-if-changed=tests/fixtures/other_preload.c");
let other = out_dir.join("other_preload.so");
let status = compiler_command()
.args([
"-shared",
"-fPIC",
"-nostdlib",
"tests/fixtures/other_preload.c",
"-o",
])
.arg(&other)
.status()
.expect("compile other preload fixture");
assert!(status.success(), "compile other preload fixture: {status}");
println!(
"cargo:rustc-env=OPENSHELL_OTHER_PRELOAD={}",
other.display()
);

verify_object_architecture(&object);

println!("cargo:rustc-env=OPENSHELL_ACCEPT_SHIM={}", object.display());
}

/// Build the C compiler invocation for the target being compiled.
///
/// Resolution is delegated to the `cc` crate so the standard overrides
/// (`CC_<target>`, `TARGET_CC`, `CC`) and the per-target wrappers that
/// cross-build drivers such as `cargo-zigbuild` install are all honored.
/// Guessing a cross prefix instead would pick a binary that is frequently
/// absent on the build host.
fn compiler_command() -> Command {
match cc::Build::new().try_get_compiler() {
Ok(compiler) => compiler.to_command(),
Err(error) => panic!("locate a C compiler for the shim: {error}"),
}
}

/// Fail the build when the emitted object does not match the Rust target.
///
/// A host compiler reached through a misconfigured `CC` produces a valid
/// object for the wrong architecture. The workload's loader then reports
/// `cannot open shared object file`, which is indistinguishable from a
/// sandbox policy denial, so catch the mismatch here instead.
fn verify_object_architecture(object: &Path) {
let expected: u16 = match std::env::var("CARGO_CFG_TARGET_ARCH")
.unwrap_or_default()
.as_str()
{
"x86_64" => 0x3e, // EM_X86_64
"aarch64" => 0xb7, // EM_AARCH64
other => panic!("unsupported architecture for the accept shim: {other}"),
};

let bytes = std::fs::read(object).expect("read the compiled shim");
assert!(bytes.len() > 20, "compiled shim is not an ELF object");
assert_eq!(
&bytes[0..4],
b"\x7fELF",
"compiled shim is not an ELF object"
);
let machine = u16::from_le_bytes([bytes[18], bytes[19]]);
assert_eq!(
machine, expected,
"compiled shim targets ELF machine {machine:#x}, expected {expected:#x}"
);
}
Loading
Loading