diff --git a/docs/seccomp.md b/docs/seccomp.md new file mode 100644 index 00000000..7cee248c --- /dev/null +++ b/docs/seccomp.md @@ -0,0 +1,77 @@ +# Host-side seccomp sandbox + +TinyKVM's guest-facing syscall emulation (`lib/tinykvm/linux/`) is the +first security layer: it decides what a guest may ask for, with full +payload awareness (paths, socket addresses, virtual fds). The seccomp +sandbox in `lib/tinykvm/linux/seccomp.hpp` is the second layer, behind +it: a kernel-enforced allowlist bounding what the **VMM process itself** +can execute. If a bug in a syscall handler is ever exploited, the +attacker lands inside a process that can no longer `execve`, `ptrace`, +`mount`, load kernel modules, or issue any syscall outside the small +set the emulation layer legitimately needs. + +The layers are complementary, not redundant: BPF cannot dereference +pointers, so seccomp can never check a path or a sockaddr — that depth +belongs to the emulation layer. Conversely, the emulation layer cannot +defend against its own compromise — that is what seccomp is for. + +## Two phases + +Seccomp filters stack and can only ever tighten; installation is +irrevocable. This maps onto the guest lifecycle: + +```cpp +#include + +// At VMM startup: wide allowlist. Guest initialization (ELF loading, +// dynamic linking, warm-fork prepare) needs a broad footprint, and the +// guest is not yet processing untrusted input. Still excludes the +// host-takeover set (execve, ptrace, mount, bpf, ...). +tinykvm::install_seccomp_filter(tinykvm::SeccompPhase::Init); + +// ... load and prepare the master VM ... + +// After prepare(), before serving the first request: strict allowlist. +// Only the KVM_RUN loop and what the syscall handlers actually call. +tinykvm::install_seccomp_filter(tinykvm::SeccompPhase::Runtime); +``` + +In a fork-per-VM architecture, install `Init` in the parent and +`Runtime` in each child after `fork()` — filters are inherited. + +## Scope warning: threads + +Filters apply to the **calling thread** by default, or to all threads +with `SeccompOptions::all_threads` (TSYNC). Both are permanent. A +thread-pool thread that installs a filter stays filtered after it +returns to the pool. Embedders that share threads between TinyKVM and +other work (e.g. a Varnish worker process) must dedicate threads to VM +execution or isolate VMs in their own process before using this. + +Embedder callbacks (`connect_socket_callback`, path callbacks, logging) +run on the filtered thread. If a callback needs syscalls outside the +built-in tables (e.g. DNS resolution), add them via +`SeccompOptions::extra_rules`. + +## Validating the allowlist (soak mode) + +The built-in tables are derived from reading the handlers and verified +against the unit test suite; treat them as a starting point. Before +enforcing in a new environment (new glibc, new allocator, new embedder), +soak with log-only mode: + +```cpp +tinykvm::install_seccomp_filter(tinykvm::SeccompPhase::Runtime, + { .log_only = true }); +``` + +Violations are logged to the kernel audit log instead of killing the +process. Run production-like workloads, then check `dmesg | grep SECCOMP` +(or auditd, `type=SECCOMP`); the `syscall=` field gives the number. +When the log stays quiet, switch to enforcing mode. Re-soak after +glibc or distro upgrades — glibc quietly adopts new syscalls +(`rseq`, `clone3`, `statx` historically), and this is the main way +enforcing filters break in production. + +`strace -f -c` over the test suite is the complementary tool: it shows +which allowed syscalls are never used and could be removed. diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index 4656c662..d702a82e 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -23,6 +23,7 @@ set (SOURCES tinykvm/memory_maps.cpp tinykvm/linux/fds.cpp + tinykvm/linux/seccomp.cpp ) if (TINYKVM_ARCH STREQUAL "AMD64") list(APPEND SOURCES diff --git a/lib/tinykvm/linux/seccomp.cpp b/lib/tinykvm/linux/seccomp.cpp new file mode 100644 index 00000000..59945a69 --- /dev/null +++ b/lib/tinykvm/linux/seccomp.cpp @@ -0,0 +1,331 @@ +#include "seccomp.hpp" + +#include "../common.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Fallbacks for older kernel headers */ +#ifndef SECCOMP_RET_KILL_PROCESS +#define SECCOMP_RET_KILL_PROCESS 0x80000000U +#endif +#ifndef SECCOMP_RET_LOG +#define SECCOMP_RET_LOG 0x7ffc0000U +#endif +#ifndef SECCOMP_FILTER_FLAG_TSYNC +#define SECCOMP_FILTER_FLAG_TSYNC (1UL << 0) +#endif +#ifndef SYS_seccomp +#if defined(__x86_64__) +#define SYS_seccomp 317 +#elif defined(__aarch64__) +#define SYS_seccomp 277 +#else +#error "No SYS_seccomp fallback for this architecture" +#endif +#endif + +#if defined(__x86_64__) +#define SECCOMP_AUDIT_ARCH AUDIT_ARCH_X86_64 +#elif defined(__aarch64__) +#define SECCOMP_AUDIT_ARCH AUDIT_ARCH_AARCH64 +#else +#error "Unsupported seccomp architecture" +#endif + +namespace tinykvm { + +/* All KVM ioctls have type byte 0xAE (bits 8..15 of the request). */ +static constexpr uint64_t IOCTL_TYPE_MASK = 0xFF00; +static constexpr uint64_t IOCTL_TYPE_KVM = 0xAE00; + +static std::vector runtime_rules() +{ + using R = SeccompRule; + std::vector rules { + /* --- KVM_RUN loop --- */ + /* Any KVM ioctl (type 0xAE), on any fd */ + R{SYS_ioctl, {1, IOCTL_TYPE_MASK, IOCTL_TYPE_KVM}}, + /* ioctls passed through for guest fds (system_calls.cpp) */ + R{SYS_ioctl, {1, ~0ULL, FIONBIO}}, + R{SYS_ioctl, {1, ~0ULL, FIONREAD}}, + + /* --- I/O on translated fds --- */ + R{SYS_read}, R{SYS_write}, R{SYS_readv}, R{SYS_writev}, + R{SYS_pread64}, R{SYS_pwrite64}, R{SYS_preadv}, R{SYS_pwritev}, + R{SYS_lseek}, R{SYS_close}, R{SYS_fstat}, R{SYS_newfstatat}, + R{SYS_statx}, R{SYS_fcntl}, R{SYS_dup}, R{SYS_dup3}, + R{SYS_getdents64}, + /* Path-based, mediated by is_readable_path/is_writable_path. + * BPF cannot see the path; depth belongs to the emulation layer. */ + R{SYS_openat}, R{SYS_readlinkat}, R{SYS_faccessat}, +#ifdef SYS_openat2 + /* The openat handler prefers openat2 with RESOLVE_* hardening */ + R{SYS_openat2}, +#endif +#ifdef SYS_faccessat2 + R{SYS_faccessat2}, +#endif + + /* --- Guest + VMM memory management --- */ + R{SYS_mmap}, R{SYS_munmap}, R{SYS_mprotect}, R{SYS_madvise}, + R{SYS_mremap}, R{SYS_brk}, R{SYS_membarrier}, + + /* --- Networking (sockaddrs validated by the emulation layer) --- */ + R{SYS_socket}, R{SYS_socketpair}, R{SYS_connect}, R{SYS_bind}, + R{SYS_listen}, R{SYS_accept4}, R{SYS_getsockname}, R{SYS_getpeername}, + R{SYS_setsockopt}, R{SYS_getsockopt}, R{SYS_sendto}, R{SYS_recvfrom}, + R{SYS_sendmsg}, R{SYS_recvmsg}, R{SYS_sendmmsg}, R{SYS_recvmmsg}, + R{SYS_shutdown}, R{SYS_pipe2}, R{SYS_eventfd2}, + + /* --- Event loops --- */ + R{SYS_epoll_create1}, R{SYS_epoll_ctl}, R{SYS_epoll_pwait}, + R{SYS_ppoll}, +#ifdef SYS_epoll_wait + R{SYS_epoll_wait}, +#endif +#ifdef SYS_poll + R{SYS_poll}, +#endif + + /* --- Time and execution timeouts (vcpu_run.cpp) --- */ + R{SYS_clock_gettime}, R{SYS_clock_getres}, R{SYS_clock_nanosleep}, + R{SYS_timer_create}, R{SYS_timer_settime}, R{SYS_timer_gettime}, + R{SYS_timer_delete}, + R{SYS_timerfd_create}, R{SYS_timerfd_settime}, R{SYS_timerfd_gettime}, +#ifdef SYS_nanosleep + R{SYS_nanosleep}, +#endif +#ifdef SYS_gettimeofday + R{SYS_gettimeofday}, +#endif + + /* --- Signals (timeout delivery, guest signal emulation) --- */ + R{SYS_rt_sigaction}, R{SYS_rt_sigprocmask}, R{SYS_rt_sigreturn}, + R{SYS_sigaltstack}, R{SYS_tgkill}, + + /* --- Threads and synchronization --- */ + /* New threads only: clone must carry CLONE_VM|CLONE_THREAD. + * Process-creating clone/fork belongs to the init phase. */ + R{SYS_clone, {0, CLONE_VM | CLONE_THREAD, CLONE_VM | CLONE_THREAD}}, + /* clone3 passes flags in a struct BPF can't read: deny with + * ENOSYS so glibc falls back to the filterable clone. */ +#ifdef SYS_clone3 + R::Errnum(SYS_clone3, ENOSYS), +#endif + R{SYS_futex}, R{SYS_set_robust_list}, +#ifdef SYS_rseq + R{SYS_rseq}, +#endif + R{SYS_sched_yield}, R{SYS_sched_getaffinity}, + R{SYS_gettid}, R{SYS_getpid}, + R{SYS_prctl, {0, ~0ULL, PR_SET_NAME}}, + R{SYS_prctl, {0, ~0ULL, PR_GET_NAME}}, + + /* --- Process lifetime and misc --- */ + R{SYS_exit}, R{SYS_exit_group}, R{SYS_restart_syscall}, + R{SYS_getrandom}, + }; + return rules; +} + +static std::vector init_rules() +{ + using R = SeccompRule; + auto rules = runtime_rules(); + const std::vector extra { + /* Unrestricted clone: cross-process warm forks, GDB fork() in + * machine_debug.cpp, and pre-thread setup. */ + R{SYS_clone}, R{SYS_wait4}, R{SYS_kill}, +#ifdef SYS_fork + R{SYS_fork}, +#endif + /* Process/runtime setup done by glibc and the ELF loader */ +#ifdef SYS_arch_prctl + R{SYS_arch_prctl}, +#endif + R{SYS_set_tid_address}, R{SYS_prlimit64}, + R{SYS_prctl}, R{SYS_getrlimit}, + /* Installing the Runtime filter from under the Init filter uses + * the seccomp() syscall. Filters only stack and tighten, so + * allowing this cannot loosen the sandbox. */ + R{SYS_seccomp, {0, ~0ULL, SECCOMP_SET_MODE_FILTER}}, + R{SYS_getuid}, R{SYS_geteuid}, R{SYS_getgid}, R{SYS_getegid}, + R{SYS_uname}, R{SYS_sysinfo}, R{SYS_sched_setaffinity}, + /* Wider filesystem access for loading guests and libraries */ + R{SYS_statfs}, R{SYS_fstatfs}, R{SYS_getcwd}, R{SYS_chdir}, + R{SYS_fchdir}, R{SYS_mkdirat}, R{SYS_unlinkat}, R{SYS_renameat}, + R{SYS_renameat2}, R{SYS_symlinkat}, R{SYS_linkat}, + R{SYS_ftruncate}, R{SYS_fallocate}, R{SYS_copy_file_range}, + R{SYS_sendfile}, R{SYS_flock}, R{SYS_umask}, R{SYS_memfd_create}, + R{SYS_msync}, R{SYS_mincore}, R{SYS_mlock}, R{SYS_munlock}, + /* unsafe_syscalls mode (setup_linux_system_calls) */ + R{SYS_inotify_init1}, R{SYS_inotify_add_watch}, R{SYS_inotify_rm_watch}, + /* Unrestricted ioctl during setup (terminal probing etc.) */ + R{SYS_ioctl}, +#ifdef SYS_open + R{SYS_open}, R{SYS_stat}, R{SYS_lstat}, R{SYS_access}, + R{SYS_readlink}, R{SYS_pipe}, R{SYS_dup2}, R{SYS_select}, + R{SYS_mkdir}, R{SYS_unlink}, R{SYS_rename}, R{SYS_symlink}, +#endif + R{SYS_pselect6}, + /* NOTE: execve/execveat, ptrace, process_vm_{readv,writev}, + * mount, pivot_root, bpf, kexec_load, init_module, setuid, + * userfaultfd, io_uring_* are deliberately absent even here: + * no legitimate guest init needs them from the VMM process. */ + }; + rules.insert(rules.end(), extra.begin(), extra.end()); + return rules; +} + +std::vector seccomp_rules_for(SeccompPhase phase) +{ + return (phase == SeccompPhase::Init) ? init_rules() : runtime_rules(); +} + +/* --- BPF program emission --- */ + +static constexpr uint32_t SECCOMP_DATA_NR = offsetof(struct seccomp_data, nr); +static constexpr uint32_t SECCOMP_DATA_ARCH = offsetof(struct seccomp_data, arch); +static uint32_t seccomp_data_arg_lo(unsigned idx) { + return offsetof(struct seccomp_data, args) + idx * sizeof(uint64_t); +} +static uint32_t seccomp_data_arg_hi(unsigned idx) { + return seccomp_data_arg_lo(idx) + sizeof(uint32_t); +} + +static uint32_t rule_return_value(const SeccompRule& rule) +{ + switch (rule.action) { + case SeccompRule::Action::Errno: + return SECCOMP_RET_ERRNO | (rule.errnum & SECCOMP_RET_DATA); + case SeccompRule::Action::Allow: + default: + return SECCOMP_RET_ALLOW; + } +} + +/* One rule becomes a self-contained block: + * LD nr + * JEQ rule.nr ? fall through : skip to next block + * per arg-half check: LD half; [AND mask]; JEQ value ? next : next block + * RET action + * Failed checks fall to the next block, so several rules for the same + * syscall number OR together. */ +static void emit_rule_block(std::vector& prog, + const SeccompRule& rule) +{ + /* num_args and Arg::index are public and settable via extra_rules: + * validate before they index args[] and seccomp_data.args. */ + constexpr unsigned MAX_ARGS = sizeof(rule.args) / sizeof(rule.args[0]); + if (rule.num_args > MAX_ARGS) + throw MachineException("seccomp: rule has too many argument " + "constraints", rule.nr); + + struct Check { uint32_t off; uint32_t mask; uint32_t value; }; + Check checks[2 * MAX_ARGS]; + unsigned num_checks = 0; + for (unsigned i = 0; i < rule.num_args; i++) { + const auto& arg = rule.args[i]; + if (arg.index > 5) + throw MachineException("seccomp: rule argument index out of " + "range", rule.nr); + const uint32_t mask_lo = arg.mask, mask_hi = arg.mask >> 32; + const uint32_t val_lo = arg.value, val_hi = arg.value >> 32; + if (mask_lo != 0) + checks[num_checks++] = {seccomp_data_arg_lo(arg.index), mask_lo, val_lo}; + if (mask_hi != 0) + checks[num_checks++] = {seccomp_data_arg_hi(arg.index), mask_hi, val_hi}; + } + + /* Compute block length to encode "jump past this block" offsets */ + unsigned block_len = 2 + 1; /* LD nr + JEQ nr + RET */ + for (unsigned i = 0; i < num_checks; i++) + block_len += (checks[i].mask == 0xFFFFFFFFu) ? 2 : 3; + + const size_t base = prog.size(); + auto to_next_block = [&](void) -> uint8_t { + /* jf is relative to the instruction after the jump; the jump + * was already pushed, so target(block_len) - emitted-so-far. */ + const unsigned emitted = prog.size() - base; + return uint8_t(block_len - emitted); + }; + + prog.push_back(BPF_STMT(BPF_LD | BPF_W | BPF_ABS, SECCOMP_DATA_NR)); + prog.push_back(BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, rule.nr, 0, 0)); + prog.back().jf = to_next_block(); + for (unsigned i = 0; i < num_checks; i++) { + prog.push_back(BPF_STMT(BPF_LD | BPF_W | BPF_ABS, checks[i].off)); + if (checks[i].mask != 0xFFFFFFFFu) + prog.push_back(BPF_STMT(BPF_ALU | BPF_AND | BPF_K, checks[i].mask)); + prog.push_back(BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, checks[i].value, 0, 0)); + prog.back().jf = to_next_block(); + } + prog.push_back(BPF_STMT(BPF_RET | BPF_K, rule_return_value(rule))); +} + +static std::vector +build_program(const std::vector& rules, uint32_t default_action) +{ + std::vector prog; + prog.reserve(rules.size() * 4 + 8); + + /* Wrong architecture: always kill, even in log mode */ + prog.push_back(BPF_STMT(BPF_LD | BPF_W | BPF_ABS, SECCOMP_DATA_ARCH)); + prog.push_back(BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SECCOMP_AUDIT_ARCH, 1, 0)); + prog.push_back(BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS)); +#if defined(__x86_64__) + /* Reject x32 ABI syscalls (nr >= 0x40000000): same numbers, different ABI */ + prog.push_back(BPF_STMT(BPF_LD | BPF_W | BPF_ABS, SECCOMP_DATA_NR)); + prog.push_back(BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0x40000000u, 0, 1)); + prog.push_back(BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS)); +#endif + + for (const auto& rule : rules) + emit_rule_block(prog, rule); + + prog.push_back(BPF_STMT(BPF_RET | BPF_K, default_action)); + if (prog.size() > BPF_MAXINSNS) + throw MachineException("seccomp: filter exceeds BPF_MAXINSNS", prog.size()); + return prog; +} + +void install_seccomp_filter(SeccompPhase phase, const SeccompOptions& options) +{ + auto rules = seccomp_rules_for(phase); + rules.insert(rules.end(), + options.extra_rules.begin(), options.extra_rules.end()); + + const uint32_t default_action = + options.log_only ? SECCOMP_RET_LOG : SECCOMP_RET_KILL_PROCESS; + auto prog = build_program(rules, default_action); + + struct sock_fprog fprog {}; + fprog.len = uint16_t(prog.size()); + fprog.filter = prog.data(); + + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) + throw MachineException("seccomp: PR_SET_NO_NEW_PRIVS failed", errno); + + const unsigned flags = options.all_threads ? SECCOMP_FILTER_FLAG_TSYNC : 0; + const long res = syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, flags, &fprog); + if (res != 0) { + /* With TSYNC, a positive result is the TID of a thread whose + * existing filter conflicts with ours. */ + if (res > 0) + throw MachineException("seccomp: TSYNC failed, thread has a " + "conflicting filter (result is its TID)", res); + throw MachineException("seccomp: SECCOMP_SET_MODE_FILTER failed", errno); + } +} + +} // tinykvm diff --git a/lib/tinykvm/linux/seccomp.hpp b/lib/tinykvm/linux/seccomp.hpp new file mode 100644 index 00000000..bb3a73f4 --- /dev/null +++ b/lib/tinykvm/linux/seccomp.hpp @@ -0,0 +1,93 @@ +#pragma once +/** + * Host-side seccomp-BPF sandbox for the TinyKVM VMM process. + * + * This is a second, kernel-enforced layer *behind* the guest-facing + * syscall emulation in system_calls.cpp. The emulation layer decides + * what the guest may ask for; this layer bounds what the VMM process + * itself can execute if a handler is ever compromised. + * + * Two-phase model: + * - Phase::Init: wide allowlist for guest/master-VM initialization + * (ELF loading, dynamic linking, warm-fork prepare). Still excludes + * the host-takeover set (execve, ptrace, mount, bpf, ...). + * - Phase::Runtime: strict allowlist for serving requests. Installed + * on top of the init filter: seccomp filters stack and can only + * ever tighten - there is no way to loosen or remove one. + * + * IMPORTANT: filters are irrevocable and per-thread (or per-process + * with all_threads=true). A thread-pool thread that installs a filter + * stays filtered after it returns to the pool. Embedders that share + * threads between TinyKVM and other work (e.g. a Varnish worker + * process) must either dedicate threads to VM execution or run VMs in + * their own process before enabling all_threads. + */ +#include +#include + +namespace tinykvm { + +enum class SeccompPhase { + Init, /* Wide: guest initialization, warm-fork prepare */ + Runtime, /* Strict: request serving after prepare() */ +}; + +struct SeccompRule { + enum class Action : uint8_t { + Allow, /* Execute the system call */ + Errno, /* Fail the system call with `errnum`, do not execute */ + }; + /* Masked-equal argument constraint: (syscall_arg[index] & mask) == value. + * Constraints on the same rule are AND-ed. Multiple rules for the same + * syscall number are OR-ed (first match wins). BPF cannot dereference + * pointers, so only raw register values can be checked here. */ + struct Arg { + uint8_t index; /* 0..5 */ + uint64_t mask; + uint64_t value; + }; + + uint32_t nr; + Action action = Action::Allow; + uint16_t errnum = 0; + uint8_t num_args = 0; + Arg args[2] = {}; + + constexpr SeccompRule(uint32_t nr_) : nr(nr_) {} + constexpr SeccompRule(uint32_t nr_, Arg a0) + : nr(nr_), num_args(1), args{a0, {}} {} + constexpr SeccompRule(uint32_t nr_, Arg a0, Arg a1) + : nr(nr_), num_args(2), args{a0, a1} {} + static constexpr SeccompRule Errnum(uint32_t nr_, uint16_t err) { + SeccompRule r{nr_}; + r.action = Action::Errno; + r.errnum = err; + return r; + } +}; + +struct SeccompOptions { + /* Log-and-allow instead of kill on violations (SECCOMP_RET_LOG). + * Use this to soak-test an allowlist: violations show up in the + * kernel audit log (dmesg / auditd, type=SECCOMP) but execution + * continues. Flip to false once the log stays quiet. */ + bool log_only = false; + /* Apply to all threads of the process atomically (TSYNC) instead + * of only the calling thread. See the header comment before using + * this in an embedded (shared-process) context. */ + bool all_threads = false; + /* Extra embedder-specific rules appended to the phase table. */ + std::vector extra_rules {}; +}; + +/* Install the seccomp filter for the given phase on the calling thread + * (or process, with all_threads). Sets PR_SET_NO_NEW_PRIVS. Irrevocable. + * Throws MachineException on failure. Calling with Phase::Runtime after + * Phase::Init stacks the filters, which is the intended usage. */ +void install_seccomp_filter(SeccompPhase phase, const SeccompOptions& options = {}); + +/* The built-in allowlist for a phase, before extra_rules. Exposed so + * embedders can inspect or derive their own tables. */ +std::vector seccomp_rules_for(SeccompPhase phase); + +} // tinykvm diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index e28773ab..8dfe002d 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -29,10 +29,12 @@ if (TINYKVM_ARCH STREQUAL "AMD64") add_unit_test(reset reset.cpp) add_unit_test(timeout timeout.cpp) add_unit_test(tegridy tegridy.cpp) + add_unit_test(seccomp seccomp.cpp) elseif (TINYKVM_ARCH STREQUAL "ARM64") add_unit_test(arm64_minimal arm64_minimal.cpp) add_unit_test(arm64_elf arm64_elf.cpp) add_unit_test(arm64_fork arm64_fork.cpp) add_unit_test(arm64_threads arm64_threads.cpp) add_unit_test(arm64_signals arm64_signals.cpp) + add_unit_test(seccomp seccomp.cpp) endif() diff --git a/tests/unit/seccomp.cpp b/tests/unit/seccomp.cpp new file mode 100644 index 00000000..656b1390 --- /dev/null +++ b/tests/unit/seccomp.cpp @@ -0,0 +1,158 @@ +#include + +#include +#include +#include +#include +#include +#include +extern std::vector build_and_load(const std::string& code); +static const uint64_t MAX_MEMORY = 8ul << 20; /* 8MB */ +static const std::vector env { + "LC_TYPE=C", "LC_ALL=C", "USER=root" +}; + +/* Seccomp filters are irrevocable for the installing thread, so every + * test installs the filter in a forked child and inspects its fate. */ +template +static int run_in_child(Fn&& fn) +{ + fflush(nullptr); + const pid_t pid = fork(); + REQUIRE(pid != -1); + if (pid == 0) { + fn(); /* must _exit() itself; falling through is a failure */ + _exit(111); + } + int status = -1; + REQUIRE(waitpid(pid, &status, 0) == pid); + return status; +} + +TEST_CASE("Guest runs under the strict runtime filter", "[Seccomp]") +{ + tinykvm::Machine::init(); + /* Compile in the parent: the compiler needs execve, which no + * phase of the sandbox allows. */ + const auto binary = build_and_load(R"M( +#include +int main() { + printf("hello sandboxed world\n"); + return 666; +})M"); + + const int status = run_in_child([&] { + tinykvm::install_seccomp_filter(tinykvm::SeccompPhase::Runtime); + /* Machine construction, KVM ioctls, timers, guest syscalls - + * everything from here on runs under the strict filter. */ + tinykvm::Machine machine { binary, { .max_mem = MAX_MEMORY } }; + machine.setup_linux({"seccomp"}, env); + machine.run(4.0f); + _exit(machine.return_value() == 666 ? 0 : 1); + }); + REQUIRE(WIFEXITED(status)); + REQUIRE(WEXITSTATUS(status) == 0); +} + +TEST_CASE("Runtime filter kills on a banned syscall", "[Seccomp]") +{ + const int status = run_in_child([&] { + tinykvm::install_seccomp_filter(tinykvm::SeccompPhase::Runtime); + syscall(SYS_chroot, "/"); /* not in any allowlist */ + _exit(0); /* not reached */ + }); + REQUIRE(WIFSIGNALED(status)); + REQUIRE(WTERMSIG(status) == SIGSYS); +} + +TEST_CASE("Log-only mode lets banned syscalls through", "[Seccomp]") +{ + const int status = run_in_child([&] { + tinykvm::install_seccomp_filter(tinykvm::SeccompPhase::Runtime, + { .log_only = true }); + syscall(SYS_chroot, "/"); /* logged (audit), fails with EPERM */ + _exit(0); + }); + REQUIRE(WIFEXITED(status)); + REQUIRE(WEXITSTATUS(status) == 0); +} + +TEST_CASE("Init then runtime filters stack", "[Seccomp]") +{ + /* The child reports over a pipe that it survived installing the + * Runtime filter, so the SIGSYS below provably comes from uname + * and not from the seccomp() call being blocked by the Init filter. */ + int fds[2]; + REQUIRE(pipe(fds) == 0); + const int status = run_in_child([&] { + tinykvm::install_seccomp_filter(tinykvm::SeccompPhase::Init); + /* uname is init-only; allowed now... */ + struct utsname { char data[65 * 6]; } uts; + if (syscall(SYS_uname, &uts) != 0) + _exit(1); + tinykvm::install_seccomp_filter(tinykvm::SeccompPhase::Runtime); + char ok = '!'; + if (write(fds[1], &ok, 1) != 1) + _exit(3); + /* ...and fatal after tightening. */ + syscall(SYS_uname, &uts); + _exit(2); /* not reached */ + }); + close(fds[1]); + char ok = 0; + REQUIRE(read(fds[0], &ok, 1) == 1); + close(fds[0]); + REQUIRE(WIFSIGNALED(status)); + REQUIRE(WTERMSIG(status) == SIGSYS); +} + +TEST_CASE("Arg filtering: non-KVM ioctls are blocked", "[Seccomp]") +{ + const int status = run_in_child([&] { + tinykvm::install_seccomp_filter(tinykvm::SeccompPhase::Runtime); + /* TCGETS on stdout: syscall number is allowed, request is not */ + struct { char data[64]; } tio; + ioctl(1, 0x5401 /* TCGETS */, &tio); + _exit(0); /* not reached */ + }); + REQUIRE(WIFSIGNALED(status)); + REQUIRE(WTERMSIG(status) == SIGSYS); +} + +TEST_CASE("Malformed extra rules are rejected", "[Seccomp]") +{ + /* Run in a child anyway: if validation regresses, the filter (or + * NO_NEW_PRIVS) must not stick to the test runner's thread. */ + const int status = run_in_child([&] { + tinykvm::SeccompOptions opts; + tinykvm::SeccompRule bad_index{SYS_uname, {6 /* > 5 */, ~0ULL, 0}}; + opts.extra_rules.push_back(bad_index); + try { + tinykvm::install_seccomp_filter(tinykvm::SeccompPhase::Runtime, opts); + _exit(1); + } catch (const std::exception&) {} + tinykvm::SeccompRule bad_count{SYS_uname}; + bad_count.num_args = 3; /* > sizeof(args) */ + opts.extra_rules = {bad_count}; + try { + tinykvm::install_seccomp_filter(tinykvm::SeccompPhase::Runtime, opts); + _exit(2); + } catch (const std::exception&) {} + _exit(0); + }); + REQUIRE(WIFEXITED(status)); + REQUIRE(WEXITSTATUS(status) == 0); +} + +TEST_CASE("Extra rules extend the allowlist", "[Seccomp]") +{ + const int status = run_in_child([&] { + tinykvm::SeccompOptions opts; + opts.extra_rules.push_back(tinykvm::SeccompRule{SYS_uname}); + tinykvm::install_seccomp_filter(tinykvm::SeccompPhase::Runtime, opts); + struct utsname { char data[65 * 6]; } uts; + _exit(syscall(SYS_uname, &uts) == 0 ? 0 : 1); + }); + REQUIRE(WIFEXITED(status)); + REQUIRE(WEXITSTATUS(status) == 0); +}