diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d111e..7153e8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.0] - 2026-03-15 + +### Added +- **WP1c: Block /dev/mem and /dev/kmem via eBPF LSM** (`ebpf/owlbear_lsm.bpf.c`): extends `file_open` hook to unconditionally block `/dev/mem` and `/dev/kmem` opens with -EPERM. Physical memory access bypasses all process-level protections, so no PID allowlist check is performed. Emits `OWL_EVENT_DEV_MEM_ACCESS` (severity CRITICAL, target_pid=0). Detail string distinguishes `/dev/mem` vs `/dev/kmem`. +- `OWL_EVENT_DEV_MEM_ACCESS` (0x0106) in shared event header and BPF common header (breaking: new event type) +- `cheats/dev_mem_reader.c`: attack program that attempts to open `/dev/mem` and `/dev/kmem` and read 256 bytes of raw physical memory +- `tests/test_bpf_loader.c`: conversion test for `OWL_EVENT_DEV_MEM_ACCESS` (102 total tests) +- `scripts/verify.sh`: baseline and protected phase assertions for dev_mem_reader, handles CONFIG_STRICT_DEVMEM (EACCES) gracefully + +### Changed +- `daemon/bpf_event_convert.c`: `OWL_EVENT_DEV_MEM_ACCESS` falls through to memory payload conversion +- `daemon/main.c`: `event_type_str()` and `print_event()` handle `DEV_MEM_ACCESS` +- `cheats/Makefile`: builds `dev_mem_reader.bin` +- `scripts/verify.sh`: version bumped to v2.0.0 +- `README.md`: updated status (v2.0.0, 102 tests, 9 attack programs), added /dev/mem blocking + ## [1.2.0] - 2026-03-15 ### Added diff --git a/README.md b/README.md index a3777a6..a679d76 100644 --- a/README.md +++ b/README.md @@ -9,16 +9,16 @@ Not production software. Runs on Graviton3 (c7g.large), Ubuntu 24.04, kernel 6.17. - **kernel/** - loadable module. Kprobes on ptrace, /proc/pid/mem, process_vm_readv/writev, mmap, module load/unload. ARM64 system register monitoring. Chardev for event delivery. -- **ebpf/** - BPF LSM hooks returning -EPERM (ptrace_access_check, file_open, file_mprotect). Tracepoints. Kprobe on do_init_module. Ring buffer to userspace. +- **ebpf/** - BPF LSM hooks returning -EPERM (ptrace_access_check, file_open for /proc/pid/mem + /dev/mem + /dev/kmem, file_mprotect). Tracepoints. Kprobe on do_init_module. Ring buffer to userspace. - **daemon/** - epoll on chardev + BPF ring buffer. Policy engine. Signature scanner. CRC32 code integrity. Self-protection watchdog. TracerPid debugger detection. - **game/** - ncurses test target. Mutable state, function pointers, exported address. -- **cheats/** - 8 attack programs: process_vm_readv, /proc/pid/mem, ptrace read, ptrace write, process_vm_writev, LD_PRELOAD, mprotect injection, debug registers. +- **cheats/** - 9 attack programs: process_vm_readv, /proc/pid/mem, /dev/mem, ptrace read, ptrace write, process_vm_writev, LD_PRELOAD, mprotect injection, debug registers. - **platform/** - Lambda + API Gateway + DynamoDB telemetry receiver. - **scripts/verify.sh** - E2E test. Baseline (cheats succeed) vs protected (cheats blocked). Machine-generated results. ## Status -v1.2.0. 101 unit tests, 11 suites. 30/31 E2E pass on Graviton3. eBPF LSM returns EPERM on ptrace, /proc/pid/mem, process_vm_writev. Module can't be unloaded while daemon runs. TracerPid polling detects debuggers attached before daemon start. +v2.0.0. 102 unit tests, 11 suites. eBPF LSM returns EPERM on ptrace, /proc/pid/mem, /dev/mem, /dev/kmem, process_vm_writev. Module can't be unloaded while daemon runs. TracerPid polling detects debuggers attached before daemon start. Prototype limitations: linear signature scan, CRC32 not cryptographic, no fleet management. diff --git a/cheats/Makefile b/cheats/Makefile index 2ecb69d..5224fb8 100644 --- a/cheats/Makefile +++ b/cheats/Makefile @@ -19,7 +19,8 @@ CHEAT_SRCS := mem_reader.c \ mprotect_injector.c \ mprotect_inject_via_ptrace.c \ ld_preload_hook.c \ - debug_reg_setter.c + debug_reg_setter.c \ + dev_mem_reader.c CHEAT_BINS := $(CHEAT_SRCS:.c=.bin) CHEAT_DEPS := $(CHEAT_SRCS:.c=.d) diff --git a/cheats/dev_mem_reader.c b/cheats/dev_mem_reader.c new file mode 100644 index 0000000..576b8cf --- /dev/null +++ b/cheats/dev_mem_reader.c @@ -0,0 +1,89 @@ +/* + * dev_mem_reader - Test cheat using /dev/mem + * + * Attempts to open /dev/mem and read the first 256 bytes of physical + * memory. This bypasses all process-level protections (ptrace hooks, + * /proc/pid/mem blocks, process_vm_* interception) because it accesses + * raw physical memory directly. + * + * Should be blocked by owlbear's eBPF LSM file_open hook. + * + * Also attempts /dev/kmem if /dev/mem is blocked, to verify both + * paths are covered. + * + * Usage: dev_mem_reader + */ + +#include +#include +#include +#include +#include +#include + +static int try_dev_path(const char *path) +{ + int fd; + unsigned char buf[256]; + ssize_t n; + + printf("[dev_mem_reader] Opening %s...\n", path); + + fd = open(path, O_RDONLY); + if (fd < 0) { + int err = errno; + fprintf(stderr, "[dev_mem_reader] open(%s) failed: %s (errno=%d)\n", + path, strerror(err), err); + return -err; + } + + n = read(fd, buf, sizeof(buf)); + if (n < 0) { + int err = errno; + fprintf(stderr, "[dev_mem_reader] read(%s) failed: %s (errno=%d)\n", + path, strerror(err), err); + close(fd); + return -err; + } + + printf("[CHEAT] Read %zd bytes from %s\n", n, path); + close(fd); + return 0; +} + +int main(void) +{ + int rc; + + printf("[dev_mem_reader] Attempting raw physical memory access\n"); + printf("[dev_mem_reader] This should be blocked by eBPF LSM\n\n"); + + rc = try_dev_path("/dev/mem"); + if (rc == 0) + return EXIT_SUCCESS; + + if (rc == -EPERM) { + fprintf(stderr, "[dev_mem_reader] Blocked by anti-cheat (EPERM)\n"); + } else if (rc == -EACCES) { + fprintf(stderr, "[dev_mem_reader] Blocked by CONFIG_STRICT_DEVMEM (EACCES)\n"); + } else if (rc == -ENOENT) { + fprintf(stderr, "[dev_mem_reader] /dev/mem does not exist\n"); + } + + /* Also try /dev/kmem */ + printf("\n"); + rc = try_dev_path("/dev/kmem"); + if (rc == 0) + return EXIT_SUCCESS; + + if (rc == -EPERM) + fprintf(stderr, "[dev_mem_reader] /dev/kmem also blocked (EPERM)\n"); + else if (rc == -EACCES) + fprintf(stderr, "[dev_mem_reader] /dev/kmem blocked by kernel config (EACCES)\n"); + else if (rc == -ENOENT) + fprintf(stderr, "[dev_mem_reader] /dev/kmem does not exist\n"); + + /* Exit 0 if at least one path was accessible, 1 otherwise. + * The E2E script checks stderr for EPERM vs EACCES. */ + return EXIT_FAILURE; +} diff --git a/daemon/bpf_event_convert.c b/daemon/bpf_event_convert.c index d79250f..1fb17b6 100644 --- a/daemon/bpf_event_convert.c +++ b/daemon/bpf_event_convert.c @@ -51,6 +51,7 @@ int owl_bpf_event_convert(const void *bpf_data, size_t bpf_size, case OWL_EVENT_VM_READV_ATTEMPT: case OWL_EVENT_VM_WRITEV_ATTEMPT: case OWL_EVENT_MPROTECT_EXEC: + case OWL_EVENT_DEV_MEM_ACCESS: out->payload.memory.caller_pid = bev->pid; memcpy(out->payload.memory.caller_comm, bev->comm, sizeof(out->payload.memory.caller_comm) < sizeof(bev->comm) diff --git a/daemon/main.c b/daemon/main.c index 904a1de..f6802df 100644 --- a/daemon/main.c +++ b/daemon/main.c @@ -99,6 +99,7 @@ static const char *event_type_str(uint32_t type) case OWL_EVENT_VM_WRITEV_ATTEMPT: return "VM_WRITEV_ATTEMPT"; case OWL_EVENT_EXEC_MMAP: return "EXEC_MMAP"; case OWL_EVENT_MPROTECT_EXEC: return "MPROTECT_EXEC"; + case OWL_EVENT_DEV_MEM_ACCESS: return "DEV_MEM_ACCESS"; case OWL_EVENT_MODULE_LOAD: return "MODULE_LOAD"; case OWL_EVENT_MODULE_UNKNOWN: return "MODULE_UNKNOWN"; case OWL_EVENT_CODE_INTEGRITY_FAIL: return "CODE_INTEGRITY_FAIL"; @@ -181,6 +182,7 @@ static void print_event(const struct owlbear_event *ev, FILE *out) case OWL_EVENT_VM_READV_ATTEMPT: case OWL_EVENT_VM_WRITEV_ATTEMPT: case OWL_EVENT_MPROTECT_EXEC: + case OWL_EVENT_DEV_MEM_ACCESS: fprintf(out, " caller_pid=%u caller=%s", ev->payload.memory.caller_pid, ev->payload.memory.caller_comm); diff --git a/ebpf/owlbear_common.bpf.h b/ebpf/owlbear_common.bpf.h index f1c389c..05fbe7f 100644 --- a/ebpf/owlbear_common.bpf.h +++ b/ebpf/owlbear_common.bpf.h @@ -32,6 +32,7 @@ #define OWL_EVENT_VM_WRITEV_ATTEMPT 0x0103 #define OWL_EVENT_EXEC_MMAP 0x0104 #define OWL_EVENT_MPROTECT_EXEC 0x0105 +#define OWL_EVENT_DEV_MEM_ACCESS 0x0106 #define OWL_EVENT_MODULE_LOAD 0x0200 #define OWL_SEV_INFO 0 diff --git a/ebpf/owlbear_lsm.bpf.c b/ebpf/owlbear_lsm.bpf.c index a4790a9..aaa6beb 100644 --- a/ebpf/owlbear_lsm.bpf.c +++ b/ebpf/owlbear_lsm.bpf.c @@ -8,7 +8,7 @@ * * Hooks: * lsm/ptrace_access_check — block ptrace on protected PID - * lsm/file_open — block /proc//mem access + * lsm/file_open — block /proc//mem, /dev/mem, /dev/kmem * lsm/mmap_file — monitor executable mmap in game * * Requires: CONFIG_BPF_LSM=y in kernel config. @@ -44,11 +44,12 @@ int BPF_PROG(owl_ptrace_check, struct task_struct *child, unsigned int mode) /* ------------------------------------------------------------------------- * LSM: file_open * - * Called when a file is opened. We check if the file is /proc//mem - * for a protected PID. If so, deny access from non-whitelisted callers. + * Called when a file is opened. Blocks: + * /dev/mem, /dev/kmem — unconditional (physical memory = system-wide threat) + * /proc//mem — if PID is protected and caller not whitelisted * * Path checking in BPF is limited — we read the dentry name and check - * if it equals "mem", then verify the parent directory is a protected PID. + * parent/grandparent to distinguish /dev/mem from /proc//mem. * ----------------------------------------------------------------------- */ SEC("lsm/file_open") @@ -67,15 +68,20 @@ int BPF_PROG(owl_file_open, struct file *file) if (!dentry) return 0; - /* Check if filename is "mem" */ + /* Check if filename is "mem" or "kmem" */ dname = BPF_CORE_READ(dentry, d_name); bpf_probe_read_kernel_str(name_buf, sizeof(name_buf), dname.name); - if (name_buf[0] != 'm' || name_buf[1] != 'e' || - name_buf[2] != 'm' || name_buf[3] != '\0') + bool is_mem = (name_buf[0] == 'm' && name_buf[1] == 'e' && + name_buf[2] == 'm' && name_buf[3] == '\0'); + bool is_kmem = (name_buf[0] == 'k' && name_buf[1] == 'm' && + name_buf[2] == 'e' && name_buf[3] == 'm' && + name_buf[4] == '\0'); + + if (!is_mem && !is_kmem) return 0; - /* Get parent directory name (should be the PID) */ + /* Get parent directory name */ parent = BPF_CORE_READ(dentry, d_parent); if (!parent) return 0; @@ -84,6 +90,26 @@ int BPF_PROG(owl_file_open, struct file *file) bpf_probe_read_kernel_str(parent_buf, sizeof(parent_buf), parent_name.name); + /* + * /dev/mem or /dev/kmem — unconditional block. + * Physical memory access threatens ALL processes. + */ + bool is_dev_parent = (parent_buf[0] == 'd' && parent_buf[1] == 'e' && + parent_buf[2] == 'v' && parent_buf[3] == '\0'); + + if (is_dev_parent && (is_mem || is_kmem)) { + caller_pid = bpf_get_current_pid_tgid() >> 32; + emit_event(OWL_EVENT_DEV_MEM_ACCESS, OWL_SEV_CRITICAL, + caller_pid, 0, + is_kmem ? "BPF LSM: /dev/kmem blocked" + : "BPF LSM: /dev/mem blocked"); + return -EPERM; + } + + /* Only "mem" is relevant under /proc; kmem handled above */ + if (!is_mem) + return 0; + /* * Verify parent's parent is "proc" — this distinguishes * /proc//mem from other files named "mem". diff --git a/include/owlbear_events.h b/include/owlbear_events.h index ddac378..ef25098 100644 --- a/include/owlbear_events.h +++ b/include/owlbear_events.h @@ -50,6 +50,7 @@ enum owlbear_event_type { OWL_EVENT_VM_WRITEV_ATTEMPT = 0x0103, OWL_EVENT_EXEC_MMAP = 0x0104, OWL_EVENT_MPROTECT_EXEC = 0x0105, + OWL_EVENT_DEV_MEM_ACCESS = 0x0106, /* Integrity checks (0x02xx) */ OWL_EVENT_MODULE_LOAD = 0x0200, diff --git a/scripts/verify.sh b/scripts/verify.sh index f373c50..35bf17a 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -297,7 +297,7 @@ preflight() { | grep -o '"accountId" *: *"[^"]*"' | cut -d'"' -f4 || echo "local") cat > "${OUT_DIR}/summary.txt" <
/dev/null && \ + ! grep -q "EPERM" "${phase_dir}/dev_mem_reader/stderr.log" 2>/dev/null; then + assert_skip "baseline/dev_mem_reader" "CONFIG_STRICT_DEVMEM blocks /dev/mem (EACCES)" + elif grep -q "No such file" "${phase_dir}/dev_mem_reader/stderr.log" 2>/dev/null; then + assert_skip "baseline/dev_mem_reader" "/dev/mem does not exist" + else + assert_fail "baseline/dev_mem_reader should succeed or skip without module" "exit_code=${rc}" + fi + capture_dmesg > "${phase_dir}/dmesg_after.txt" dmesg_since "${phase_mark}" "${phase_dir}/dmesg_phase_diff.txt" @@ -767,6 +784,34 @@ phase_protected() { assert_pass "protected/mprotect_inject_via_ptrace ptrace denied (EPERM)" fi + # --- dev_mem_reader --- + run_cheat_captured "${phase_dir}" "dev_mem_reader" \ + "${cheats_dir}/dev_mem_reader.bin" + + # Assertion 1: open blocked with EPERM (our hook) or EACCES (kernel config) + if grep -q "EPERM" "${phase_dir}/dev_mem_reader/stderr.log" 2>/dev/null; then + assert_pass "protected/dev_mem_reader blocked by eBPF LSM (EPERM)" + elif grep -q "EACCES\|Permission denied" "${phase_dir}/dev_mem_reader/stderr.log" 2>/dev/null; then + assert_skip "protected/dev_mem_reader" "CONFIG_STRICT_DEVMEM blocks before LSM (EACCES)" + elif grep -q "No such file" "${phase_dir}/dev_mem_reader/stderr.log" 2>/dev/null; then + assert_skip "protected/dev_mem_reader" "/dev/mem does not exist" + else + assert_fail "protected/dev_mem_reader should be blocked" \ + "exit_code=$(cat "${phase_dir}/dev_mem_reader/exit_code")" + fi + + # Assertion 2: DEV_MEM_ACCESS event in daemon log + if grep -q "EPERM" "${phase_dir}/dev_mem_reader/stderr.log" 2>/dev/null; then + if [ -f "${phase_dir}/daemon.log" ] && \ + grep -q "DEV_MEM_ACCESS" "${phase_dir}/daemon.log" 2>/dev/null; then + assert_pass "protected/dev_mem_reader DEV_MEM_ACCESS event in daemon log" + else + assert_fail "protected/dev_mem_reader DEV_MEM_ACCESS missing from daemon log" + fi + elif grep -q "EACCES\|Permission denied\|No such file" "${phase_dir}/dev_mem_reader/stderr.log" 2>/dev/null; then + assert_skip "protected/dev_mem_reader event check" "kernel blocked before LSM hook" + fi + # Check daemon log for BLOCK entries if enforce mode if [ -f "${phase_dir}/daemon.log" ]; then if grep -q "\[ENFORCE\].*\[BLOCK\]" "${phase_dir}/daemon.log" 2>/dev/null; then @@ -917,7 +962,7 @@ FOOTER main() { echo "" echo -e "${BOLD}================================================${NC}" - echo -e "${BOLD} Owlbear E2E Verification (v1.2.0)${NC}" + echo -e "${BOLD} Owlbear E2E Verification (v2.0.0)${NC}" echo -e "${BOLD} Evidence Package Builder${NC}" echo -e "${BOLD}================================================${NC}" echo "" diff --git a/tests/test_bpf_loader b/tests/test_bpf_loader index b273e39..b205a69 100755 Binary files a/tests/test_bpf_loader and b/tests/test_bpf_loader differ diff --git a/tests/test_bpf_loader.c b/tests/test_bpf_loader.c index 52d878e..fa966e3 100644 --- a/tests/test_bpf_loader.c +++ b/tests/test_bpf_loader.c @@ -103,6 +103,28 @@ TEST(bpf_convert_mprotect_event) { ASSERT_EQ(out.payload.memory.caller_pid, 500); } +TEST(bpf_convert_dev_mem_access_event) { + struct test_bpf_event bev; + struct owlbear_event out; + + memset(&bev, 0, sizeof(bev)); + bev.event_type = OWL_EVENT_DEV_MEM_ACCESS; + bev.severity = OWL_SEV_CRITICAL; + bev.pid = 600; + bev.target_pid = 0; + strncpy(bev.comm, "dev_mem_read", sizeof(bev.comm)); + strncpy(bev.detail, "BPF LSM: /dev/mem blocked", sizeof(bev.detail)); + + int ret = owl_bpf_event_convert(&bev, sizeof(bev), &out); + ASSERT_EQ(ret, 0); + ASSERT_EQ(out.event_type, OWL_EVENT_DEV_MEM_ACCESS); + ASSERT_EQ(out.severity, OWL_SEV_CRITICAL); + ASSERT_EQ(out.source, OWL_SRC_EBPF); + ASSERT_EQ(out.pid, 600); + ASSERT_EQ(out.target_pid, 0); + ASSERT_EQ(out.payload.memory.caller_pid, 600); +} + TEST(bpf_convert_null_input_fails) { struct owlbear_event out; @@ -151,6 +173,7 @@ int main(void) RUN_TEST(bpf_convert_module_load_event); RUN_TEST(bpf_convert_vm_readv_event); RUN_TEST(bpf_convert_mprotect_event); + RUN_TEST(bpf_convert_dev_mem_access_event); RUN_TEST(bpf_convert_null_input_fails); RUN_TEST(bpf_convert_null_output_fails); RUN_TEST(bpf_convert_too_small_fails);