DEV Community

NTCTech
NTCTech

Posted on Originally published at rack2cloud.com

Setuid Replacement Moves the Privilege Decision. It Doesn't Remove It.

A setuid replacement does not remove the decision about who may act as root. It moves that decision from a file mode bit to a service and a policy, and whoever runs the system inherits the job of writing that policy down.

On October 1, 2026, Thorsten Kukuk presented "Making NoNewPrivs the default" at All Systems Go!, a status report on running a Linux distribution with the kernel's no_new_privs flag set for every process. The talk page supports the event, the title and the abstract. The implementation details in this post come from his separate write-up, which documents a working proof of concept on openSUSE MicroOS and openSUSE Tumbleweed. It is opt-in, it is one engineer's report, and it is not a product commitment. It is still a useful map of what depends on setuid, because removing the mechanism forces every dependency to name itself.

This post uses that map for one purpose: the operator decision inside any setuid replacement. For each way a user becomes root, which component decides now, what can it see, and what happens to the workloads that assumed the old path? Container hosts sharpen the question, because every container on the host shares its exec rules.

Setuid replacement: authority moves from a file mode bit to a socket-activated service and a polkit policy

What One Inherited Bit Changes

The kernel defines the semantics, and they are narrow. With no_new_privs set, execve() cannot raise a process's privileges: setuid and setgid bits no longer change the uid or gid, file capabilities do not add to the permitted set, and LSMs do not relax constraints at exec. The flag is inherited across fork, clone and execve and cannot be unset. It does not stop privilege changes that happen without an exec, so a sufficiently privileged task can still call setuid(2).

The distribution's part is separate. In Kukuk's proof of concept the system manager sets the flag on PID 1 through systemd's NoNewPrivs option. An enable-no_new_privs package installs the drop-in, and a reboot is needed. The polkit, pwaccessd, pwupdd and newidmapd socket units must also be enabled and running. Every process the manager starts inherits the flag, including container processes. Setuid and setgid bits can no longer change credentials on exec, and file capabilities cannot add privileges through exec either. Kukuk reports compatibility failures for workloads that depend on those mechanisms; Rack2Cloud has not tested them.

Containers share the host kernel, which makes exec-time privilege rules a virtualization architecture concern and not only a distribution one. Our earlier coverage treats the flag at workload scope. The Seccomp vs AppArmor checklist tells platform teams to set allowPrivilegeEscalation: false per pod, and the checkpoint restore analysis treated no_new_privs as one attribute a restored process can carry in from a saved image. This post asks a different question: what happens when the flag is the host default, and what a setuid replacement has to cover once the binaries it disables are gone.

⚠ Evidence boundary: This is an opt-in proof of concept on openSUSE MicroOS and Tumbleweed, enabled by a package and a reboot. The replacement and compatibility details are the author's reports. Rack2Cloud did not test them. Nothing here is a SUSE roadmap, an SLES commitment or a distribution-wide rollout.

Where Privilege Goes When the Bit Is Gone

Kukuk's design moves each privileged function out of a file mode and into a root-run, socket-activated systemd service that clients reach over varlink. The services can be sandboxed with systemd unit options. His example is chage -l, which needs to read /etc/shadow. Instead of a setuid binary reading the file, a service called pwaccessd returns only the calling user's own shadow entry.

His stated reasons are that setuid binaries are a privilege-escalation target when they contain bugs, and that on image-based distributions files under /usr could be owned by root:root, which removes UID and GID drift once setuid and setgid ownership goes.

That is the architectural change behind a setuid replacement in one sentence: the authority used to travel with the binary, and it now lives with a service that answers requests. A setuid binary grants authority to whoever can execute it, and the binary's own code decides how much to give. In the chage -l example, the service returns only the calling user's own shadow entry, according to Kukuk. The unit can also be sandboxed with systemd options. Those are properties of this design, not proof that every replacement service identifies callers correctly or enforces an appropriate policy. Moving authority into a service creates an opportunity to define the boundary more explicitly; it does not show that the boundary is correct, and the rest of this post keeps the two apart.

The Setuid Replacement Table, Function by Function

Each row of this setuid replacement table comes from the source. Markers show where: [K] is Kukuk's write-up, so author-reported; [R] is the systemd 262 run0 manual. Where the source stops, the cell says so. Nothing in the table is tested by Rack2Cloud.

Function Existing mechanism Replacement path Authority semantics that change Container behavior
Authentication unix_chkpwd, the setuid helper behind pam_unix.so pam_unix.so fetches hashes through the pwaccessd service but cannot change passwords. pam_unix_ng.so (account-utils) has full pwaccessd support. unix_chkpwd itself: "no plans to adjust" [K] Hash lookup moves from a helper binary to a service call. Policy the service applies beyond that: not stated by source pam_unix_ng.so includes a container fallback [K]. Otherwise not stated by source
Account management chage, chfn, chsh, expiry, passwd (shadow utilities) Replaced by account-utils frontends that call pwaccessd and pwupdd over varlink [K] Reads return only the calling user's record, as in the chage -l example [K]. How pwupdd authorizes changes: not stated by source Not stated by source
UID/GID mapping newuidmap, newgidmap Replaced; the newidmapd socket unit must be running [K] Not stated by source Rootless podman works [K]. rootlesskit is listed as not working because its re-exec drops privileges again [K]. Unresolved
Polkit authentication helper polkit-agent-helper-1 Socket-activated service: systemctl enable --now polkit-agent-helper.socket; needs polkit 127 or later [K] Not stated by source Not stated by source
Elevation su, sudo, pkexec (setuid-based); newgrp run0 with polkit and no setuid [R]. Wrappers run0-su, run0-sudo, run0-pkexec; run0 --group for newgrp. The originals: "no plans to adjust" [K] Not equivalent. The command starts in a new service with no credentials inherited from the caller, and polkit authorizes it [R]. The author reports that per-command sudo-style rules cannot be matched because polkit is not given the command line, and that scripts relying on them are not covered [K] Not stated by source. distrobox and toolbox users who need su or sudo inside a container use podman exec as root from the host [K]. Unresolved
mount, umount mount, umount Author reports they work without setuid in all relevant use cases [K] Not stated by source Not stated by source
FUSE fusermount3 defused, using systemd sockets and varlink [K] A setuid defused would undermine authentication; the author calls that dangerous [K] AppImage refuses to run a FUSE helper that lacks a setuid bit [K]. Unresolved
Mail submission Postfix setgid maildrop helpers: postdrop, postlog, postqueue postdrop: reachable through a systemd socket, minimal proof-of-concept patch. postlog: unnecessary, logging goes to journald. postqueue: needs socket access; moving ACL checks into the daemons would remove the need [K] Not stated by source Not stated by source. postqueue unresolved
Network diagnostics ping Not covered by the source Not stated by source Not stated by source

Read the gaps as findings. The source lists su, sudo, pkexec, newgrp and unix_chkpwd as "no plans to adjust", rootlesskit as not working, AppImage as refusing the FUSE helper, and ping as not covered, and Postfix's postqueue is unresolved. A setuid replacement map that hid those rows would read as a finished migration, and it is not one. "Not stated by source" in a cell means the source says nothing, not that nothing changes.

Sudo, Run0 and Polkit: Who Authorizes the Request

In any setuid replacement the elevation row carries the most weight, because it is where an estate's authorization policy actually lives. Three claims are in play here, and they have three different standings.

What the run0 documentation establishes

run0 is the elevation half of this setuid replacement. According to the systemd 262 manual, it starts the command in a fresh service forked by the service manager, and no execution or security context credentials are inherited from the caller. Authentication goes through polkit, which keeps the prompt separate from the terminal where possible. The command gets an independent pseudo-terminal. The implementation uses no setuid or setgid file bits, which is why it still works where NoNewPrivileges= is in force. run0 also sets SUDO_USER, SUDO_UID and SUDO_GID to the originating user's values, and shells always get login-shell semantics.

Traditional sudo deployments commonly apply policy through sudoers rules, including rules scoped to particular commands. run0 instead starts the command in a fresh service through the service manager and uses polkit for authorization. The authorization component and the information available to its policy are therefore important migration questions; the two mechanisms should not be treated as equivalent.

What the author reports

Kukuk's proof of concept ships wrappers (run0-su, run0-sudo and run0-pkexec) that emulate the old commands on top of run0. He also documents a polkit rule: members of a _run0_nopasswd group need no password, members of wheel authenticate with their own password, and everyone else needs the root password. He reports that this matches the sudo-policy-auth-wheel-self configuration of openSUSE.

The limitation he reports is the one that matters: run0 does not give polkit the called program or command line, so sudo-style per-command rules cannot be matched. He also reports that scripts depending on sudo's per-command policy are not covered by the wrappers. These are one engineer's observations, and he lists the polkit gaps as open systemd issues.

What is not established

Systemd issue #39677, open when this post was prepared, describes a related authorization-boundary concern. Kukuk reports that run0 uses the org.freedesktop.systemd1.manage-units polkit action and says users object to the possibility that permission to manage units can also permit a root shell. He also says the action variables available to polkit do not let a rule reliably distinguish the use cases. The discussion explores possible distinctions but does not establish a general solution for fine-grained authorization.

That supports the concern about how authorization is expressed.

It does not establish what polkit receives in every execution path, how run0 elevations are recorded for audit, or whether command-level control can be rebuilt on top of it. We have not tested any of those and we do not claim them. Where your sudoers rules are command-scoped, the evidence of who was allowed to run what lives in the old policy. Whether a replacement produces equivalent evidence is a question to test, not an answer to assume.

Diagnostic: "For each way a user becomes root in your estate, which component decides now, and what does it see of the request?"

What Containers and Workloads Inherit

Containers are where a setuid replacement stops being an administrator's decision and becomes a workload's problem. The flag does not switch off every capability a container process already holds. It stops exec from gaining privileges through setuid and setgid bits or file capabilities, so workloads that depend on those paths are the ones at risk. By Kukuk's reports, rootless podman keeps working because newuidmap and newgidmap have replacements, and rootlesskit is listed as not working. Among the containers he examined, Pi-hole is the only one he found that needs setuid or capabilities; that is his finding, not a general conclusion about container compatibility. Users of distrobox and toolbox who need su or sudo inside a container have a workaround: run podman exec as root from the host.

No_new_privs inheritance from the host manager into a rootless container and a container that needs setuid

He frames the container question as either the biggest blocker to wider adoption or no problem at all, and he reports no noticeable desktop problems beyond run0 and polkit usability. More subsystems need testing. The talk abstract says the same in fewer words: many use cases already work without issues, and some do not.

A middle path has been proposed. Kukuk cites a uapi-group proposal, "Dynamic No New Privileges via bpf", that would supervise privilege-escalating exec through a BPF LSM instead of a static flag. We have not reviewed it, and it is a proposal, not a feature.

For platform teams this sits inside the container security stack. Per-pod controls choose the flag workload by workload. A host default chooses it for everything the manager starts, and the images have to cope.

A Dependency Assessment Before Any Default Changes

What follows are Rack2Cloud's recommendations. They are not test results, and nothing in the source or in this post validates them on your distribution, kernel or workloads. They turn the author's inventory into questions to answer before the flag becomes a default.

01 — AUTHENTICATION PATHS

List what authenticates through PAM on each host and image, and which modules it uses. The source requires the polkit, pwaccessd, pwupdd and newidmapd sockets to be enabled and running, so decide what you want to happen when one is not, and test it before the default changes.

02 — ACCOUNT MANAGEMENT

Find every script, agent and runbook step that calls passwd, chage, chfn, chsh or expiry. Run each against the replacement frontends and compare behavior and output. Do not assume they match.

03 — ADMINISTRATIVE COMMANDS

Export your sudoers rules and separate unscoped rules from command-scoped ones. For each command-scoped rule, record what it permits, what evidence it produces and who consumes that evidence. Then test what a run0 elevation records. The source does not establish that run0 or the wrappers reproduce command-level control.

04 — ROOTLESS CONTAINERS AND WORKLOADS

Inventory images and host paths for setuid and setgid binaries and file capabilities, for example with find / -xdev -perm /6000 and getcap -r /. These commands are starting points, not a complete inventory: -xdev excludes other filesystems. Scan relevant mounts, container images and workload filesystems separately, and run the checks with sufficient privileges to inspect the intended paths. List rootless runtimes, FUSE users, AppImage use and anything that needs su or sudo inside a container. Treat each as a test case.

05 — COMPATIBILITY TESTING

Test the flag on a non-production host before it is a default, by workload class, on your distribution and versions. Decide the rollback path before the reboot the author's package requires. Record your results as yours; the author's reports are hypotheses for your estate.

Architect's Verdict

A setuid replacement does not retire the privilege decision. It relocates it, and relocation is an improvement only if the new location decides on better information than the old one.

The problem most people miss is that setuid authority was implicit. It was a mode bit on a file, set at packaging time, with the binary's own code acting as the policy. A setuid replacement makes the policy explicit, in a service, in polkit rules, in a unit's sandboxing. Explicit is better only if someone writes it down, and the places where the source reports gaps, command-level rules, FUSE and rootless tooling, are the places where the old behavior was implicit and may never have been written down.

This setuid replacement is one engineer's opt-in proof of concept on two openSUSE variants. It shows what a migration surfaces, not what yours will. Treat its inventory as a test plan for your own estate, not as evidence that the estate is ready.

Deleting the mode bit deletes the shortcut. The decision it was taking for you is still yours.

Additional Resources


Originally published at rack2cloud.com

Top comments (0)