Skip to content
Merged
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ 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.1.0] - 2026-03-15

### Added
- **WP1d: LD_PRELOAD detection on process exec** (`daemon/preload_detect.c`): scans `/proc/<pid>/environ` for `LD_PRELOAD` on every `PROCESS_EXEC` event. Emits `OWL_EVENT_LIB_UNEXPECTED` (0x0203, severity CRITICAL) with the preload path in the module payload. Pure scanner function + I/O wrapper, stateless one-shot per exec.
- `daemon/preload_detect.h`: public API (`owl_scan_environ_for_preload`, `owl_check_preload_env`)
- `tests/test_preload_detect.c`: 5 unit tests (TDD) — synthetic buffer scan, not-found, null buffer, null output, self-check via /proc
- `scripts/verify.sh`: E2E assertion for `ld_preload_hook` triggering `LIB_UNEXPECTED` in daemon log

### Changed
- `daemon/event_pipeline.c`: calls `pipeline_check_preload()` on `OWL_EVENT_PROCESS_EXEC` events, emits `LIB_UNEXPECTED` if `LD_PRELOAD` found
- `daemon/Makefile`: added `preload_detect.c` to SRCS
- `tests/Makefile`: added `test_preload_detect` suite, linked `preload_detect.o` into `test_event_pipeline`
- `scripts/verify.sh`: version bumped to v2.1.0, added `ld_preload_hook.bin` to preflight checks
- `README.md`: updated status (v2.1.0, 107 tests, 12 suites), added LD_PRELOAD detection

## [2.0.0] - 2026-03-15

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ 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 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.
- **daemon/** - epoll on chardev + BPF ring buffer. Policy engine. Signature scanner. CRC32 code integrity. Self-protection watchdog. TracerPid debugger detection. LD_PRELOAD environ scanning.
- **game/** - ncurses test target. Mutable state, function pointers, exported address.
- **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

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.
v2.1.0. 107 unit tests, 12 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. LD_PRELOAD detection on exec.

Prototype limitations: linear signature scan, CRC32 not cryptographic, no fleet management.

Expand Down
1 change: 1 addition & 0 deletions daemon/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ SRCS := main.c \
integrity.c \
self_protect.c \
debugger_detect.c \
preload_detect.c \
policy.c \
scanner.c \
heartbeat.c
Expand Down
42 changes: 42 additions & 0 deletions daemon/event_pipeline.c
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
#include <unistd.h>

#include "event_pipeline.h"
#include "preload_detect.h"

static void pipeline_check_preload(struct owl_pipeline *pipe,
const struct owlbear_event *exec_ev);

/* -------------------------------------------------------------------------
* Initialization
Expand Down Expand Up @@ -86,9 +90,47 @@ enum owl_policy_action owl_pipeline_process(struct owl_pipeline *pipe,
break;
}

/* On exec events, check for LD_PRELOAD in the new process's environ */
if (ev->event_type == OWL_EVENT_PROCESS_EXEC)
pipeline_check_preload(pipe, ev);

return action;
}

/* -------------------------------------------------------------------------
* LD_PRELOAD detection on exec events
* ----------------------------------------------------------------------- */

static void pipeline_check_preload(struct owl_pipeline *pipe,
const struct owlbear_event *exec_ev)
{
char preload_val[256];

if (exec_ev->pid <= 0)
return;

if (owl_check_preload_env((pid_t)exec_ev->pid,
preload_val, sizeof(preload_val)) != 1)
return;

struct owlbear_event ev;
struct timespec ts;

memset(&ev, 0, sizeof(ev));
clock_gettime(CLOCK_MONOTONIC, &ts);
ev.timestamp_ns = (uint64_t)ts.tv_sec * 1000000000ULL +
(uint64_t)ts.tv_nsec;
ev.event_type = OWL_EVENT_LIB_UNEXPECTED;
ev.severity = OWL_SEV_CRITICAL;
ev.source = OWL_SRC_DAEMON;
ev.pid = exec_ev->pid;
ev.target_pid = (uint32_t)pipe->target_pid;
strncpy(ev.payload.module.name, preload_val,
sizeof(ev.payload.module.name) - 1);

owl_pipeline_process(pipe, &ev);
}

/* -------------------------------------------------------------------------
* Signature scanning - buffer variant (testable)
* ----------------------------------------------------------------------- */
Expand Down
71 changes: 71 additions & 0 deletions daemon/preload_detect.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: GPL-2.0-only
/*
* preload_detect.c - LD_PRELOAD detection via /proc/<pid>/environ
*
* Scans a process's null-separated environment block for the
* LD_PRELOAD variable. Used by the event pipeline on exec events.
*/

#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#include "preload_detect.h"

#define LD_PRELOAD_PREFIX "LD_PRELOAD="
#define LD_PRELOAD_PREFIX_LEN 11
#define ENVIRON_MAX_SIZE (64 * 1024)

int owl_scan_environ_for_preload(const char *buf, size_t buf_len,
char *value_out, size_t value_len)
{
if (!buf || buf_len == 0)
return -1;

const char *pos = buf;
const char *end = buf + buf_len;

while (pos < end) {
size_t entry_len = strnlen(pos, (size_t)(end - pos));

if (entry_len >= LD_PRELOAD_PREFIX_LEN &&
strncmp(pos, LD_PRELOAD_PREFIX, LD_PRELOAD_PREFIX_LEN) == 0) {
if (value_out && value_len > 0) {
const char *val = pos + LD_PRELOAD_PREFIX_LEN;
size_t vlen = entry_len - LD_PRELOAD_PREFIX_LEN;
if (vlen >= value_len)
vlen = value_len - 1;
memcpy(value_out, val, vlen);
value_out[vlen] = '\0';
}
return 1;
}

pos += entry_len + 1;
}

return 0;
}

int owl_check_preload_env(pid_t pid, char *value_out, size_t value_len)
{
if (pid <= 0)
return -1;

char path[64];
snprintf(path, sizeof(path), "/proc/%d/environ", (int)pid);

int fd = open(path, O_RDONLY);
if (fd < 0)
return -1;

char buf[ENVIRON_MAX_SIZE];
ssize_t n = read(fd, buf, sizeof(buf));
close(fd);

if (n <= 0)
return -1;

return owl_scan_environ_for_preload(buf, (size_t)n, value_out, value_len);
}
42 changes: 42 additions & 0 deletions daemon/preload_detect.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* SPDX-License-Identifier: GPL-2.0-only */
/*
* preload_detect.h - LD_PRELOAD detection via /proc/<pid>/environ
*
* Scans a process's environment for LD_PRELOAD. Called on exec events
* to detect library injection before the dynamic linker runs.
*/

#ifndef OWLBEAR_PRELOAD_DETECT_H
#define OWLBEAR_PRELOAD_DETECT_H

#include <stddef.h>
#include <sys/types.h>

/**
* owl_scan_environ_for_preload - Scan null-separated environ buffer
* @buf: Buffer of null-separated KEY=VALUE entries
* @buf_len: Length of buffer in bytes
* @value_out: Output buffer for the LD_PRELOAD value (may be NULL)
* @value_len: Size of value_out
*
* Pure function. Walks null-separated entries looking for "LD_PRELOAD=".
* Copies the value after '=' to value_out if provided.
*
* Returns: 1 found, 0 not found, -1 error (null buf or zero len)
*/
int owl_scan_environ_for_preload(const char *buf, size_t buf_len,
char *value_out, size_t value_len);

/**
* owl_check_preload_env - Check if a process has LD_PRELOAD set
* @pid: Process to check
* @value_out: Output buffer for the LD_PRELOAD value (may be NULL)
* @value_len: Size of value_out
*
* I/O wrapper. Reads /proc/<pid>/environ (up to 64KB), calls scan.
*
* Returns: 1 found, 0 not found, -1 error
*/
int owl_check_preload_env(pid_t pid, char *value_out, size_t value_len);

#endif /* OWLBEAR_PRELOAD_DETECT_H */
24 changes: 21 additions & 3 deletions scripts/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ preflight() {
| grep -o '"accountId" *: *"[^"]*"' | cut -d'"' -f4 || echo "local")

cat > "${OUT_DIR}/summary.txt" <<HEADER
# Owlbear E2E Verification Report (v2.0.0)
# Owlbear E2E Verification Report (v2.1.0)
# Generated: $(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Host: $(uname -n)
# Kernel: $(uname -r)
Expand Down Expand Up @@ -325,7 +325,8 @@ HEADER
cheats/ptrace_injector.bin cheats/ptrace_writer.bin \
cheats/vm_writer.bin cheats/mprotect_injector.bin \
cheats/mprotect_inject_via_ptrace.bin \
cheats/dev_mem_reader.bin; do
cheats/dev_mem_reader.bin \
cheats/ld_preload_hook.bin; do
if [ ! -f "${PROJECT_DIR}/${bin}" ]; then
warn "Missing: ${bin} — building..."
missing=1
Expand Down Expand Up @@ -812,6 +813,23 @@ phase_protected() {
assert_skip "protected/dev_mem_reader event check" "kernel blocked before LSM hook"
fi

# --- ld_preload_hook ---
run_cheat_captured "${phase_dir}" "ld_preload_hook" \
"${cheats_dir}/ld_preload_hook.bin" \
"${PROJECT_DIR}/game/owlbear-game" "--no-curses"

sleep 1 # daemon processes exec event async

if [ -f "${phase_dir}/daemon.log" ] && \
grep -q "LIB_UNEXPECTED" "${phase_dir}/daemon.log" 2>/dev/null; then
assert_pass "protected/ld_preload_hook triggers LIB_UNEXPECTED detection"
elif [ -f "${phase_dir}/daemon_stdout.log" ] && \
grep -q "LIB_UNEXPECTED" "${phase_dir}/daemon_stdout.log" 2>/dev/null; then
assert_pass "protected/ld_preload_hook triggers LIB_UNEXPECTED (stdout)"
else
assert_fail "protected/ld_preload_hook LIB_UNEXPECTED detection missing"
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
Expand Down Expand Up @@ -962,7 +980,7 @@ FOOTER
main() {
echo ""
echo -e "${BOLD}================================================${NC}"
echo -e "${BOLD} Owlbear E2E Verification (v2.0.0)${NC}"
echo -e "${BOLD} Owlbear E2E Verification (v2.1.0)${NC}"
echo -e "${BOLD} Evidence Package Builder${NC}"
echo -e "${BOLD}================================================${NC}"
echo ""
Expand Down
10 changes: 7 additions & 3 deletions tests/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ TEST_BINS := test_events \
test_event_pipeline \
test_integrity \
test_self_protect \
test_debugger_detect
test_debugger_detect \
test_preload_detect

.PHONY: all unit integration clean

Expand Down Expand Up @@ -64,7 +65,7 @@ test_bpf_loader: test_harness.o test_bpf_loader.o $(DAEMON_DIR)/bpf_event_conver
test_sig_loader: test_harness.o test_sig_loader.o $(DAEMON_DIR)/sig_loader.o $(DAEMON_DIR)/scanner.o
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)

test_event_pipeline: test_harness.o test_event_pipeline.o $(DAEMON_DIR)/event_pipeline.o $(DAEMON_DIR)/policy.o $(DAEMON_DIR)/scanner.o
test_event_pipeline: test_harness.o test_event_pipeline.o $(DAEMON_DIR)/event_pipeline.o $(DAEMON_DIR)/policy.o $(DAEMON_DIR)/scanner.o $(DAEMON_DIR)/preload_detect.o
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)

test_integrity: test_harness.o test_integrity.o $(DAEMON_DIR)/integrity.o
Expand All @@ -76,6 +77,9 @@ test_self_protect: test_harness.o test_self_protect.o $(DAEMON_DIR)/self_protect
test_debugger_detect: test_harness.o test_debugger_detect.o $(DAEMON_DIR)/debugger_detect.o
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)

test_preload_detect: test_harness.o test_preload_detect.o $(DAEMON_DIR)/preload_detect.o
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)

# Daemon objects needed by tests
$(DAEMON_DIR)/%.o: $(DAEMON_DIR)/%.c
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
Expand All @@ -96,7 +100,7 @@ clean:
$(RM) $(DAEMON_DIR)/policy.o $(DAEMON_DIR)/scanner.o $(DAEMON_DIR)/heartbeat.o
$(RM) $(DAEMON_DIR)/bpf_event_convert.o $(DAEMON_DIR)/sig_loader.o
$(RM) $(DAEMON_DIR)/event_pipeline.o $(DAEMON_DIR)/integrity.o
$(RM) $(DAEMON_DIR)/self_protect.o $(DAEMON_DIR)/debugger_detect.o
$(RM) $(DAEMON_DIR)/self_protect.o $(DAEMON_DIR)/debugger_detect.o $(DAEMON_DIR)/preload_detect.o
$(RM) $(DAEMON_DIR)/*.d

-include $(wildcard *.d)
Binary file modified tests/test_event_pipeline
Binary file not shown.
Binary file added tests/test_preload_detect
Binary file not shown.
80 changes: 80 additions & 0 deletions tests/test_preload_detect.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/* SPDX-License-Identifier: GPL-2.0-only */
/*
* test_preload_detect.c - Tests for LD_PRELOAD environ detection
*
* Tests the pure environ scanning function and the I/O wrapper
* that reads /proc/<pid>/environ.
*/

#include <string.h>
#include <sys/types.h>
#include <unistd.h>

#include "test_harness.h"
#include "preload_detect.h"

/* -------------------------------------------------------------------------
* Pure function: owl_scan_environ_for_preload
* ----------------------------------------------------------------------- */

TEST(scan_preload_found) {
/* Synthetic environ buffer with LD_PRELOAD set */
const char buf[] = "HOME=/h\0LD_PRELOAD=/path/hook.so\0PATH=/bin\0";
char value[256] = {0};

int ret = owl_scan_environ_for_preload(buf, sizeof(buf) - 1,
value, sizeof(value));
ASSERT_EQ(ret, 1);
ASSERT_STR_EQ(value, "/path/hook.so");
}

TEST(scan_preload_not_found) {
const char buf[] = "HOME=/h\0PATH=/bin\0TERM=xterm\0";
char value[256] = {0};

int ret = owl_scan_environ_for_preload(buf, sizeof(buf) - 1,
value, sizeof(value));
ASSERT_EQ(ret, 0);
}

TEST(scan_null_buffer_returns_error) {
int ret = owl_scan_environ_for_preload(NULL, 0, NULL, 0);
ASSERT_EQ(ret, -1);
}

TEST(scan_preload_null_output) {
/* LD_PRELOAD present, but value_out is NULL — should return 1 without crash */
const char buf[] = "LD_PRELOAD=/evil.so\0PATH=/bin\0";

int ret = owl_scan_environ_for_preload(buf, sizeof(buf) - 1, NULL, 0);
ASSERT_EQ(ret, 1);
}

/* -------------------------------------------------------------------------
* I/O wrapper: owl_check_preload_env
* ----------------------------------------------------------------------- */

TEST(check_preload_self) {
/* Test process should not have LD_PRELOAD set */
char value[256] = {0};
int ret = owl_check_preload_env(getpid(), value, sizeof(value));
ASSERT_EQ(ret, 0);
}

/* -------------------------------------------------------------------------
* Runner
* ----------------------------------------------------------------------- */

int main(void)
{
printf("=== Owlbear Preload Detection Tests ===\n");

RUN_TEST(scan_preload_found);
RUN_TEST(scan_preload_not_found);
RUN_TEST(scan_null_buffer_returns_error);
RUN_TEST(scan_preload_null_output);
RUN_TEST(check_preload_self);

TEST_SUMMARY();
return test_failures;
}
Loading