//go:build linux package platform import ( "encoding/json" "errors" "fmt" "io" "net" "os" "os/exec" "os/signal" "runtime" "syscall" "time" "unsafe" "github.com/landlock-lsm/go-landlock/landlock" llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall" "github.com/safedep/dry/log" "golang.org/x/sys/unix" ) // RunLandlockHelper is the entry point for the __landlock_sandbox_exec helper // process. policyFile is the path to the policy JSON temp file. auditSocket is // the path to the audit unix socket. cmdArgs are the target command args // (everything after "--"). // // Architecture — why the helper spawns a shim in a user namespace: // // We want to install seccomp-notify AND keep /proc//mem readable for // descendants, so the supervisor can resolve openat path arguments throughout // the process tree. Unprivileged seccomp install requires PR_SET_NO_NEW_PRIVS // — but NNP + execve resets the target's dumpable flag to 0, which blocks // /proc//mem opens for anyone without CAP_SYS_PTRACE. This defeats // deny-rule enforcement on grandchildren (bash -> npm -> node). // // Fix: fork the target through a thin shim with CLONE_NEWUSER + uid map // 0->host. The shim boots as uid 0 inside the new user namespace (so // CAP_SYS_ADMIN in that ns) and installs seccomp WITHOUT NNP, which means // descendants keep dumpable=1 and the helper can read their memory. The // shim then execve's the real target with the filter inherited. To the real // target, this is indistinguishable from running directly — same uid, same // filesystem, same environment. The user namespace is only a capability // vehicle. func RunLandlockHelper(policyFile, auditSocket string, cmdArgs []string) error { // The shim will re-open this file from disk; we keep it alive until the // shim has loaded it. policy, err := readLandlockPolicyFromFile(policyFile) if err != nil { return fmt.Errorf("read policy from file: %w", err) } defer func() { if os.Getenv("PMG_KEEP_POLICY") == "" { _ = os.Remove(policyFile) } }() log.InitZapLogger("pmg", "landlock-helper") auditWriter := io.Writer(io.Discard) conn, err := net.Dial("unix", auditSocket) if err == nil { defer func() { if cerr := conn.Close(); cerr != nil { log.Warnf("close audit socket: %v", cerr) } }() auditWriter = conn } else { log.Debugf("Failed to connect to audit socket %s: %v", auditSocket, err) } if len(cmdArgs) > 0 { policy.Command = cmdArgs[0] if len(cmdArgs) > 1 { policy.Args = cmdArgs[1:] } else { policy.Args = nil } } // Die if parent exits. if err := unix.Prctl(unix.PR_SET_PDEATHSIG, uintptr(unix.SIGKILL), 0, 0, 0); err != nil { return fmt.Errorf("prctl PR_SET_PDEATHSIG: %w", err) } // Socketpair: the shim sends its seccomp notify fd back to the helper // over its end (passed via ExtraFiles). sockPair, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0) if err != nil { return fmt.Errorf("socketpair: %w", err) } helperSockFile := os.NewFile(uintptr(sockPair[0]), "shim-notify-helper") shimSockFile := os.NewFile(uintptr(sockPair[1]), "shim-notify-shim") defer func() { if err := helperSockFile.Close(); err != nil { log.Warnf("close helper socket: %v", err) } }() // ExtraFiles[0] becomes fd=3 inside the shim. Go's exec.Cmd writes // uid_map/gid_map automatically when UidMappings/GidMappings are set. selfExe, err := os.Executable() if err != nil { return fmt.Errorf("resolve self exe: %w", err) } shimArgs := []string{ selfExe, "__landlock_shim", "--policy-file", policyFile, "--notify-socket-fd", "3", "--", policy.Command, } shimArgs = append(shimArgs, policy.Args...) cmd := exec.Command(selfExe, shimArgs[1:]...) cmd.Path = selfExe cmd.Args = shimArgs if len(policy.Env) > 0 { cmd.Env = policy.Env } else { cmd.Env = os.Environ() } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.ExtraFiles = []*os.File{shimSockFile} // CLONE_NEWUSER is the whole point — see function-level comment. We map // host uid/gid to 0 in the ns so the shim has CAP_SYS_ADMIN to install // seccomp without NNP. Identity mapping would leave us as unprivileged // uid inside the ns and we'd have to re-acquire caps via ambient, which // is not trivial in a Go runtime. uid := os.Getuid() gid := os.Getgid() cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUSER, UidMappings: []syscall.SysProcIDMap{ {ContainerID: 0, HostID: uid, Size: 1}, }, GidMappings: []syscall.SysProcIDMap{ {ContainerID: 0, HostID: gid, Size: 1}, }, GidMappingsEnableSetgroups: false, } extraCloneFlags := landlockBuildCloneflags(policy) if extraCloneFlags != 0 { cmd.SysProcAttr.Cloneflags |= extraCloneFlags } // Retry without extra-ns flags if clone fails (kernel/seccomp policy // may forbid PID/IPC namespaces in restricted environments). if err := cmd.Start(); err != nil { var pathErr *os.PathError if errors.As(err, &pathErr) && (errors.Is(pathErr.Err, unix.EPERM) || errors.Is(pathErr.Err, unix.EINVAL)) && extraCloneFlags != 0 { _ = landlockWriteAuditEvent(auditWriter, auditEvent{ Type: auditNamespaceUnavailable, Message: fmt.Sprintf("namespace clone failed (%v), retrying without PID/IPC ns", err), Ts: time.Now().UnixNano(), }) fmt.Fprintf(os.Stderr, "pmg: warning: PID/IPC namespace unavailable (%v), continuing without\n", err) cmd.SysProcAttr.Cloneflags &^= extraCloneFlags if err := cmd.Start(); err != nil { _ = shimSockFile.Close() return fmt.Errorf("start shim (retry): %w", err) } } else { _ = shimSockFile.Close() return fmt.Errorf("start shim: %w", err) } } _ = shimSockFile.Close() // child has its own copy childPID := cmd.Process.Pid notifyFd, err := receiveNotifyFd(int(helperSockFile.Fd())) if err != nil { _ = cmd.Process.Signal(unix.SIGKILL) _ = cmd.Wait() return fmt.Errorf("receive notify fd from shim: %w", err) } supervisor, err := newLandlockSupervisorFromFd(notifyFd) if err != nil { _ = cmd.Process.Signal(unix.SIGKILL) _ = cmd.Wait() _ = unix.Close(notifyFd) return fmt.Errorf("create supervisor: %w", err) } // dumpable=1 is preserved across the shim tree (no NNP), so this open // succeeds for grandchildren too via memFdFor. memFd, err := openLandlockChildMemFd(childPID) if err != nil { _ = cmd.Process.Signal(unix.SIGKILL) _ = cmd.Wait() _ = supervisor.Stop() _ = landlockWriteAuditEvent(auditWriter, auditEvent{ Type: auditMemFdOpenFailed, PID: childPID, Error: err.Error(), Message: "failed to open /proc//mem, killing child (fail-close)", Ts: time.Now().UnixNano(), }) return fmt.Errorf("open /proc/%d/mem (fail-close): %w", childPID, err) } if err := supervisor.Enforce(childPID, memFd, policy.DenyPaths, policy.DenyExecPaths, auditWriter); err != nil { _ = cmd.Process.Signal(unix.SIGKILL) _ = cmd.Wait() if cerr := memFd.Close(); cerr != nil { log.Warnf("close /proc/%d/mem: %v", childPID, cerr) } _ = supervisor.Stop() return fmt.Errorf("enforce seccomp rules: %w", err) } sigCh := make(chan os.Signal, 3) signal.Notify(sigCh, unix.SIGINT, unix.SIGTERM, unix.SIGQUIT) go func() { for sig := range sigCh { _ = cmd.Process.Signal(sig) } }() waitErr := cmd.Wait() _ = supervisor.Stop() signal.Stop(sigCh) close(sigCh) if err := memFd.Close(); err != nil { log.Warnf("close /proc/%d/mem: %v", childPID, err) } exitCode := 0 if waitErr != nil { var exitErr *exec.ExitError if errors.As(waitErr, &exitErr) { exitCode = exitErr.ExitCode() } else { exitCode = 1 } } os.Exit(exitCode) return nil // unreachable } // receiveNotifyFd reads a single SCM_RIGHTS-packed fd from the socketpair. // The shim writes it right after installing the seccomp filter. Returns the // fd as seen by the helper (kernel re-numbered at recvmsg time). func receiveNotifyFd(sockFd int) (int, error) { buf := make([]byte, 1) oob := make([]byte, unix.CmsgSpace(4)) iov := unix.Iovec{Base: &buf[0], Len: 1} msg := unix.Msghdr{Iov: &iov, Iovlen: 1, Control: &oob[0]} msg.SetControllen(len(oob)) runtime.LockOSThread() defer runtime.UnlockOSThread() for { _, _, errno := unix.Syscall( unix.SYS_RECVMSG, uintptr(sockFd), uintptr(unsafe.Pointer(&msg)), 0, ) runtime.KeepAlive(&buf) runtime.KeepAlive(&oob) runtime.KeepAlive(&iov) runtime.KeepAlive(&msg) if errno == unix.EINTR { continue } if errno != 0 { return -1, fmt.Errorf("recvmsg: %w", errno) } break } cmsgs, err := unix.ParseSocketControlMessage(oob[:msg.Controllen]) if err != nil { return -1, fmt.Errorf("parse cmsg: %w", err) } if len(cmsgs) == 0 { return -1, fmt.Errorf("no SCM_RIGHTS cmsg received (shim likely failed before send)") } fds, err := unix.ParseUnixRights(&cmsgs[0]) if err != nil { return -1, fmt.Errorf("parse unix rights: %w", err) } if len(fds) == 0 { return -1, fmt.Errorf("no fds in cmsg") } return fds[0], nil } // readLandlockPolicyFromFile reads and deserializes a landlockExecPolicy from // a file path. func readLandlockPolicyFromFile(path string) (*landlockExecPolicy, error) { if path == "" { return nil, fmt.Errorf("policy file path is empty") } f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("open policy file: %w", err) } defer func() { if err := f.Close(); err != nil { log.Warnf("close policy file %s: %v", path, err) } }() return readLandlockPolicyFromReader(f) } // readLandlockPolicyFromReader reads and deserializes a landlockExecPolicy // from an io.Reader. func readLandlockPolicyFromReader(r io.Reader) (*landlockExecPolicy, error) { var policy landlockExecPolicy if err := json.NewDecoder(r).Decode(&policy); err != nil { return nil, fmt.Errorf("decode policy JSON: %w", err) } if policy.Command == "" { return nil, fmt.Errorf("policy has empty command") } return &policy, nil } // landlockBuildCloneflags builds extra clone flags for the shim process based // on the policy. The CLONE_NEWUSER flag itself is always added by the caller; // this function returns ONLY the optional PID/IPC/MNT namespace flags. func landlockBuildCloneflags(policy *landlockExecPolicy) uintptr { var flags uintptr if !policy.SkipPIDNamespace { flags |= unix.CLONE_NEWPID | unix.CLONE_NEWNS } if !policy.SkipIPCNamespace { flags |= unix.CLONE_NEWIPC } return flags } // openLandlockChildMemFd opens /proc//mem for reading the child's memory. func openLandlockChildMemFd(pid int) (*os.File, error) { path := fmt.Sprintf("/proc/%d/mem", pid) f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("open %s: %w", path, err) } return f, nil } // landlockSelectConfig picks the Landlock go library's Config (and thus // ABI target) based on the highest access flag used in the policy. Kept // in the helper file to avoid import cycles; consumed by the shim. func landlockSelectConfig(policy *landlockExecPolicy) landlock.Config { var hasRefer, hasTruncate, hasIoctlDev bool for _, r := range policy.FilesystemRules { if r.Access&uint64(llsyscall.AccessFSRefer) != 0 { hasRefer = true } if r.Access&uint64(llsyscall.AccessFSTruncate) != 0 { hasTruncate = true } if r.Access&uint64(llsyscall.AccessFSIoctlDev) != 0 { hasIoctlDev = true } } switch { case hasIoctlDev: return landlock.V5 case hasTruncate: return landlock.V3 case hasRefer: return landlock.V2 default: return landlock.V1 } }