#!/usr/bin/env python3 """kernelsu_syscall_mask.py -- generate a configurable kernel-side syscall-masking scaffold. What this generates, and the correction it exists to make --------------------------------------------------------- There is a widespread misreading worth killing early: **a KernelSU module is a Magisk-compatible userspace module and cannot change what a syscall returns.** Its `post-fs-data.sh` / `service.sh` run as root in the normal world; nothing in that format reaches the kernel's return path. Everything a module of that shape can do is userspace work -- bind mounts, setting props, starting a daemon, writing config. Actually rewriting what `openat`/`read`/`stat` return for `/proc/self/maps` or `/proc/self/status` needs one of exactly three things: kpm KernelPatch / APatch Kernel Patch Module -- pointer replacement in the syscall table (`fp_hook_syscalln`) or inline hooks (`hook_wrapN`). Built with a bare-metal ARM64 toolchain (aarch64-none-elf-gcc) into a relocatable .kpm ELF. Needs a KernelPatch-patched boot image. lkm An out-of-tree kernel module. Needs kernel source matching the device's exact version and vermagic, and a loader path. ebpf A BPF program attached to a syscall tracepoint or kprobe. Needs a GKI kernel with the tracing/BTF machinery -- 5.10+ in Android practice. So this script emits a **module directory that is honest about which half is loadable**: the userspace skeleton (module.prop + scripts + config) is real and installable, and each kernel-side target is a template with its toolchain gate stated in its own header. None of the kernel-side code is compiled or loaded anywhere by this script. What is measured vs unverified ------------------------------ `generate`, `gates` and `verify` are exercised on the host (see docs/tool-verification/EXTENSION-kernel-weapons.md). **Whether the kernel-side code compiles or loads is unverified** -- that needs a device with the matching kernel and the matching toolchain, and the reference device is a 4.14.186 Magisk build where all three routes are closed. Examples python kernelsu_syscall_mask.py gates python kernelsu_syscall_mask.py generate --out work/sysmask --package python kernelsu_syscall_mask.py verify --dir work/sysmask """ import argparse import json import os import re import subprocess import sys # --------------------------------------------------------------------------- # Kernel gate table. Every number here is either a toolchain fact or a # documented platform threshold; the "how to check" column is what turns it # from trivia into a test you run on your own device. # --------------------------------------------------------------------------- GATES = [ { "route": "eBPF tracepoint/kprobe", "gate": "GKI kernel 5.10+ (Android 12+)", "why": "Android's BPF/tracing support as bpftrace-class tooling and " "stackplz expect it is a GKI-era feature; BTF is required for " "CO-RE style probes.", "check": "uname -r -> expect 5.10.x or newer; ls /sys/kernel/btf/vmlinux", "ref": "https://source.android.com/docs/core/architecture/kernel/bpf", }, { "route": "KernelPatch / APatch KPM", "gate": "KernelPatch-patched boot image + kpm toolchain", "why": "KPMs load through kpimg injected into the kernel image's payload " "segment; building one needs a bare-metal ARM64 compiler " "(aarch64-none-elf-gcc), not the NDK.", "check": "ls /data/adb/ | grep -i apatch ; which aarch64-none-elf-gcc", "ref": "https://github.com/bmax121/KernelPatch", }, { "route": "Out-of-tree LKM", "gate": "Kernel source matching the device's exact version + vermagic", "why": "A module built against a different kernel revision is refused at " "load time on a mismatched vermagic, and the device's kernel " "source is frequently not published at all.", "check": "uname -r ; look for a matching kernel source tree for the device", "ref": "https://source.android.com/docs/core/architecture/kernel/android-common", }, { "route": "seccomp-BPF (for contrast)", "gate": "any modern kernel, but it is not this route", "why": "seccomp can make a syscall FAIL (SECCOMP_RET_ERRNO/TRAP); it " "cannot rewrite the CONTENT a successful read returns. It closes " "doors, it does not paint them -- which is the whole requirement " "for a spoofed /proc read.", "check": "n/a -- this is a capability ceiling, not a version check", "ref": "references/kernel-and-environment-hardening.md", }, ] DEFAULT_RULES = [ { "syscall": "openat", "path": "/proc/self/maps", "action": "deny", "errno": "ENOENT", "note": "hides the mapping list a userspace instrumentation agent " "advertises itself in", }, { "syscall": "newfstatat", "path": "/proc/self/maps", "action": "fake_size", "size": 0, "note": "a stat that reports 0 bytes is as good as absent for a reader " "that sizes its buffer first", }, { "syscall": "openat", "path": "/proc/self/status", "action": "filter_line", "prefix": "TracerPid", "note": "the write side owns this field; see " "references/kernel-and-environment-hardening.md section 5", }, { "syscall": "read", "path": "/proc/self/status", "action": "filter_line", "prefix": "TracerPid", "note": "line filter applied to the read once the fd is open", }, ] MODULE_PROP = """id={id} name={name} version={version} versionCode=1 author=apk-reverse description={description} """ POST_FS_DATA = """#!/system/bin/sh # Runs at post-fs-data, as root, in the normal world. # The kernel side (if you built and loaded one) owns the syscall table; this # script's whole job is to make the configuration visible to it and to leave a # record that the module actually ran. Do not put a syscall hook here: this is # userspace, and nothing here can change what openat/read/stat return. MODDIR=${{0%/*}} CONF=$MODDIR/config/syscall_mask.json LOG=/data/adb/{id}.log if [ ! -f "$CONF" ]; then echo "$(date) missing $CONF" >> "$LOG" exit 0 fi echo "$(date) post-fs-data: config present, $(wc -c < "$CONF") bytes" >> "$LOG" # The KPM/LKM loader path is intentionally NOT invoked from here. Loading a # kernel module is a boot-image-level decision with a bricking risk; do it once, # by hand, and read the evidence file first. exit 0 """ SERVICE = """#!/system/bin/sh # Runs in late_start service mode, as root. Kept minimal and non-blocking on # purpose: a module that spins here delays boot on every start, and this one # only reports state. MODDIR=${{0%/*}} CONF=$MODDIR/config/syscall_mask.json LOG=/data/adb/{id}.log if [ -r /proc/sys/kernel/version ]; then echo "$(date) service: kernel $(uname -r)" >> "$LOG" fi echo "$(date) service: rules=$(grep -c '"syscall"' "$CONF" 2>/dev/null)" >> "$LOG" exit 0 """ UNINSTALL = """#!/system/bin/sh # Uninstall hook. Deliberately does not attempt to unload a kernel module: if one # is loaded, unloading it is a separate deliberate act with its own risk, and a # silent unload during package removal is how a device ends up in a boot loop. rm -f /data/adb/{id}.log exit 0 """ CUSTOMIZE = """#!/system/bin/sh # KernelSU/Magisk install-time script. Kept as a no-op so the module always # installs; see the module README for why nothing is set up here. exit 0 """ KPM_C = r'''// SPDX-License-Identifier: GPL-2.0 /* * syscall_mask.c -- config-driven syscall return masking, KernelPatch/APatch KPM. * * Generated by skills/apkseep/scripts/kernelsu_syscall_mask.py * * STATUS: TEMPLATE, NOT COMPILED AND NOT LOADED. Every hook below follows the * KernelPatch KPM interface as documented in public KPM development write-ups * (KPM_NAME/KPM_INIT/KPM_EXIT sections, kfunc_def symbol resolution without * `extern`, fp_hook_syscalln pointer replacement, hook_fargs4_t callbacks with * args->skip_origin / args->ret). VERIFY EVERY NAME AND SIGNATURE against the * kpmodule.h of the KernelPatch revision you build against -- this file was not * compiled anywhere, and an interface that has drifted produces a module that * fails to load, or worse, loads and faults. * * Build (bare-metal ARM64, NOT the NDK): * aarch64-none-elf-gcc -O2 -fno-stack-protector -c syscall_mask.c * ... link into a relocatable ELF per the KernelPatch module docs * Load: push the .kpm and add it through the APatch app's KPM manager. * * Design notes that are load-bearing rather than stylistic: * - Callbacks run on EVERY syscall of their number, system-wide. The first * thing each one does is reject the overwhelmingly common case, because a * hook that does real work on every write is how a device locks up. * - Inline hooks (hook_wrapN) rewrite the target's prologue. On an LTO kernel * the exported symbol is frequently NOT the call site (the call got inlined), * so an inline hook installs cleanly and never fires. Pointer replacement in * the syscall table (fp_hook_syscalln) does not have that failure mode, * because the syscall entry path must go through the table. * - Never sleep in these callbacks except where the syscall itself runs in * process context and the copy helper is the sleeping variant. */ #include #include #include #include KPM_NAME("{id}"); KPM_VERSION("{version}"); KPM_LICENSE("GPL"); KPM_AUTHOR("apk-reverse"); KPM_DESCRIPTION("{description}"); /* kfunc declarations must NOT carry `extern`: an extern declaration compiles to * an undefined reference (*UND*) and the loader rejects the module with * "unknown symbol". Letting it be a tentative definition allocates the slot in * .bss and the loader fills it in. Measured and documented in public KPM work. */ int kfunc_def(strncpy_from_user)(char *dst, const char __user *src, long count); /* ------------------------------------------------------------------ rules -- * Generated from config/syscall_mask.json. Keep this table in sync by * re-running the generator rather than by hand-editing it. */ #define MASK_DENY 1 /* return -err before the real syscall runs */ #define MASK_FAKE_SIZE 2 /* openat succeeds, stat reports a bogus size */ #define MASK_FILTER_LINE 3 /* strip lines with this prefix from read() */ struct mask_rule { const char *path; /* userspace path to match, NULL = any */ const char *prefix; /* line prefix for MASK_FILTER_LINE, else NULL */ int action; int err; /* -errno for MASK_DENY */ long value; /* size for MASK_FAKE_SIZE */ }; static const struct mask_rule rules[] = { {RULE_TABLE} }; #define NRULES (sizeof(rules) / sizeof(rules[0])) /* A bounded, allocation-free matcher. The path is copied in first, so the * comparison never touches userspace after the initial copy -- touching * userspace memory twice is how a hook races with the caller's own unmapping. */ static int copy_and_match(const char __user *upath, char *kbuf, unsigned long n, const char **matched) {{ long copied; unsigned long i; if (!upath || n == 0) return 0; if (!kfunc(strncpy_from_user)) return 0; /* symbol unresolved: fail open */ copied = kfunc(strncpy_from_user)(kbuf, upath, (long)(n - 1)); if (copied <= 0) return 0; kbuf[copied] = '\0'; for (i = 0; i < NRULES; i++) {{ if (!rules[i].path) continue; if (strstr(kbuf, rules[i].path)) {{ *matched = rules[i].path; return rules[i].action; }} }} return 0; }} /* ------------------------------------------------------------------ openat -- * The path argument is arg2 on arm64 (dfd, path, flags, mode). If the path is * unreadable, the rule is skipped rather than guessed at. */ static void before_openat(hook_fargs4_t *args, void *udata) {{ const char __user *upath = (const char __user *)syscall_argn(args, 1); char kbuf[256]; const char *matched = 0; int action; (void)udata; action = copy_and_match(upath, kbuf, sizeof(kbuf), &matched); if (action == MASK_DENY) args->ret = MASK_DENY_ERRNO; if (action == MASK_FAKE_SIZE) args->skip_origin = 0; /* let it open; the stat is spoofed */ }} /* --------------------------------------------------------------- newfstatat -- * arm64 has no plain `stat` syscall; readers go through newfstatat (or fstatat64 * on 32-bit). A zero size is what makes a reader that sizes its buffer first * decide there is nothing to read. */ static void after_newfstatat(hook_fargs4_t *args, void *udata) {{ const char __user *upath = (const char __user *)syscall_argn(args, 1); char kbuf[256]; const char *matched = 0; (void)udata; if (copy_and_match(upath, kbuf, sizeof(kbuf), &matched) != MASK_FAKE_SIZE) return; /* The struct stat* is arg2; zeroing st_size is the whole point. Field * offsets are ABI-specific -- resolve them against the target's headers * rather than trusting an offset copied from a different architecture. */ /* TODO(verify-on-device): zero the size field of the struct at arg2. */ }} /* -------------------------------------------------------------------- read -- * Line filtering happens on the read side, once the fd is already open. Doing * it here rather than at openat is what lets a reader that opens the file * before the filter is installed still be covered. */ static void before_read(hook_fargs4_t *args, void *udata) {{ long count = (long)syscall_argn(args, 2); (void)udata; if (count <= 0 || count > 8192) return; /* first filter: the common case */ /* TODO(verify-on-device): identify the fd's path, copy the buffer, rewrite * it in place minus the filtered lines, and adjust the returned length. */ }} /* ------------------------------------------------------------------ init ----- * Registration order matters: install the syscall hooks first, then enable the * rules. If the module is unloaded between the two, the hooks must already be * gone. */ static long mask_init(const char *args, const struct kernel_patch_funcs *kp) {{ (void)args; (void)kp; if (!kfunc(strncpy_from_user)) {{ /* Failing loudly here is deliberate: a half-armed hook set that cannot * read a path would pass every check through, which looks like success * and is not. */ return -EINVAL; }} /* fp_hook_syscalln(__NR_openat, before_openat, 0, 0); */ /* fp_hook_syscalln(__NR_newfstatat, before_newfstatat, 0, 0); */ /* fp_hook_syscalln(__NR_read, before_read, 0, 0); */ /* TODO(verify-on-device): the argument lists above follow the public * examples; confirm arity against your kpmodule.h before enabling them. */ return 0; }} static long mask_exit(void *udata) {{ (void)udata; /* Unhook in the exact reverse order of registration. */ return 0; }} KPM_INIT(mask_init); KPM_EXIT(mask_exit); ''' LKM_C = r'''// SPDX-License-Identifier: GPL-2.0 /* * syscall_mask.c -- out-of-tree LKM variant of the same idea. * * STATUS: TEMPLATE, NOT COMPILED. Worse than the KPM variant in one specific * way worth understanding before choosing it: to replace a syscall you must * reach the syscall table, and `kallsyms_lookup_name` stopped being exported in * Linux 5.7. On pre-5.7 kernels a direct call works; after that you need a * kprobe to recover the address, or an explicit kernel export. The device's own * kernel source must also match its exact vermagic, and an out-of-tree module * built against a mismatched tree is refused at insmod. * * This is genuine kernel development with a bricking risk. It is the most * expensive of the three routes and the one least likely to be worth it. */ #include #include #include #include #include MODULE_LICENSE("GPL"); MODULE_DESCRIPTION("{description}"); MODULE_AUTHOR("apk-reverse"); /* * Pre-5.7: kallsyms_lookup_name() is exported, so the table pointer is * recoverable directly. 5.7+: NOT exported -- you must either carry a kprobe to * find the symbol or patch the kernel's export list. There is no portable * version of this, which is the honest reason this route is rated last. */ #if LINUX_VERSION_CODE < KERNEL_VERSION(5, 7, 0) static unsigned long *sys_call_table_addr(void) { return (unsigned long *)kallsyms_lookup_name("sys_call_table"); } #else static unsigned long *sys_call_table_addr(void) { /* TODO(verify-on-device): recover via kprobe on kallsyms_lookup_name, or * enable an explicit export in your own kernel build. */ return NULL; } #endif static int __init mask_init(void) { unsigned long *table = sys_call_table_addr(); if (!table) { pr_err("syscall_mask: cannot locate sys_call_table on this kernel\n"); return -EINVAL; } pr_info("syscall_mask: sys_call_table at %px\n", table); /* TODO(verify-on-device): save the original pointer, write CR0 (or use * set_memory_rw) to make the table writable, swap in your handler. Getting * the write-protect dance wrong takes the device down. */ return 0; } static void __exit mask_exit(void) { /* TODO(verify-on-device): restore the saved pointers. */ } module_init(mask_init); module_exit(mask_exit); ''' EBPF_C = r'''// SPDX-License-Identifier: GPL-2.0 OR BSD-2-Clause /* * syscall_mask.bpf.c -- eBPF probe template: filter sys_enter_openat, then * rewrite the result. * * STATUS: TEMPLATE, NOT COMPILED AND NOT ATTACHED. Not runnable on a 4.14 * kernel: see the gate table in the module README (GKI 5.10+ in Android * practice, plus BTF for CO-RE style probes). * * READ THIS BEFORE COUNTING ON THE RESULT REWRITE. There are two different * attachment points and they are not interchangeable: * * - A *tracepoint* (syscalls/sys_enter_openat) is an observation point. It can * read the arguments and it can emit events, and it CANNOT change the * syscall's return value. A tracepoint-only program gives you visibility, * not spoofing. * - A *kprobe* on the syscall's entry (or a kretprobe on its exit) can modify * a register with bpf_override_return() -- but only for functions flagged * ALLOW_ERROR_INJECTION, which kernel syscall entry points are not, in * general. On many kernels the helper is refused for exactly this target. * * So the honest eBPF shape for this job is: observe with a tracepoint, and do * the rewrite in a place that actually owns the return value. What follows is * the observation half done properly, plus the rewrite half marked for the * device-side verification it needs. */ #include #include #include char LICENSE[] SEC("license") = "GPL"; #define MAX_PATH 256 #define TARGET_LEN 15 /* strlen("/proc/self/maps") */ struct event { __u32 pid; __u32 uid; __s32 dfd; __s64 ret; char path[MAX_PATH]; char comm[16]; }; /* /proc/self/status is 17; kept as a separate constant so the two rules can be * toggled independently. */ #define TARGET_STATUS_LEN 17 struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 1 << 24); } events SEC(".maps"); /* Filter the target package's processes. Populated from userspace with the * uids the app can run as; an empty map means "all processes", which is a * deliberate default because a filter that silently matches nothing looks * exactly like a probe that is not firing. */ struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 64); __type(key, __u32); /* uid */ __type(value, __u8); } target_uids SEC(".maps"); static __always_inline int want(void) { __u32 uid = bpf_get_current_uid_gid() & 0xffffffff; __u8 *hit = bpf_map_lookup_elem(&target_uids, &uid); /* Empty map -> observe everything. Populated map -> only listed uids. */ return hit || (bpf_map_lookup_elem(&target_uids, &uid) == 0); } SEC("tracepoint/syscalls/sys_enter_openat") int tp_sys_enter_openat(struct trace_event_raw_sys_enter *ctx) { struct event *e; const char *upath = (const char *)ctx->args[1]; if (!want()) return 0; e = bpf_ringbuf_reserve(&events, sizeof(*e), 0); if (!e) return 0; e->pid = bpf_get_current_pid_tgid() >> 32; e->uid = bpf_get_current_uid_gid() & 0xffffffff; e->dfd = (__s32)ctx->args[0]; e->ret = 0; bpf_get_current_comm(&e->comm, sizeof(e->comm)); bpf_probe_read_user_str(&e->path, sizeof(e->path), upath); bpf_ringbuf_submit(e, 0); return 0; } /* * The result-rewrite half. Two dead ends are recorded here on purpose so they * are not re-derived: * * 1. bpf_override_return() only works on functions marked * ALLOW_ERROR_INJECTION. A raw syscall entry is not one of them, so the * helper is typically refused for this target -- check the return value * and the verifier message rather than assuming the rewrite happened. * 2. A tracepoint cannot rewrite at all. If the rewrite is essential, the * route is a kprobe plus a kernel that permits the override, or one of the * KPM/LKM routes; eBPF's strength here is observation. */ SEC("kprobe/do_sys_openat2") int BPF_KPROBE(kp_do_sys_openat2, int dfd, const char *filename) { char buf[64]; if (bpf_probe_read_user_str(&buf, sizeof(buf), filename) <= 0) return 0; if (buf[0] == '/' && buf[1] == 'p' && buf[2] == 'r') { /* TODO(verify-on-device): bpf_override_return(ctx, -ENOENT) here only * after confirming ALLOW_ERROR_INJECTION on this target kernel. */ } return 0; } ''' KPM_MAKEFILE = """# aarch64-none-elf-gcc is a BARE-METAL ARM64 compiler, not the Android NDK # toolchain. The NDK targets Android userspace and will not produce a loadable # KPM. STATUS: untested -- this toolchain is not present on a Magisk-only device. CROSS ?= aarch64-none-elf- CC := $(CROSS)gcc OBJCOPY := $(CROSS)objcopy TARGET := {id} KPM_DIR ?= ../KernelPatch CFLAGS := -O2 -fno-stack-protector -fno-pic -fno-builtin \\ -I$(KPM_DIR)/kernel/include -I$(KPM_DIR)/include \\ -Wall -Wno-unused-variable all: $(TARGET).kpm $(TARGET).kpm: src/{id}.c \t$(CC) $(CFLAGS) -c $< -o $@.o \t$(OBJCOPY) -O binary --only-section=.text $@.o /dev/null 2>/dev/null || true \t@echo "NOTE: linking follows the KernelPatch module format; see the docs" \t@echo " for the current link step before trusting this target." clean: \trm -f *.o *.kpm .PHONY: all clean """ MODULE_README = """# {name} -- syscall-return masking scaffold Generated by `skills/apkseep/scripts/kernelsu_syscall_mask.py`. **Read this first: the userspace module in this directory cannot change what a syscall returns.** It is a KernelSU/Magisk-compatible module: its scripts run as root in the normal world. Its only jobs are to carry the configuration, to record that it ran, and to be a place to put the module metadata. The kernel side is a separate artifact with a separate toolchain and a separate risk. ## What is here | Path | Status | |---|---| | `module.prop`, `customize.sh`, `post-fs-data.sh`, `service.sh` | real, installable module skeleton | | `config/syscall_mask.json` | the rule set, read by the kernel side | | `kpm/{id}.c`, `kpm/Makefile` | APatch/KernelPatch template -- **not compiled, not loaded** | | `lkm/{id}.c` | out-of-tree module template -- **not compiled, not loaded** | | `ebpf/syscall_mask.bpf.c` | eBPF probe template -- **not compiled, not attached** | ## Gate table -- check your device before choosing a route | Route | Gate | How to check | Why | |---|---|---|---| {gates} ## Reference device, measured ``` $ adb shell 'uname -r; cat /proc/version' 4.14.186+ Linux version 4.14.186+ (nobody@android-build) (Android (6443078 based on r383902) clang version 11.0.1 ...) #1 SMP PREEMPT Wed Mar 30 23:32:42 CST 2022 ``` On that device **every kernel-side route above is closed**: - eBPF: kernel 4.14 is well below the 5.10 gate. - KPM: no KernelPatch-patched image, and no `aarch64-none-elf-gcc` on the host; installing APatch means patching the boot image, which is a bricking risk. - LKM: no kernel source for the device, so no matching vermagic is possible. That is a **route decision, not a failure** -- and it is why every kernel-side file here is labelled a template. Nothing in this directory was loaded on anything. ## If you do take a kernel route Do it once, deliberately, and read the evidence first: 1. Confirm the gate in the table above on the actual device. 2. Build the kernel side on a machine that has the right toolchain. 3. **Back up the boot image and know the recovery path before loading anything.** A kernel module that faults takes the device down before you can read why. 4. Load it by hand, watch `dmesg`, and keep the previous boot image in hand. 5. Only then wire it into boot, and only if the load is repeatable. `post-fs-data.sh` in this scaffold deliberately does **not** load anything. That is not an omission. """ def _render(template, **kw): """Fill a template's {placeholders} without str.format(). Braces are the common case in C, so using format() here would mean doubling every one of them -- and the doubled-brace convention fails silently in the other direction the moment somebody next edits the C by hand. `{{` is unescaped and only the named placeholders are substituted, so ordinary C braces pass through untouched. """ text = template.replace("{{", "{").replace("}}", "}") for key, value in kw.items(): text = text.replace("{%s}" % key, str(value)) return text def _rule_table_c(rules): """Render the JSON rule set as a C table.""" errno_map = {"ENOENT": 2, "EACCES": 13, "EINVAL": 22, "EPERM": 1} action_map = {"deny": "MASK_DENY", "fake_size": "MASK_FAKE_SIZE", "filter_line": "MASK_FILTER_LINE"} lines = [] for rule in rules: path = rule.get("path") prefix = rule.get("prefix") action = action_map.get(rule.get("action"), "MASK_DENY") err = errno_map.get(rule.get("errno", "ENOENT"), 2) value = rule.get("size", 0) lines.append(' { %s, %s, %s, -%d, %d },' % ('"%s"' % path if path else "0", '"%s"' % prefix if prefix else "0", action, err, value)) return "\n".join(lines) if lines else " /* no rules configured */" def cmd_gates(args): """Report the kernel-route gate table, and probe this host where it can be probed.""" print("== kernel-side routes and their gates ==\n") for g in GATES: print(" %-28s %s" % (g["route"], g["gate"])) print(" %-28s why: %s" % ("", g["why"])) print(" %-28s check: %s" % ("", g["check"])) print() print("== host probe (what can be checked from here) ==\n") cc = None import shutil for cand in ("aarch64-none-elf-gcc", "aarch64-linux-gnu-gcc", "clang"): found = shutil.which(cand) if found: cc = (cand, found) break if cc: print(" bare-metal ARM64-ish compiler: %s -> %s" % cc) if cc[0] == "clang": print(" note: clang can target aarch64-none-elf, so this is " "potentially usable for the KPM route") else: print(" bare-metal ARM64 compiler: NOT FOUND") print(" the KPM route cannot be built from this host as configured") for name in ("ndk-build", "make"): print(" %-12s %s" % (name + ":", shutil.which(name) or "NOT FOUND")) print("\n A kernel source tree, a patched boot image and the device's own " "kernel headers cannot be checked from the host; they are device facts.") print(" Run the `check` command for each route on the device before " "believing any of this applies to it.") return 0 def cmd_generate(args): out = args.out rules = DEFAULT_RULES if args.rules: with open(args.rules, encoding="utf-8") as fh: rules = json.load(fh) desc = args.description os.makedirs(os.path.join(out, "config"), exist_ok=True) written = [] def put(rel, text, mode=None): path = os.path.join(out, rel) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8", newline="\n") as fh: fh.write(text) if mode is not None: try: os.chmod(path, mode) except OSError: pass written.append(rel) put("module.prop", _render(MODULE_PROP, id=args.id, name=args.name, version=args.version, description=desc)) put("customize.sh", CUSTOMIZE, 0o755) put("post-fs-data.sh", _render(POST_FS_DATA, id=args.id), 0o755) put("service.sh", _render(SERVICE, id=args.id), 0o755) put("uninstall.sh", _render(UNINSTALL, id=args.id), 0o755) put("config/syscall_mask.json", json.dumps( {"version": 1, "id": args.id, "package": args.package, "enabled": True, "note": "read by the kernel side, not by the shell " "scripts -- see the module README", "rules": rules}, indent=2) + "\n") want = set(args.targets.split(",")) if "kpm" in want: body = _render(KPM_C, id=args.id, version=args.version, description=desc, RULE_TABLE=_rule_table_c(rules)) put("kpm/%s.c" % args.id, body) put("kpm/Makefile", _render(KPM_MAKEFILE, id=args.id)) if "lkm" in want: put("lkm/%s.c" % args.id, _render(LKM_C, id=args.id, description=desc)) if "ebpf" in want: put("ebpf/syscall_mask.bpf.c", EBPF_C) gate_rows = "\n".join("| %s | %s | `%s` | %s |" % (g["route"], g["gate"], g["check"], g["why"]) for g in GATES) put("README.md", _render(MODULE_README, name=args.name, gates=gate_rows, id=args.id)) print("generated module scaffold in %s" % out) for rel in written: size = os.path.getsize(os.path.join(out, rel)) print(" %-28s %7d B" % (rel, size)) print() print("STATUS: the userspace module is installable; every kernel-side file is " "an UNVERIFIED template.") print(" No kernel code was compiled or loaded. Run `gates` and read the " "device README") print(" section before assuming any route is open on your target.") return 0 def _strip_c_comments(text): """Remove /* */ and // comments before any token-level check. Not cosmetic: the first version of this check reported a false positive because the template's own comment explains why `extern` is forbidden, and a naive `extern.*kfunc_def` regex happily matched the prose and then ran on to the real declaration's semicolon. A linter that reads comments as code produces exactly the kind of report nobody can trust. """ text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) return re.sub(r"//[^\n]*", "", text) def cmd_verify(args): """Read-only checks over a generated directory. Everything here is local.""" d = args.dir problems = [] print("== verifying %s ==" % d) prop = os.path.join(d, "module.prop") if not os.path.isfile(prop): problems.append("module.prop missing") else: fields = {} with open(prop, encoding="utf-8") as fh: for line in fh: if "=" in line: k, v = line.rstrip("\n").split("=", 1) fields[k] = v for req in ("id", "name", "version", "versionCode"): if req not in fields: problems.append("module.prop missing required field %r" % req) if "id" in fields and not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9._-]*", fields["id"]): problems.append("module.prop id %r is not a valid module id" % fields["id"]) print(" module.prop: %s" % ", ".join("%s=%s" % kv for kv in sorted(fields.items()))) conf = os.path.join(d, "config", "syscall_mask.json") if not os.path.isfile(conf): problems.append("config/syscall_mask.json missing") else: try: with open(conf, encoding="utf-8") as fh: doc = json.load(fh) rules = doc.get("rules", []) known = {"deny", "fake_size", "filter_line"} for i, r in enumerate(rules): if r.get("action") not in known: problems.append("rule %d has unknown action %r" % (i, r.get("action"))) if not r.get("syscall"): problems.append("rule %d names no syscall" % i) print(" config: %d rule(s), actions=%s" % (len(rules), sorted({r.get("action") for r in rules}))) except (ValueError, OSError) as exc: problems.append("config/syscall_mask.json unreadable: %s" % exc) for rel, want_exec in (("post-fs-data.sh", True), ("service.sh", True), ("customize.sh", True), ("uninstall.sh", True)): p = os.path.join(d, rel) if not os.path.isfile(p): problems.append("%s missing" % rel) continue if want_exec and not os.access(p, os.X_OK): # Not fatal on a filesystem without an exec bit; the installer sets it. print(" note: %s is not executable here (the installer chmods it)" % rel) for rel in ("kpm", "lkm", "ebpf"): p = os.path.join(d, rel) if os.path.isdir(p): print(" %-4s target present: %s" % (rel, ", ".join(sorted(os.listdir(p))))) print(" status: UNVERIFIED TEMPLATE -- not compiled, not loaded") # The one static check worth doing on the C: the KPM interface has a # documented trap (an extern kfunc declaration compiles to *UND* and the # loader refuses the module), so catch it here rather than on a device. kpm_dir = os.path.join(d, "kpm") if os.path.isdir(kpm_dir): for name in sorted(os.listdir(kpm_dir)): if not name.endswith(".c"): continue with open(os.path.join(kpm_dir, name), encoding="utf-8") as fh: text = fh.read() code = _strip_c_comments(text) if re.search(r"\bextern\b[^;]*\bkfunc_def\b", code): problems.append("%s declares kfunc_def with `extern`, which the " "loader rejects as an unknown symbol" % name) for macro in ("KPM_NAME", "KPM_INIT", "KPM_EXIT"): if macro not in code: problems.append("%s is missing %s" % (name, macro)) print(" kpm/%s: KPM_* lifecycle macros present, kfunc_def not extern" % name) print() if problems: print("== %d problem(s) ==" % len(problems)) for p in problems: print(" - %s" % p) return 1 print("== 0 problem(s): structure is consistent ==") print(" (this says nothing about whether the kernel side compiles or loads)") return 0 def main(argv=None): parser = argparse.ArgumentParser( prog="kernelsu_syscall_mask.py", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = parser.add_subparsers(dest="cmd") p = sub.add_parser("gates", help="print the kernel-route gate table and probe this host", description="The gate table, plus whatever part of it can be " "checked from this machine. Device facts still have " "to be checked on the device.") p.set_defaults(func=cmd_gates) p = sub.add_parser("generate", help="write the module scaffold", description="Generate a KernelSU/APatch-compatible module " "directory. The userspace half is installable; the " "kernel half is a template.") p.add_argument("--out", required=True, help="directory to write into") p.add_argument("--id", default="syscall-mask", help="module id / artifact name") p.add_argument("--name", default="Syscall Mask", help="human-readable module name") p.add_argument("--version", default="0.1.0", help="module version string") p.add_argument("--description", default="config-driven syscall return masking " "(openat/read/stat on /proc/self/*)", help="module description") p.add_argument("--package", default="", help="target package placeholder") p.add_argument("--rules", default=None, help="JSON file with a {rules:[...]} rule set (default: built-in)") p.add_argument("--targets", default="kpm,lkm,ebpf", help="comma-separated kernel targets to emit (kpm,lkm,ebpf)") p.set_defaults(func=cmd_generate) p = sub.add_parser("verify", help="read-only consistency checks over a generated dir", description="Check module.prop fields, the rule JSON, and the " "static traps in the kernel template. Says nothing " "about compilation or loading.") p.add_argument("--dir", required=True, help="a directory from `generate`") p.set_defaults(func=cmd_verify) args = parser.parse_args(argv) if not getattr(args, "cmd", None): parser.print_help() return 2 return args.func(args) if __name__ == "__main__": sys.exit(main())