Sandbox: seccomp user-notify fallback for Termux and kernels without Landlock

- Child installs a seccomp filter with a listener; a supervisor thread
  resolves the path of write-type syscalls (cwd, dirfd, .., symlinks,
  /proc/self, /dev/stdout, both ends of rename/link) and answers
  CONTINUE or EACCES; IPv4/IPv6 sockets are refused
- Probe order Landlock -> seccomp -> none, checked in a throwaway child;
  CST_SANDBOX=landlock|seccomp|off
- curl connect failures count as sandbox refusals
- test_sandbox runs the detected sandbox and forced seccomp, with escape,
  symlink, rename, link, chmod, stdout, ENOENT, background and network cases
- Docs: platform matrix, limits (not a security boundary, Yama and
  background jobs)
This commit is contained in:
WHC
2026-10-06 00:25:24 +08:00
parent 7d3ec9fcb9
commit 8efd63bb31
6 changed files with 933 additions and 28 deletions
+8 -3
View File
@@ -192,7 +192,7 @@ cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(120),超时整组进程被杀,exit=124 | | `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(120),超时整组进程被杀,exit=124 |
| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/<session>.jsonl`) | | `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/<session>.jsonl`) |
| `CST_RECORD_SSE=DIR` / `CST_REPLAY_SSE=DIR` | 录制 / 回放原始 SSE 流(`NNNN.sse` + `NNNN.req.json`) | | `CST_RECORD_SSE=DIR` / `CST_REPLAY_SSE=DIR` | 录制 / 回放原始 SSE 流(`NNNN.sse` + `NNNN.req.json`) |
| `CST_APPROVAL=ask\|auto\|yolo` / `CST_SANDBOX=off` | 审批模式 / 关闭沙箱(回落到只读白名单规则) | | `CST_APPROVAL=ask\|auto\|yolo` / `CST_SANDBOX=off\|landlock\|seccomp` | 审批模式 / 关闭沙箱(回落到只读白名单规则)或只用指定沙箱 |
| `CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR` | 模型列表、自动批准(= yolo)、shell、旁路 busybox | | `CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR` | 模型列表、自动批准(= yolo)、shell、旁路 busybox |
## Run ## Run
@@ -269,9 +269,14 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
- **沙箱**(C 实现,`src/sandbox_exec.c`): - **沙箱**(C 实现,`src/sandbox_exec.c`):
- Linux:Landlock(内核 5.13+,直接 syscall,无需 libseccomp)。子进程只能写工作区、临时目录、`/dev/null`、`/dev/tty`; - Linux:Landlock(内核 5.13+,直接 syscall,无需 libseccomp)。子进程只能写工作区、临时目录、`/dev/null`、`/dev/tty`;
读和执行不受限。Landlock ABI ≥ 4(内核 6.7+)时同时禁止 TCP 连接和监听。 读和执行不受限。Landlock ABI ≥ 4(内核 6.7+)时同时禁止 TCP 连接和监听。
- Linux 没有 Landlock 时(Android / Termux、WSL、老发行版;内核 5.5+,x86_64 / aarch64):seccomp user-notify
(界面显示 `auto · seccomp`)。子进程的写类系统调用(带写标志的 open、创建 / 删除 / 改名 / 链接 / chmod 等)先交给
agent 里的监督线程,按真实路径判断是否在工作区、临时目录或 `/dev/null` 等之内,不在就返回 `EACCES`;IPv4/IPv6 socket 一律拒绝。
读、执行和已打开文件的写入不检查。它防的是误操作而不是恶意程序:检查之后内核会再读一次路径,多线程程序可以趁机换掉。
比 shell 活得久的后台进程:桌面发行版开着 Yama(`ptrace_scope` ≥ 1)时它们的受检写入会被拒;命令结束后一律失败。
- macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,禁网。 - macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,禁网。
- Android / Termux、Windows、老内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。 - Windows、更老的内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。
- `CST_SANDBOX=off` 强制关闭沙箱。 - `CST_SANDBOX=off` 强制关闭沙箱;`CST_SANDBOX=landlock` / `seccomp` 只用指定的那种。
- 沙箱里的命令如果像是被拦了(`Permission denied` / `权限不够` / `Read-only file system` 等),会弹窗问 - 沙箱里的命令如果像是被拦了(`Permission denied` / `权限不够` / `Read-only file system` 等),会弹窗问
“Rerun outside the sandbox?”:`y` 在沙箱外重跑一次 · `p` 保存命令前缀规则并重跑 · `n` 保留沙箱结果(模型会收到说明)。 “Rerun outside the sandbox?”:`y` 在沙箱外重跑一次 · `p` 保存命令前缀规则并重跑 · `n` 保留沙箱结果(模型会收到说明)。
- **只读白名单**(无沙箱时 `auto` 用):`ls cat head tail wc grep rg find pwd echo which file stat du tree` - **只读白名单**(无沙箱时 `auto` 用):`ls cat head tail wc grep rg find pwd echo which file stat du tree`
+27 -9
View File
@@ -46,17 +46,35 @@ write beneath the workspace, the temp directory, `/dev/null` and `/dev/tty`.
| Platform | Sandbox | Notes | | Platform | Sandbox | Notes |
|---|---|---| |---|---|---|
| Linux 5.13+ | Landlock (raw syscalls, no extra library) | ABI ≥ 4 (6.7+) also blocks TCP connect/bind | | Linux 5.13+ | Landlock (raw syscalls, no extra library) | ABI ≥ 4 (6.7+) also blocks TCP connect/bind |
| Linux 5.5+ without Landlock (Android / Termux, WSL, older distros), x86_64 / aarch64 | seccomp user-notify | refuses IPv4/IPv6 sockets; see below |
| macOS | `sandbox_init` profile | also denies network | | macOS | `sandbox_init` profile | also denies network |
| Android / Termux | none | falls back to the read-only rules |
| WSL 2 | Landlock when the WSL kernel enables it | otherwise rules |
| Older kernels, Windows | none | falls back to the read-only rules | | Older kernels, Windows | none | falls back to the read-only rules |
The status bar and sidebar show `auto · landlock`, `auto · seatbelt` or The status bar and sidebar show `auto · landlock`, `auto · seccomp`,
`auto · rules`. `CST_SANDBOX=off` disables the sandbox. When a sandboxed `auto · seatbelt` or `auto · rules`. `CST_SANDBOX=off` disables the
command looks blocked, the TUI asks whether to rerun it outside the sandbox. sandbox; `CST_SANDBOX=landlock` or `CST_SANDBOX=seccomp` allows only that
The ruleset is built in the parent; the forked child only calls `prctl` and kind. When a sandboxed command looks blocked, the TUI asks whether to rerun
`landlock_restrict_self` (async-signal-safe), so the agent itself is never it outside the sandbox. The policy is built in the parent; the forked child
confined. only makes a few async-signal-safe syscalls before `exec`, so the agent
itself is never confined.
**seccomp.** Used when Landlock is missing, e.g. Android GKI kernels. The
child installs a seccomp filter with a user-notify listener and passes the
listener to a supervisor thread in the agent. Opens with write flags and
every create / remove / rename / link / chmod / chown / utimes / xattr call
stop until the supervisor has read the path from the child's memory,
resolved it (cwd, dirfd, `..`, symlinks, `/proc/self`, `/dev/stdout`) and
answered: continue when it lands in the allowed places, `EACCES` otherwise.
Reads, exec and writes to already-open files are not checked. Background
jobs that outlive their shell are no longer the agent's descendants: with
Yama `ptrace_scope` ≥ 1 (common on desktop distros, absent on Android) the
supervisor cannot read their memory and their checked writes fail with
`EACCES`; once the run has finished they get `ENOSYS`. This keeps
mistakes out of the rest of the disk but is not a security boundary: the
kernel reads the path again after the check, so a hostile multi-threaded
program can swap it in between. The availability check runs in a
throwaway child, so a platform seccomp policy that kills the caller cannot
take the agent down.
## Terminal notes ## Terminal notes
@@ -106,7 +124,7 @@ TUI:
- [ ] Tab toggles Build/Plan, Shift+Tab cycles ask/auto/yolo, Ctrl+P opens the palette - [ ] Tab toggles Build/Plan, Shift+Tab cycles ask/auto/yolo, Ctrl+P opens the palette
- [ ] `/model` lists the provider's `/models` (marked `(api)`); a wrong key is re-asked - [ ] `/model` lists the provider's `/models` (marked `(api)`); a wrong key is re-asked
- [ ] `auto` mode: `echo x > ~/outside` in run_shell asks "Rerun outside the - [ ] `auto` mode: `echo x > ~/outside` in run_shell asks "Rerun outside the
sandbox?" (Linux 5.13+/macOS); with `CST_SANDBOX=off`, `ls` runs and `touch x` asks sandbox?" (Linux/Termux/macOS); with `CST_SANDBOX=off`, `ls` runs and `touch x` asks
- [ ] Resize the terminal during streaming: layout recovers - [ ] Resize the terminal during streaming: layout recovers
- [ ] `NO_COLOR=1` and `CST_ASCII=1` render readable screens - [ ] `NO_COLOR=1` and `CST_ASCII=1` render readable screens
+1 -1
View File
@@ -78,7 +78,7 @@ export OPENAI_MODEL='your-model'
| `CST_MODELS` | `/model` 可选列表,`:` 或 `,` 分隔 | | `CST_MODELS` | `/model` 可选列表,`:` 或 `,` 分隔 |
| `CST_AUTO_APPROVE=1` | 自动允许 shell/写文件(= `--approval yolo`;headless 默认开启) | | `CST_AUTO_APPROVE=1` | 自动允许 shell/写文件(= `--approval yolo`;headless 默认开启) |
| `CST_APPROVAL` | 审批模式 `ask` / `auto`(默认)/ `yolo` | | `CST_APPROVAL` | 审批模式 `ask` / `auto`(默认)/ `yolo` |
| `CST_SANDBOX=off` | 关闭 run_shell 沙箱(Landlock / macOS sandbox),`auto` 回落到只读白名单 | | `CST_SANDBOX=off` | 关闭 run_shell 沙箱(Landlock / seccomp / macOS sandbox),`auto` 回落到只读白名单;`landlock` / `seccomp` 只允许该种 |
| `CST_SHELL` | shell 解释器路径(覆盖 busybox/`sh`) | | `CST_SHELL` | shell 解释器路径(覆盖 busybox/`sh`) |
| `CST_EXE_DIR` | 可执行文件目录(Termux 旁路 busybox 时有用) | | `CST_EXE_DIR` | 可执行文件目录(Termux 旁路 busybox 时有用) |
+15 -4
View File
@@ -6,6 +6,11 @@
* are limited to the workspace, the temp dir, /dev/null and /dev/tty, and * are limited to the workspace, the temp dir, /dev/null and /dev/tty, and
* the network is cut where the OS can do it. * the network is cut where the OS can do it.
* Linux Landlock (network needs ABI 4, kernel 6.7+) * Linux Landlock (network needs ABI 4, kernel 6.7+)
* Linux seccomp user-notify when Landlock is missing (Android/Termux,
* older kernels): a supervisor thread checks the path of every
* write-type syscall and IPv4/IPv6 sockets are refused. Guards
* against mistakes, not hostile code (the path is checked before
* the kernel reads it again).
* macOS sandbox_init (Seatbelt profile) * macOS sandbox_init (Seatbelt profile)
* other none: callers fall back to rule-based approval * other none: callers fall back to rule-based approval
*/ */
@@ -21,12 +26,17 @@ extern "C" {
typedef enum { typedef enum {
CST_SANDBOX_NONE = 0, CST_SANDBOX_NONE = 0,
CST_SANDBOX_LANDLOCK, CST_SANDBOX_LANDLOCK,
CST_SANDBOX_SEATBELT CST_SANDBOX_SEATBELT,
CST_SANDBOX_SECCOMP
} cst_sandbox_kind_t; } cst_sandbox_kind_t;
/* What this process can use; cached. CST_SANDBOX=0 forces NONE. */ /* What this process can use; cached. CST_SANDBOX=off|0|none forces NONE,
* CST_SANDBOX=landlock|seccomp allows only that kind. */
int cst_sandbox_probe(void); int cst_sandbox_probe(void);
const char *cst_sandbox_name(int kind); /* "landlock" | "seatbelt" | "none" */ /* Drops the cache and probes again (tests that change CST_SANDBOX). */
int cst_sandbox_reprobe(void);
/* "landlock" | "seatbelt" | "seccomp" | "none" */
const char *cst_sandbox_name(int kind);
/* Landlock ABI version (0 when unavailable); ≥4 also blocks TCP. */ /* Landlock ABI version (0 when unavailable); ≥4 also blocks TCP. */
int cst_sandbox_landlock_abi(void); int cst_sandbox_landlock_abi(void);
@@ -34,13 +44,14 @@ typedef struct {
int kind; int kind;
int ruleset_fd; /* Landlock, built in the parent */ int ruleset_fd; /* Landlock, built in the parent */
char *profile; /* Seatbelt */ char *profile; /* Seatbelt */
void *sc; /* seccomp: filter program and supervisor thread */
} cst_sandbox_plan_t; } cst_sandbox_plan_t;
/* Parent side: build the policy. Returns 0, or -1 (plan->kind = NONE). */ /* Parent side: build the policy. Returns 0, or -1 (plan->kind = NONE). */
int cst_sandbox_prepare(const char *workspace, const char *tmpdir, int cst_sandbox_prepare(const char *workspace, const char *tmpdir,
cst_sandbox_plan_t *plan); cst_sandbox_plan_t *plan);
/* Child side, after fork and before exec (os_run_capture_ex hook): /* Child side, after fork and before exec (os_run_capture_ex hook):
* async-signal-safe for Landlock. Returns 0 when confined. */ * async-signal-safe. Returns 0 when confined. */
int cst_sandbox_enter(void *plan); int cst_sandbox_enter(void *plan);
void cst_sandbox_release(cst_sandbox_plan_t *plan); void cst_sandbox_release(cst_sandbox_plan_t *plan);
+735 -7
View File
@@ -21,6 +21,30 @@
#include <stdint.h> #include <stdint.h>
#endif #endif
#if defined(__linux__) && (defined(__x86_64__) || defined(__aarch64__))
#include <fcntl.h>
#include <linux/audit.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <poll.h>
#include <pthread.h>
#include <signal.h>
#include <stddef.h>
#include <stdint.h>
#include <sys/ioctl.h>
#include <sys/prctl.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/uio.h>
#include <sys/wait.h>
#include <unistd.h>
#if defined(SECCOMP_RET_USER_NOTIF) && defined(SECCOMP_IOCTL_NOTIF_RECV) && \
defined(SECCOMP_FILTER_FLAG_NEW_LISTENER)
#define CST_HAVE_SECCOMP 1
#endif
#endif
static int g_kind = -1; static int g_kind = -1;
static int g_abi; static int g_abi;
@@ -174,26 +198,712 @@ static int sb_prepare(const char *ws, const char *tmp, cst_sandbox_plan_t *p) {
#endif /* CST_HAVE_SEATBELT */ #endif /* CST_HAVE_SEATBELT */
/* --- Linux: seccomp user-notify ------------------------------------------- */
#ifdef CST_HAVE_SECCOMP
#ifndef __NR_seccomp
#if defined(__x86_64__)
#define __NR_seccomp 317
#else
#define __NR_seccomp 277
#endif
#endif
#ifndef __NR_openat2
#define __NR_openat2 437
#endif
#ifndef __NR_fchmodat2
#define __NR_fchmodat2 452
#endif
#ifndef __NR_io_uring_setup
#define __NR_io_uring_setup 425
#endif
#ifndef SECCOMP_SET_MODE_FILTER
#define SECCOMP_SET_MODE_FILTER 1
#endif
#ifndef SECCOMP_USER_NOTIF_FLAG_CONTINUE
#define SECCOMP_USER_NOTIF_FLAG_CONTINUE (1UL << 0)
#endif
#ifndef PR_SET_NO_NEW_PRIVS
#define PR_SET_NO_NEW_PRIVS 38
#endif
#ifndef AT_EMPTY_PATH
#define AT_EMPTY_PATH 0x1000
#endif
#if defined(__x86_64__)
#define SC_ARCH AUDIT_ARCH_X86_64
#else
#define SC_ARCH AUDIT_ARCH_AARCH64
#endif
#define SC_WRITE_FLAGS (O_WRONLY | O_RDWR | O_CREAT | O_TRUNC)
#define SC_PROG_MAX 160
typedef struct {
struct sock_filter f[SC_PROG_MAX];
unsigned short n;
} sc_prog_t;
typedef struct {
sc_prog_t prog;
int sock[2]; /* the child sends its listener fd on [1] */
int stop[2]; /* release wakes the supervisor through [1] */
pthread_t th;
int started;
char ws[OS_PATH_MAX];
char tmp[OS_PATH_MAX];
} sc_state_t;
static void sc_emit(sc_prog_t *p, uint16_t code, uint8_t jt, uint8_t jf,
uint32_t k) {
if (p->n < SC_PROG_MAX) {
struct sock_filter ins = {code, jt, jf, k};
p->f[p->n++] = ins;
}
}
#define SC_LD(off) sc_emit(p, BPF_LD | BPF_W | BPF_ABS, 0, 0, (uint32_t)(off))
#define SC_RET(k) sc_emit(p, BPF_RET | BPF_K, 0, 0, (uint32_t)(k))
#define SC_JEQ(k, jt, jf) \
sc_emit(p, BPF_JMP | BPF_JEQ | BPF_K, jt, jf, (uint32_t)(k))
#define SC_JSET(k, jt, jf) \
sc_emit(p, BPF_JMP | BPF_JSET | BPF_K, jt, jf, (uint32_t)(k))
#define SC_ARG(i) (offsetof(struct seccomp_data, args) + (i) * sizeof(uint64_t))
/* Syscalls whose path arguments the supervisor always checks. */
static const int SC_NOTIFY[] = {
__NR_openat2, __NR_mkdirat, __NR_mknodat, __NR_unlinkat,
__NR_symlinkat, __NR_linkat, __NR_renameat2, __NR_truncate,
__NR_fchmodat, __NR_fchmodat2, __NR_fchownat, __NR_utimensat,
__NR_setxattr, __NR_lsetxattr, __NR_removexattr, __NR_lremovexattr,
#ifdef __NR_renameat
__NR_renameat,
#endif
#if defined(__x86_64__)
__NR_creat, __NR_mkdir, __NR_mknod, __NR_rmdir,
__NR_unlink, __NR_rename, __NR_link, __NR_symlink,
__NR_chmod, __NR_chown, __NR_lchown, __NR_utime,
__NR_utimes, __NR_futimesat,
#endif
};
/* Foreign ABIs are refused, IPv4/IPv6 sockets fail with EACCES, opens
* without write flags pass untouched, write-type calls go to the
* supervisor. */
static void sc_build(sc_prog_t *p) {
p->n = 0;
SC_LD(offsetof(struct seccomp_data, arch));
SC_JEQ(SC_ARCH, 1, 0);
SC_RET(SECCOMP_RET_ERRNO | EPERM);
SC_LD(offsetof(struct seccomp_data, nr));
#if defined(__x86_64__)
sc_emit(p, BPF_JMP | BPF_JGE | BPF_K, 0, 1, 0x40000000U); /* x32 */
SC_RET(SECCOMP_RET_ERRNO | EPERM);
#endif
SC_JEQ(__NR_socket, 0, 5);
SC_LD(SC_ARG(0));
SC_JEQ(AF_INET, 2, 0);
SC_JEQ(AF_INET6, 1, 0);
SC_RET(SECCOMP_RET_ALLOW);
SC_RET(SECCOMP_RET_ERRNO | EACCES);
SC_JEQ(__NR_io_uring_setup, 0, 1);
SC_RET(SECCOMP_RET_ERRNO | EPERM);
SC_JEQ(__NR_openat, 0, 4);
SC_LD(SC_ARG(2));
SC_JSET(SC_WRITE_FLAGS, 1, 0);
SC_RET(SECCOMP_RET_ALLOW);
SC_RET(SECCOMP_RET_USER_NOTIF);
#if defined(__x86_64__)
SC_JEQ(__NR_open, 0, 4);
SC_LD(SC_ARG(1));
SC_JSET(SC_WRITE_FLAGS, 1, 0);
SC_RET(SECCOMP_RET_ALLOW);
SC_RET(SECCOMP_RET_USER_NOTIF);
#endif
size_t n = sizeof(SC_NOTIFY) / sizeof(SC_NOTIFY[0]);
for (size_t i = 0; i < n; i++)
SC_JEQ(SC_NOTIFY[i], (uint8_t)(n - i), 0);
SC_RET(SECCOMP_RET_ALLOW);
SC_RET(SECCOMP_RET_USER_NOTIF);
}
static int sc_send_fd(int s, int fd) {
char c = 0;
union {
char buf[CMSG_SPACE(sizeof(int))];
struct cmsghdr align;
} u;
struct iovec io = {&c, 1};
struct msghdr m;
memset(&m, 0, sizeof(m));
memset(&u, 0, sizeof(u));
m.msg_iov = &io;
m.msg_iovlen = 1;
m.msg_control = u.buf;
m.msg_controllen = sizeof(u.buf);
struct cmsghdr *h = CMSG_FIRSTHDR(&m);
h->cmsg_level = SOL_SOCKET;
h->cmsg_type = SCM_RIGHTS;
h->cmsg_len = CMSG_LEN(sizeof(int));
memcpy(CMSG_DATA(h), &fd, sizeof(int));
return sendmsg(s, &m, MSG_NOSIGNAL) == 1 ? 0 : -1;
}
static int sc_recv_fd(int s) {
char c;
union {
char buf[CMSG_SPACE(sizeof(int))];
struct cmsghdr align;
} u;
struct iovec io = {&c, 1};
struct msghdr m;
memset(&m, 0, sizeof(m));
m.msg_iov = &io;
m.msg_iovlen = 1;
m.msg_control = u.buf;
m.msg_controllen = sizeof(u.buf);
if (recvmsg(s, &m, MSG_CMSG_CLOEXEC) != 1)
return -1;
struct cmsghdr *h = CMSG_FIRSTHDR(&m);
if (!h || h->cmsg_level != SOL_SOCKET || h->cmsg_type != SCM_RIGHTS)
return -1;
int fd;
memcpy(&fd, CMSG_DATA(h), sizeof(int));
return fd;
}
/* Reads child memory one page at a time (a range may end at a hole). */
static ssize_t sc_peek(int pid, uint64_t addr, void *out, size_t n) {
struct iovec l = {out, n}, r = {(void *)(uintptr_t)addr, n};
ssize_t k = process_vm_readv(pid, &l, 1, &r, 1, 0);
if (k > 0)
return k;
char mp[64];
snprintf(mp, sizeof(mp), "/proc/%d/mem", pid);
int fd = open(mp, O_RDONLY | O_CLOEXEC);
if (fd < 0)
return -1;
k = pread(fd, out, n, (off_t)addr);
close(fd);
return k;
}
static int sc_read_mem(int pid, uint64_t addr, void *out, size_t n) {
size_t got = 0;
while (got < n) {
uint64_t a = addr + got;
size_t chunk = 4096 - (size_t)(a & 4095);
if (chunk > n - got)
chunk = n - got;
ssize_t k = sc_peek(pid, a, (char *)out + got, chunk);
if (k <= 0)
return -1;
got += (size_t)k;
}
return 0;
}
static int sc_read_str(int pid, uint64_t addr, char *out, size_t n) {
size_t got = 0;
while (got + 1 < n) {
uint64_t a = addr + got;
size_t chunk = 4096 - (size_t)(a & 4095);
if (chunk > n - 1 - got)
chunk = n - 1 - got;
ssize_t k = sc_peek(pid, a, out + got, chunk);
if (k <= 0)
return -1;
if (memchr(out + got, 0, (size_t)k))
return 0;
got += (size_t)k;
}
return -1;
}
static int sc_readlink(const char *link, char *out, size_t n) {
ssize_t k = readlink(link, out, n - 1);
if (k < 0)
return -1;
out[k] = 0;
return 0;
}
/* strict: the root itself does not count (it may not be removed). */
static int sc_under(const char *root, const char *p, int strict) {
size_t n = strlen(root);
if (n == 0)
return 0;
if (n == 1 && root[0] == '/')
return p[0] == '/' && (!strict || p[1]);
if (strncmp(p, root, n) != 0)
return 0;
if (p[n] == 0)
return !strict;
return p[n] == '/';
}
static int sc_inside(const sc_state_t *s, const char *p, int strict) {
static const char *const DEV[] = {"/dev/null", "/dev/zero", "/dev/full",
"/dev/tty", "/dev/ptmx", "/dev/random",
"/dev/urandom"};
if (sc_under(s->ws, p, strict) || sc_under(s->tmp, p, strict))
return 1;
for (size_t i = 0; i < sizeof(DEV) / sizeof(DEV[0]); i++)
if (strcmp(p, DEV[i]) == 0)
return 1;
return strncmp(p, "/dev/pts/", 9) == 0;
}
/* The file behind fd (or the cwd for AT_FDCWD). Pipes, sockets and other
* anonymous files are fine; unreadable links are refused. */
static int sc_fd_ok(const sc_state_t *s, int pid, int fd, int strict) {
char link[64], tgt[OS_PATH_MAX];
if (fd == AT_FDCWD)
snprintf(link, sizeof(link), "/proc/%d/cwd", pid);
else
snprintf(link, sizeof(link), "/proc/%d/fd/%d", pid, fd);
if (sc_readlink(link, tgt, sizeof(tgt)) != 0)
return 0;
return tgt[0] != '/' || sc_inside(s, tgt, strict);
}
static int sc_digits(const char **q) {
const char *p = *q;
while (**q >= '0' && **q <= '9')
(*q)++;
return *q > p;
}
/* /proc/N/fd/M or /proc/N/task/T/fd/M */
static int sc_is_fd_link(const char *p) {
if (strncmp(p, "/proc/", 6) != 0)
return 0;
p += 6;
if (!sc_digits(&p))
return 0;
if (strncmp(p, "/task/", 6) == 0) {
p += 6;
if (!sc_digits(&p))
return 0;
}
if (strncmp(p, "/fd/", 4) != 0)
return 0;
p += 4;
return sc_digits(&p) && *p == 0;
}
/* /proc/self, /dev/fd and /dev/std* name the supervisor when it resolves
* them, so they are rewritten to the task first. */
static void sc_rewrite(int pid, const char *in, char *out, size_t n) {
static const char *const STD[] = {"/dev/stdin", "/dev/stdout",
"/dev/stderr"};
for (int i = 0; i < 3; i++)
if (strcmp(in, STD[i]) == 0) {
snprintf(out, n, "/proc/%d/fd/%d", pid, i);
return;
}
if (strncmp(in, "/dev/fd/", 8) == 0) {
snprintf(out, n, "/proc/%d/fd/%s", pid, in + 8);
return;
}
static const char *const SELF[] = {"/proc/self", "/proc/thread-self"};
for (int i = 0; i < 2; i++) {
size_t k = strlen(SELF[i]);
if (strncmp(in, SELF[i], k) == 0 && (in[k] == 0 || in[k] == '/')) {
snprintf(out, n, "/proc/%d%s", pid, in + k);
return;
}
}
snprintf(out, n, "%s", in);
}
/* full: absolute path as the task sees it. follow: the call follows a
* symlink in the last component. */
static int sc_abs_ok(const sc_state_t *s, const char *full, int follow,
int strict) {
char path[OS_PATH_MAX], real[OS_PATH_MAX], loc[2 * OS_PATH_MAX + 2];
if (strlen(full) >= sizeof(path))
return 0;
snprintf(path, sizeof(path), "%s", full);
size_t n = strlen(path);
while (n > 1 && path[n - 1] == '/')
path[--n] = 0;
char *slash = strrchr(path, '/');
if (!slash)
return 0;
const char *base = slash + 1;
if (!*base || strcmp(base, ".") == 0 || strcmp(base, "..") == 0) {
if (!realpath(path, real))
return errno != ENAMETOOLONG;
return sc_inside(s, real, strict);
}
if (slash == path) {
real[0] = 0;
} else {
*slash = 0;
/* a missing or unreadable parent fails the same way in the kernel */
if (!realpath(path, real))
return errno == ENOENT || errno == ENOTDIR || errno == EACCES ||
errno == ELOOP;
if (strcmp(real, "/") == 0)
real[0] = 0;
}
snprintf(loc, sizeof(loc), "%s/%s", real, base);
if (strlen(loc) >= OS_PATH_MAX)
return 0;
if (sc_is_fd_link(loc)) {
char tgt[OS_PATH_MAX];
if (sc_readlink(loc, tgt, sizeof(tgt)) != 0)
return 0;
return tgt[0] != '/' || sc_inside(s, tgt, 0);
}
if (follow) {
struct stat st;
if (lstat(loc, &st) == 0 && S_ISLNK(st.st_mode)) {
if (!realpath(loc, real))
return 0; /* dangling: following it would create the target */
return sc_inside(s, real, strict);
}
}
return sc_inside(s, loc, strict);
}
/* One (dirfd, path) argument pair. addr 0 (or "" with AT_EMPTY_PATH)
* means the call acts on dirfd itself. */
static int sc_path_ok(const sc_state_t *s, int pid, int dirfd, uint64_t addr,
int follow, int empty_ok, int strict) {
char rel[OS_PATH_MAX], base[OS_PATH_MAX], full[2 * OS_PATH_MAX];
if (addr == 0)
return sc_fd_ok(s, pid, dirfd, strict);
if (sc_read_str(pid, addr, rel, sizeof(rel)) != 0)
return 0;
if (!rel[0])
return empty_ok ? sc_fd_ok(s, pid, dirfd, strict) : 1;
if (rel[0] == '/') {
sc_rewrite(pid, rel, full, sizeof(full));
} else {
char link[64];
if (dirfd == AT_FDCWD)
snprintf(link, sizeof(link), "/proc/%d/cwd", pid);
else
snprintf(link, sizeof(link), "/proc/%d/fd/%d", pid, dirfd);
if (sc_readlink(link, base, sizeof(base)) != 0 || base[0] != '/')
return 0;
snprintf(full, sizeof(full), "%s/%s", base, rel);
}
return sc_abs_ok(s, full, follow, strict);
}
/* 1: let the call run, 0: fail it with EACCES. */
static int sc_decide(const sc_state_t *s, const struct seccomp_notif *req) {
const uint64_t *a = (const uint64_t *)req->data.args;
int pid = (int)req->pid;
#define SC_P(d, p, fl, em, st) \
sc_path_ok(s, pid, (int)(d), (p), (fl), (em), (st))
switch (req->data.nr) {
case __NR_openat: {
int fl = (int)a[2];
return !(fl & SC_WRITE_FLAGS) || SC_P(a[0], a[1], !(fl & O_NOFOLLOW), 0, 0);
}
case __NR_openat2: {
struct {
uint64_t flags, mode, resolve;
} how;
memset(&how, 0, sizeof(how));
size_t sz = a[3] < sizeof(how) ? (size_t)a[3] : sizeof(how);
if (sz < sizeof(uint64_t) || sc_read_mem(pid, a[2], &how, sz) != 0)
return 0;
if (!(how.flags & SC_WRITE_FLAGS))
return 1;
return SC_P(a[0], a[1],
!(how.flags & O_NOFOLLOW) && !(how.resolve & 0x04 /* NO_SYMLINKS */),
0, 0);
}
case __NR_mkdirat:
case __NR_mknodat:
return SC_P(a[0], a[1], 0, 0, 0);
case __NR_unlinkat:
return SC_P(a[0], a[1], 0, 0, 1);
case __NR_symlinkat:
return SC_P(a[1], a[2], 0, 0, 0);
case __NR_linkat:
return SC_P(a[0], a[1], (a[4] & AT_SYMLINK_FOLLOW) != 0,
(a[4] & AT_EMPTY_PATH) != 0, 0) &&
SC_P(a[2], a[3], 0, 0, 0);
#ifdef __NR_renameat
case __NR_renameat:
#endif
case __NR_renameat2:
return SC_P(a[0], a[1], 0, 0, 1) && SC_P(a[2], a[3], 0, 0, 1);
case __NR_truncate:
return SC_P(AT_FDCWD, a[0], 1, 0, 0);
case __NR_fchmodat:
return SC_P(a[0], a[1], 1, 0, 0);
case __NR_fchmodat2:
return SC_P(a[0], a[1], !(a[3] & AT_SYMLINK_NOFOLLOW),
(a[3] & AT_EMPTY_PATH) != 0, 0);
case __NR_fchownat:
return SC_P(a[0], a[1], !(a[4] & AT_SYMLINK_NOFOLLOW),
(a[4] & AT_EMPTY_PATH) != 0, 0);
case __NR_utimensat:
return SC_P(a[0], a[1], !(a[3] & AT_SYMLINK_NOFOLLOW), 0, 0);
case __NR_setxattr:
case __NR_removexattr:
return SC_P(AT_FDCWD, a[0], 1, 0, 0);
case __NR_lsetxattr:
case __NR_lremovexattr:
return SC_P(AT_FDCWD, a[0], 0, 0, 0);
#if defined(__x86_64__)
case __NR_open: {
int fl = (int)a[1];
return !(fl & SC_WRITE_FLAGS) ||
SC_P(AT_FDCWD, a[0], !(fl & O_NOFOLLOW), 0, 0);
}
case __NR_creat:
case __NR_chmod:
case __NR_chown:
case __NR_utime:
case __NR_utimes:
return SC_P(AT_FDCWD, a[0], 1, 0, 0);
case __NR_mkdir:
case __NR_mknod:
case __NR_lchown:
return SC_P(AT_FDCWD, a[0], 0, 0, 0);
case __NR_rmdir:
case __NR_unlink:
return SC_P(AT_FDCWD, a[0], 0, 0, 1);
case __NR_rename:
return SC_P(AT_FDCWD, a[0], 0, 0, 1) && SC_P(AT_FDCWD, a[1], 0, 0, 1);
case __NR_link:
return SC_P(AT_FDCWD, a[0], 0, 0, 0) && SC_P(AT_FDCWD, a[1], 0, 0, 0);
case __NR_symlink:
return SC_P(AT_FDCWD, a[1], 0, 0, 0);
case __NR_futimesat:
return SC_P(a[0], a[1], 1, 0, 0);
#endif
default:
return 0;
}
#undef SC_P
}
static void sc_handle(const sc_state_t *s, int lfd) {
struct seccomp_notif req;
struct seccomp_notif_resp resp;
memset(&req, 0, sizeof(req));
if (ioctl(lfd, SECCOMP_IOCTL_NOTIF_RECV, &req) != 0)
return; /* the task died meanwhile */
int allow = sc_decide(s, &req);
/* the pid read above still names the task that made the call */
if (ioctl(lfd, SECCOMP_IOCTL_NOTIF_ID_VALID, &req.id) != 0)
return;
memset(&resp, 0, sizeof(resp));
resp.id = req.id;
if (allow)
resp.flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE;
else
resp.error = -EACCES;
ioctl(lfd, SECCOMP_IOCTL_NOTIF_SEND, &resp);
}
/* Waits for the listener fd, then answers until no task uses the filter
* or release() asks to stop. Tasks left over after that get ENOSYS from
* the kernel for checked calls. */
static void *sc_main(void *arg) {
sc_state_t *s = arg;
int lfd = -1;
for (;;) {
struct pollfd pf[2] = {{s->sock[0], POLLIN, 0}, {s->stop[0], POLLIN, 0}};
if (poll(pf, 2, -1) < 0) {
if (errno == EINTR)
continue;
return NULL;
}
if (pf[1].revents)
return NULL;
if (pf[0].revents) {
lfd = sc_recv_fd(s->sock[0]);
break;
}
}
if (lfd < 0)
return NULL;
for (;;) {
struct pollfd pf[2] = {{lfd, POLLIN, 0}, {s->stop[0], POLLIN, 0}};
if (poll(pf, 2, -1) < 0) {
if (errno == EINTR)
continue;
break;
}
if (pf[0].revents & POLLIN) {
sc_handle(s, lfd);
continue;
}
if (pf[0].revents || pf[1].revents)
break;
}
close(lfd);
return NULL;
}
static void sc_free(sc_state_t *s) {
if (!s)
return;
if (s->started) {
char c = 1;
ssize_t w = write(s->stop[1], &c, 1);
(void)w;
pthread_join(s->th, NULL);
}
for (int i = 0; i < 2; i++) {
if (s->sock[i] >= 0)
close(s->sock[i]);
if (s->stop[i] >= 0)
close(s->stop[i]);
}
free(s);
}
static int sc_prepare(const char *ws, const char *tmp, cst_sandbox_plan_t *p) {
sc_state_t *s = calloc(1, sizeof(*s));
if (!s)
return -1;
s->sock[0] = s->sock[1] = s->stop[0] = s->stop[1] = -1;
if (!realpath(ws, s->ws))
goto fail;
if (!tmp || !tmp[0] || !realpath(tmp, s->tmp))
s->tmp[0] = 0;
sc_build(&s->prog);
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, s->sock) != 0 ||
pipe2(s->stop, O_CLOEXEC) != 0)
goto fail;
if (pthread_create(&s->th, NULL, sc_main, s) != 0)
goto fail;
s->started = 1;
p->sc = s;
return 0;
fail:
sc_free(s);
return -1;
}
static int sc_install(const sc_prog_t *prog, int sock) {
struct sock_fprog fp;
fp.len = prog->n;
fp.filter = (struct sock_filter *)prog->f;
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0)
return -1;
int lfd = (int)syscall(__NR_seccomp, SECCOMP_SET_MODE_FILTER,
SECCOMP_FILTER_FLAG_NEW_LISTENER, &fp);
if (lfd < 0)
return -1;
int rc = sc_send_fd(sock, lfd);
close(lfd);
return rc;
}
/* The whole round trip in a throwaway child: install a listener filter on
* getppid, answer it with CONTINUE (kernel 5.5+). A child killed by the
* platform's own seccomp policy only costs that child. */
static int sc_probe(void) {
sc_prog_t prog;
sc_prog_t *p = &prog;
p->n = 0;
SC_LD(offsetof(struct seccomp_data, arch));
SC_JEQ(SC_ARCH, 1, 0);
SC_RET(SECCOMP_RET_ALLOW);
SC_LD(offsetof(struct seccomp_data, nr));
SC_JEQ(__NR_getppid, 0, 1);
SC_RET(SECCOMP_RET_USER_NOTIF);
SC_RET(SECCOMP_RET_ALLOW);
int sp[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sp) != 0)
return 0;
pid_t self = getpid();
pid_t pid = fork();
if (pid == 0) {
close(sp[0]);
if (sc_install(&prog, sp[1]) != 0)
_exit(3);
_exit(syscall(__NR_getppid) == self ? 0 : 4);
}
close(sp[1]);
if (pid < 0) {
close(sp[0]);
return 0;
}
int ok = 0;
struct pollfd pf = {sp[0], POLLIN, 0};
if (poll(&pf, 1, 3000) > 0) {
int lfd = sc_recv_fd(sp[0]);
if (lfd >= 0) {
struct pollfd pl = {lfd, POLLIN, 0};
if (poll(&pl, 1, 3000) > 0 && (pl.revents & POLLIN)) {
struct seccomp_notif req;
struct seccomp_notif_resp resp;
memset(&req, 0, sizeof(req));
if (ioctl(lfd, SECCOMP_IOCTL_NOTIF_RECV, &req) == 0) {
memset(&resp, 0, sizeof(resp));
resp.id = req.id;
resp.flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE;
ok = ioctl(lfd, SECCOMP_IOCTL_NOTIF_SEND, &resp) == 0;
}
}
close(lfd);
}
}
close(sp[0]);
if (!ok)
kill(pid, SIGKILL);
int st = 0;
while (waitpid(pid, &st, 0) < 0 && errno == EINTR)
;
return ok && WIFEXITED(st) && WEXITSTATUS(st) == 0;
}
#endif /* CST_HAVE_SECCOMP */
/* --- common --------------------------------------------------------------- */ /* --- common --------------------------------------------------------------- */
int cst_sandbox_probe(void) { int cst_sandbox_probe(void) {
if (g_kind >= 0) if (g_kind >= 0)
return g_kind; return g_kind;
g_kind = CST_SANDBOX_NONE; int kind = CST_SANDBOX_NONE;
const char *e = getenv("CST_SANDBOX"); const char *e = getenv("CST_SANDBOX");
if (e && (strcmp(e, "0") == 0 || strcmp(e, "off") == 0 || if (e && (strcmp(e, "0") == 0 || strcmp(e, "off") == 0 ||
strcmp(e, "none") == 0)) strcmp(e, "none") == 0)) {
g_kind = kind;
return g_kind; return g_kind;
}
int want_ll = !e || strcmp(e, "seccomp") != 0;
int want_sc = !e || strcmp(e, "landlock") != 0;
#ifdef CST_HAVE_LANDLOCK #ifdef CST_HAVE_LANDLOCK
g_abi = ll_abi(); if (want_ll) {
if (g_abi > 0) g_abi = ll_abi();
g_kind = CST_SANDBOX_LANDLOCK; if (g_abi > 0)
#elif defined(CST_HAVE_SEATBELT) kind = CST_SANDBOX_LANDLOCK;
g_kind = CST_SANDBOX_SEATBELT; }
#endif #endif
#ifdef CST_HAVE_SECCOMP
if (kind == CST_SANDBOX_NONE && want_sc && sc_probe())
kind = CST_SANDBOX_SECCOMP;
#endif
#ifdef CST_HAVE_SEATBELT
kind = CST_SANDBOX_SEATBELT;
#endif
(void)want_ll;
(void)want_sc;
g_kind = kind;
return g_kind; return g_kind;
} }
int cst_sandbox_reprobe(void) {
g_kind = -1;
g_abi = 0;
return cst_sandbox_probe();
}
int cst_sandbox_landlock_abi(void) { int cst_sandbox_landlock_abi(void) {
cst_sandbox_probe(); cst_sandbox_probe();
return g_abi; return g_abi;
@@ -205,6 +915,8 @@ const char *cst_sandbox_name(int kind) {
return "landlock"; return "landlock";
case CST_SANDBOX_SEATBELT: case CST_SANDBOX_SEATBELT:
return "seatbelt"; return "seatbelt";
case CST_SANDBOX_SECCOMP:
return "seccomp";
default: default:
return "none"; return "none";
} }
@@ -223,6 +935,10 @@ int cst_sandbox_prepare(const char *workspace, const char *tmpdir,
#ifdef CST_HAVE_SEATBELT #ifdef CST_HAVE_SEATBELT
if (kind == CST_SANDBOX_SEATBELT) if (kind == CST_SANDBOX_SEATBELT)
rc = sb_prepare(workspace, tmpdir, plan); rc = sb_prepare(workspace, tmpdir, plan);
#endif
#ifdef CST_HAVE_SECCOMP
if (kind == CST_SANDBOX_SECCOMP)
rc = sc_prepare(workspace, tmpdir, plan);
#endif #endif
(void)workspace; (void)workspace;
(void)tmpdir; (void)tmpdir;
@@ -252,6 +968,12 @@ int cst_sandbox_enter(void *arg) {
} }
return 0; return 0;
} }
#endif
#ifdef CST_HAVE_SECCOMP
if (p->kind == CST_SANDBOX_SECCOMP && p->sc) {
const sc_state_t *s = p->sc;
return sc_install(&s->prog, s->sock[1]);
}
#endif #endif
return -1; return -1;
} }
@@ -263,6 +985,10 @@ void cst_sandbox_release(cst_sandbox_plan_t *plan) {
if (plan->ruleset_fd >= 0) if (plan->ruleset_fd >= 0)
close(plan->ruleset_fd); close(plan->ruleset_fd);
#endif #endif
#ifdef CST_HAVE_SECCOMP
sc_free(plan->sc);
#endif
plan->sc = NULL;
free(plan->profile); free(plan->profile);
plan->profile = NULL; plan->profile = NULL;
plan->ruleset_fd = -1; plan->ruleset_fd = -1;
@@ -307,6 +1033,8 @@ int cst_sandbox_blocked(const char *out, size_t n, int exit_code) {
"Permission denied", "Read-only file system", "Operation not permitted", "Permission denied", "Read-only file system", "Operation not permitted",
"EACCES", "EPERM", "EROFS", "EACCES", "EPERM", "EROFS",
"[cst: sandbox", "Network is unreachable", "[cst: sandbox", "Network is unreachable",
/* curl when the socket itself was refused (seccomp) */
"Could not connect to server", "Failed to connect to",
/* localized strerror(EACCES/EPERM/EROFS) */ /* localized strerror(EACCES/EPERM/EROFS) */
"权限不够", "不允许的操作", "只读文件系统", "權限不足", "权限不够", "不允许的操作", "只读文件系统", "權限不足",
"拒絕不符權限的操作", "許可がありません", "Keine Berechtigung", "拒絕不符權限的操作", "許可がありません", "Keine Berechtigung",
+147 -4
View File
@@ -70,6 +70,9 @@ static void test_blocked(void) {
CHECK(!cst_sandbox_blocked(b, strlen(b), 1)); CHECK(!cst_sandbox_blocked(b, strlen(b), 1));
const char *c = "touch: cannot touch 'x': Read-only file system\n"; const char *c = "touch: cannot touch 'x': Read-only file system\n";
CHECK(cst_sandbox_blocked(c, strlen(c), 1)); CHECK(cst_sandbox_blocked(c, strlen(c), 1));
const char *d = "curl: (7) Failed to connect to example.com port 443 after 9 "
"ms: Could not connect to server\n";
CHECK(cst_sandbox_blocked(d, strlen(d), 7));
} }
#if !defined(_WIN32) #if !defined(_WIN32)
@@ -112,7 +115,10 @@ static void test_network(const char *ws) {
if (pid == 0) { if (pid == 0) {
if (cst_sandbox_enter(&plan) != 0) if (cst_sandbox_enter(&plan) != 0)
_exit(3); _exit(3);
/* Landlock refuses the connect, seccomp already the socket */
int s = socket(AF_INET, SOCK_STREAM, 0); int s = socket(AF_INET, SOCK_STREAM, 0);
if (s < 0)
_exit(errno == EACCES ? 0 : 2);
int rc = connect(s, (struct sockaddr *)&a, sizeof(a)); int rc = connect(s, (struct sockaddr *)&a, sizeof(a));
_exit(rc == 0 ? 1 : errno == EACCES ? 0 : 2); _exit(rc == 0 ? 1 : errno == EACCES ? 0 : 2);
} }
@@ -123,7 +129,123 @@ static void test_network(const char *ws) {
CHECK(WIFEXITED(st) && WEXITSTATUS(st) == 0); CHECK(WIFEXITED(st) && WEXITSTATUS(st) == 0);
} }
static void test_landlock(void) { static int blocked(const os_run_result_t *r) {
if (!cst_sandbox_blocked(r->out, r->out_len, r->exit_code)) {
fprintf(stderr, "expected a sandbox refusal (exit %d): %s\n", r->exit_code,
r->out ? r->out : "");
return 0;
}
return 1;
}
static char *slurp(const char *path) {
FILE *fp = fopen(path, "r");
if (!fp)
return NULL;
static char buf[256];
size_t n = fread(buf, 1, sizeof(buf) - 1, fp);
buf[n] = 0;
fclose(fp);
return buf;
}
/* Path tricks the seccomp supervisor has to resolve itself. */
static void test_seccomp_paths(const char *ws, const char *out) {
char cmd[3 * OS_PATH_MAX], p[OS_PATH_MAX + 64], q[OS_PATH_MAX + 64];
char readme[OS_PATH_MAX + 64];
os_run_result_t r;
snprintf(readme, sizeof(readme), "%s/readme.txt", out);
/* ../ out of the workspace */
const char *outname = strrchr(out, '/') + 1;
snprintf(cmd, sizeof(cmd), "echo x > '../%s/e1'", outname);
snprintf(p, sizeof(p), "%s/e1", out);
CHECK(run(ws, cmd, &r) != 0 && blocked(&r) && !exists(p));
os_run_result_free(&r);
/* a symlink inside may be created, but not written through */
snprintf(cmd, sizeof(cmd), "ln -s '%s' link && echo x > link/e2", out);
snprintf(p, sizeof(p), "%s/e2", out);
snprintf(q, sizeof(q), "%s/link", ws);
CHECK(run(ws, cmd, &r) != 0 && blocked(&r) && !exists(p));
struct stat st;
CHECK(lstat(q, &st) == 0 && S_ISLNK(st.st_mode));
os_run_result_free(&r);
snprintf(cmd, sizeof(cmd), "ln -s '%s' rl && echo x >> rl", readme);
CHECK(run(ws, cmd, &r) != 0 && blocked(&r));
CHECK(strcmp(slurp(readme), "outside-content\n") == 0);
os_run_result_free(&r);
/* moving, hard-linking or chmod-ing an outside file */
snprintf(cmd, sizeof(cmd), "mv '%s' ./moved", readme);
CHECK(run(ws, cmd, &r) != 0 && blocked(&r) && exists(readme));
os_run_result_free(&r);
snprintf(cmd, sizeof(cmd), "ln '%s' hard", readme);
snprintf(p, sizeof(p), "%s/hard", ws);
CHECK(run(ws, cmd, &r) != 0 && blocked(&r) && !exists(p));
os_run_result_free(&r);
snprintf(cmd, sizeof(cmd), "chmod 600 '%s'", readme);
CHECK(run(ws, cmd, &r) != 0 && blocked(&r));
os_run_result_free(&r);
/* the command's own stdout/stderr */
CHECK(run(ws, "echo hi > /dev/stdout && echo e > /dev/stderr && "
"echo fd > /proc/self/fd/1",
&r) == 0);
CHECK(strstr(r.out, "hi") && strstr(r.out, "fd"));
os_run_result_free(&r);
/* ordinary tree work inside */
CHECK(run(ws, "mkdir -p a/b/c && touch a/b/c/f && mv a/b/c/f a/g && "
"ln -s g a/s && chmod 600 a/g && rm -r a",
&r) == 0);
os_run_result_free(&r);
/* a missing parent stays ENOENT, not a sandbox refusal */
CHECK(run(ws, "touch nodir/x", &r) != 0);
CHECK(!cst_sandbox_blocked(r.out, r.out_len, r.exit_code));
os_run_result_free(&r);
/* many checked writes */
double t0 = os_now_ms();
CHECK(run(ws, "i=0; while [ $i -lt 300 ]; do echo $i > f$i; "
"i=$((i+1)); done; ls | grep -c '^f[0-9]'",
&r) == 0);
CHECK(strstr(r.out, "300") != NULL);
printf("test_sandbox: 300 checked writes in %.0f ms\n", os_now_ms() - t0);
os_run_result_free(&r);
/* background jobs: one still holding the pipe is served (unless Yama
* hides the orphaned job's memory from the supervisor), one detached
* does not hang the run */
snprintf(p, sizeof(p), "%s/bg.txt", ws);
CHECK(run(ws, "(sleep 0.2; echo late > bg.txt) & echo started", &r) == 0);
FILE *yf = fopen("/proc/sys/kernel/yama/ptrace_scope", "r");
int yama = 0;
if (yf) {
if (fscanf(yf, "%d", &yama) != 1)
yama = 0;
fclose(yf);
}
CHECK(yama > 0 || exists(p));
os_run_result_free(&r);
t0 = os_now_ms();
CHECK(run(ws, "(sleep 0.3; echo late > bg2.txt) >/dev/null 2>&1 &", &r) ==
0);
CHECK(os_now_ms() - t0 < 3000);
os_run_result_free(&r);
/* the workspace root itself cannot be removed */
char cwd[OS_PATH_MAX], ws2[OS_PATH_MAX + 32];
CHECK(getcwd(cwd, sizeof(cwd)) != NULL);
snprintf(ws2, sizeof(ws2), "%s/tests/sbx_ws2_XXXXXX", cwd);
CHECK(mkdtemp(ws2));
CHECK(run(ws2, "rmdir \"$PWD\"", &r) != 0 && blocked(&r) && exists(ws2));
os_run_result_free(&r);
CHECK(rmdir(ws2) == 0);
}
static void test_confined(int kind) {
char cwd[OS_PATH_MAX], ws[OS_PATH_MAX + 32], out[OS_PATH_MAX + 32]; char cwd[OS_PATH_MAX], ws[OS_PATH_MAX + 32], out[OS_PATH_MAX + 32];
char cmd[3 * OS_PATH_MAX]; char cmd[3 * OS_PATH_MAX];
CHECK(getcwd(cwd, sizeof(cwd)) != NULL); CHECK(getcwd(cwd, sizeof(cwd)) != NULL);
@@ -170,12 +292,15 @@ static void test_landlock(void) {
CHECK(run(ws, cmd, &r) == 0); CHECK(run(ws, cmd, &r) == 0);
os_run_result_free(&r); os_run_result_free(&r);
if (kind == CST_SANDBOX_SECCOMP)
test_seccomp_paths(ws, out);
/* the parent itself stays unconfined */ /* the parent itself stays unconfined */
fp = fopen(outfile, "w"); fp = fopen(outfile, "w");
CHECK(fp); CHECK(fp);
fclose(fp); fclose(fp);
if (cst_sandbox_landlock_abi() >= 4) if (kind == CST_SANDBOX_SECCOMP || cst_sandbox_landlock_abi() >= 4)
test_network(ws); test_network(ws);
else else
printf("test_sandbox: landlock ABI %d < 4, network rule not tested\n", printf("test_sandbox: landlock ABI %d < 4, network rule not tested\n",
@@ -192,8 +317,26 @@ int main(void) {
int kind = cst_sandbox_probe(); int kind = cst_sandbox_probe();
printf("test_sandbox: sandbox %s\n", cst_sandbox_name(kind)); printf("test_sandbox: sandbox %s\n", cst_sandbox_name(kind));
#if !defined(_WIN32) #if !defined(_WIN32)
if (kind == CST_SANDBOX_LANDLOCK) if (kind == CST_SANDBOX_LANDLOCK || kind == CST_SANDBOX_SECCOMP)
test_landlock(); test_confined(kind);
if (kind != CST_SANDBOX_SECCOMP) {
const char *was = getenv("CST_SANDBOX");
char saved[32] = "";
if (was)
snprintf(saved, sizeof(saved), "%s", was);
setenv("CST_SANDBOX", "seccomp", 1);
if (cst_sandbox_reprobe() == CST_SANDBOX_SECCOMP) {
printf("test_sandbox: forced seccomp\n");
test_confined(CST_SANDBOX_SECCOMP);
} else {
printf("test_sandbox: seccomp not available here\n");
}
if (was)
setenv("CST_SANDBOX", saved, 1);
else
unsetenv("CST_SANDBOX");
CHECK(cst_sandbox_reprobe() == kind);
}
#endif #endif
puts("test_sandbox: ok"); puts("test_sandbox: ok");
return 0; return 0;