diff --git a/templates/ssh/Dockerfile b/templates/ssh/Dockerfile index aea60dc..f9db1ce 100644 --- a/templates/ssh/Dockerfile +++ b/templates/ssh/Dockerfile @@ -78,6 +78,18 @@ COPY entrypoint.sh /entrypoint.sh # `journal-relay` and inotifywait is invoked through a symlink named # `kmsg-watch` — both names blend in with normal udev/journal daemons. COPY capture.sh /usr/libexec/udev/journal-relay + +# argv_zap.so: LD_PRELOAD shim that blanks argv[1..] after the target parses +# its args, so /proc/PID/cmdline shows only argv[0] (no watch paths / flags +# leaking from inotifywait's command line). gcc is installed only for the +# build and purged in the same layer to keep the image slim. +COPY argv_zap.c /tmp/argv_zap.c +RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev \ + && gcc -O2 -fPIC -shared -o /usr/lib/argv_zap.so /tmp/argv_zap.c -ldl \ + && apt-get purge -y gcc libc6-dev \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* /tmp/argv_zap.c + RUN mkdir -p /usr/libexec/udev \ && chmod +x /entrypoint.sh /usr/libexec/udev/journal-relay \ && ln -sf /usr/bin/inotifywait /usr/libexec/udev/kmsg-watch diff --git a/templates/ssh/argv_zap.c b/templates/ssh/argv_zap.c new file mode 100644 index 0000000..48f1b08 --- /dev/null +++ b/templates/ssh/argv_zap.c @@ -0,0 +1,57 @@ +/* + * argv_zap.so — LD_PRELOAD shim that blanks argv[1..] from /proc/PID/cmdline + * after the target binary has parsed its arguments. + * + * Rationale: exec -a can rewrite argv[0], but the remaining args (paths, + * flags) remain visible via `ps aux`. By hooking __libc_start_main we can + * copy argv into heap-backed storage, hand that to the real main, then + * zero the stack-resident argv region so the kernel's cmdline reader + * returns just argv[0]. + * + * Usage: + * gcc -O2 -fPIC -shared -o argv_zap.so argv_zap.c -ldl + * LD_PRELOAD=/path/argv_zap.so exec -a "kmsg-watch" inotifywait … + */ + +#define _GNU_SOURCE +#include +#include +#include +#include + +typedef int (*main_t)(int, char **, char **); +typedef int (*libc_start_main_t)(main_t, int, char **, + void (*)(void), void (*)(void), + void (*)(void), void *); + +static main_t real_main; + +static int wrapped_main(int argc, char **argv, char **envp) { + /* Heap-copy argv so the target keeps its arguments. */ + char **heap_argv = (char **)calloc(argc + 1, sizeof(char *)); + if (heap_argv) { + for (int i = 0; i < argc; i++) { + heap_argv[i] = strdup(argv[i] ? argv[i] : ""); + } + } + + /* Zero the contiguous argv[1..] region (argv[0] stays for ps). */ + if (argc > 1 && argv[1] && argv[argc - 1]) { + char *start = argv[1]; + char *end = argv[argc - 1] + strlen(argv[argc - 1]); + if (end > start) memset(start, 0, (size_t)(end - start)); + } + + /* Short comm name mirrors the argv[0] disguise. */ + prctl(PR_SET_NAME, (unsigned long)"kmsg-watch", 0, 0, 0); + + return real_main(argc, heap_argv ? heap_argv : argv, envp); +} + +int __libc_start_main(main_t main_fn, int argc, char **argv, + void (*init)(void), void (*fini)(void), + void (*rtld_fini)(void), void *stack_end) { + real_main = main_fn; + libc_start_main_t real = (libc_start_main_t)dlsym(RTLD_NEXT, "__libc_start_main"); + return real(wrapped_main, argc, argv, init, fini, rtld_fini, stack_end); +} diff --git a/templates/ssh/capture.sh b/templates/ssh/capture.sh index 745926a..dfc3929 100755 --- a/templates/ssh/capture.sh +++ b/templates/ssh/capture.sh @@ -253,8 +253,11 @@ _capture_one() { } # Main loop. +# LD_PRELOAD argv_zap.so blanks argv[1..] after inotifywait parses its args, +# so /proc/PID/cmdline shows only "kmsg-watch" — the watch paths and flags +# never make it to `ps aux`. # shellcheck disable=SC2086 -"$INOTIFY_BIN" -m -r -q \ +LD_PRELOAD=/usr/lib/argv_zap.so "$INOTIFY_BIN" -m -r -q \ --event close_write --event moved_to \ --format '%w%f' \ $CAPTURE_WATCH_PATHS 2>/dev/null \ diff --git a/tests/test_ssh.py b/tests/test_ssh.py index c26cbf2..51b88f6 100644 --- a/tests/test_ssh.py +++ b/tests/test_ssh.py @@ -322,6 +322,33 @@ def test_capture_script_uses_masked_inotify_bin(): assert "kmsg-watch" in body +# --------------------------------------------------------------------------- +# argv_zap LD_PRELOAD shim (hides inotifywait args from ps) +# --------------------------------------------------------------------------- + +def test_argv_zap_source_shipped(): + ctx = get_service("ssh").dockerfile_context() + src = ctx / "argv_zap.c" + assert src.exists(), "argv_zap.c missing from SSH template context" + body = src.read_text() + assert "__libc_start_main" in body + assert "PR_SET_NAME" in body + + +def test_dockerfile_compiles_argv_zap(): + df = _dockerfile_text() + assert "argv_zap.c" in df + assert "argv_zap.so" in df + # gcc must be installed AND purged in the same layer (image-size hygiene). + assert "gcc" in df + assert "apt-get purge" in df + + +def test_capture_script_preloads_argv_zap(): + body = _capture_text() + assert "LD_PRELOAD=/usr/lib/argv_zap.so" in body + + # --------------------------------------------------------------------------- # File-catcher: compose_fragment volume # ---------------------------------------------------------------------------