Add --user/--group support to -e/--exec, matching -r/--run

Previously -e/--exec always ran its command as the host's own invoking
credentials, ignoring whatever uid/gid the session's own -r/--run resolved
it to. Now it defaults to whatever the session's sandboxed command is
already running as (read from /proc/<pid>/status), and --user/--group can
override that, resolved against the session's own /etc/passwd/group
(fetched via nsenter, since the mount namespace isn't otherwise reachable).
Either way it's applied by reusing the already bind-mounted
slocker-lite-priv-drop helper from the original -r/--run, not a second copy.

Refactored resolve_user_and_group() (user_spec.{h,cpp}) to take passwd/group
*content* instead of a filesystem path, so both callers -- run_container()
(local file read) and exec_in_session() (nsenter + cat) -- can share it.
Exported priv_drop::path/helper_name and find_priv_drop_helper() from
bwrap.h so exec_session.cpp can reuse the same helper.

Caught and fixed a second bug during testing on a rootless dev machine:
under -r/--run's own --unshare-user, "root inside the container" is a uid
mapping, not a real privilege drop, so /proc/<pid>/status's uid/gid (read
from outside that namespace) is the host-mapped id, not the container's
own view -- defaulting to a priv-drop there was wrong and failed outright.
Fixed by skipping the default-identity lookup whenever --exec is already
joining a differing user namespace, since nsenter --preserve-credentials
alone already reproduces the container's view via that same kernel mapping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
2026-08-29 08:05:55 +00:00
parent 1c186b0365
commit 247e61d9d8
9 changed files with 293 additions and 71 deletions
+72 -15
View File
@@ -270,22 +270,34 @@ Source layout (all under `src/`):
working code). Usage: `slocker-lite-priv-drop <uid>:<gid> -- <command> [args...]`;
does `setgroups(0,…)``setgid()``setuid()``execvp()`, in that order
(dropping the group needs `CAP_SETGID`, which is lost once `setuid()` drops root).
`find_priv_drop_helper()` (`src/bwrap.cpp`) locates it next to `slocker-lite`'s own
binary (via `/proc/self/exe`'s directory), which holds both when run straight from
`buildDir/` and after a real `meson install`.
`find_priv_drop_helper()` and the `priv_drop::path`/`priv_drop::helper_name`
constants live in `bwrap.h` (not just internal to `bwrap.cpp`) specifically so
`exec_in_session()` (`exec_session.cpp`, see below) can reuse the exact same
already-bind-mounted helper for `-e/--exec`'s own `--user`/`--group` support,
instead of a second copy needing to be bind-mounted for it (which wouldn't even
be possible — `-e/--exec` joins an *already-running* session's mount namespace,
it doesn't get to add bind mounts to it). `find_priv_drop_helper()` itself only
checks this binary's own host-side existence; it says nothing about whether a
given session actually has it bind-mounted (only true when that session's
`-r/--run` resolved a user in the first place).
- `user_spec.{h,cpp}``resolve_user_and_group()` resolves a user/group spec (each
a name or numeric id) against the *mounted image's own* `/etc/passwd`/`/etc/group`
(not the host's), since names like `git` only mean anything inside that image's own
user database. A numeric user with no group and no matching `/etc/passwd` entry
defaults gid to the same numeric value as the uid. `ResolvedUser` (`bwrap.h`) also
carries `home`, looked up by the final resolved uid's `/etc/passwd` entry (field 5)
regardless of whether `user` was given as a name or a number; falls back to
`"/root"` for uid 0 or `"/"` otherwise when there's no matching row.
`build_sandbox_env()` (`bwrap.cpp`) sets the sandboxed process's `HOME` from
this — `"/root"` only when no user override applies at all (no `--user`, no
image-declared `config.User`). `run_container()` (`commands.cpp`) calls `resolve_user_and_group()`
with either the explicit `--user`/`--group` flags, or, when `--user` wasn't given,
the image's own declared `config.User` (`OciImageConfig::user`/`group`) — so a
a name or numeric id) against the *container's own* `/etc/passwd`/`/etc/group`
**content** (not the host's, and not a path — callers own reading it, since the
two current callers get that content two different ways: `run_container()`
reads it directly off the merged mount path, while `exec_in_session()` fetches
it over `nsenter`, since a running session's mount namespace isn't otherwise
reachable from this process — see below). `nullopt` content for either file
means "unreadable/absent"; a numeric user with no group still resolves fine
without it (defaults gid to the same numeric value as the uid) but a named one
doesn't. `ResolvedUser` (`bwrap.h`) also carries `home`, looked up by the final
resolved uid's `/etc/passwd` entry (field 5) regardless of whether `user` was
given as a name or a number; falls back to `"/root"` for uid 0 or `"/"`
otherwise when there's no matching row. `build_sandbox_env()` (`bwrap.cpp`)
sets the sandboxed process's `HOME` from this — `"/root"` only when no user
override applies at all (no `--user`, no image-declared `config.User`).
`run_container()` (`commands.cpp`) calls `resolve_user_and_group()` with either
the explicit `--user`/`--group` flags, or, when `--user` wasn't given, the
image's own declared `config.User` (`OciImageConfig::user`/`group`) — so a
container defaults to running as whatever user the image itself declares, not
root, unless the image declares none.
- `process.{h,cpp}` — argv-based subprocess helpers (fork/execvp, no shell):
@@ -456,6 +468,51 @@ Source layout (all under `src/`):
`nsenter ... -- <command>` via the existing `run_process_foreground()`
(`process.h`) — same inherited stdio and SIGINT/SIGTERM forwarding as every
other foreground external command, no new process-running logic needed.
`exec_in_session()` also takes optional `--user`/`--group` (mirroring `-r/--run`'s
own): given, they resolve against the session's own `/etc/passwd`/`/etc/group`
(fetched via `cat` run through the same `nsenter` join, since this process can't
otherwise see into that namespace, then handed to `resolve_user_and_group()`
`user_spec.h`, see below); if unset, defaults to whatever uid/gid the session's
own sandboxed command is *already* running as (read from `/proc/<ns_pid>/status`),
rather than root/the caller — fixing a real bug (reported after this project's
own `-e/--exec` and priv-drop features had both shipped separately): without this,
`-e/--exec` always ran as whatever the *host* invocation was, ignoring any
`--user`/`--group` the session itself was started with. Either way, the resolved
identity is applied by running `command` through the session's already
bind-mounted `slocker-lite-priv-drop` helper (`priv_drop::path`, `bwrap.h`) —
reused as-is, not bind-mounted again (`-e/--exec` can't add bind mounts to an
already-running session's namespace anyway). Skipped entirely when the resolved
uid *and* gid are both 0: a session that was never given a resolvable user at
`-r/--run` time never got the helper bind-mounted at all, and dropping to 0:0
would be a no-op regardless; a missing helper for a genuinely non-root
resolution instead surfaces as `nsenter`'s own "No such file or directory" once
it tries to exec `priv_drop::path`, diagnostic enough on its own. **Second real
bug, caught by direct testing on a rootless dev machine before this shipped**:
when the session's own `-r/--run` used `--unshare-user` (i.e. ran rootless —
see the root-vs-rootless paragraph below), "root inside the container" is
achieved purely through the kernel's own uid mapping for that namespace, not a
real privilege drop — so `/proc/<ns_pid>/status`'s uid/gid, read from *outside*
that namespace, shows the host-mapped id (e.g. `1000`), not the
container-relative one (`0`). Treating that as "needs a priv-drop to 1000" is
wrong two ways: the helper is typically never bind-mounted for a session with
no resolved `--user`, and even when it is, `setuid()` fails outright under the
single-entry uid map an unprivileged user namespace gets (confirmed directly:
`failed to drop privileges to 0:0: Operation not permitted`). Fixed by tracking
whether the `user` namespace type was actually one of the ones joined (it only
is when it differs from this process's own, i.e. exactly when `-r/--run` used
`--unshare-user`) and, when so, leaving the default identity unresolved (no
priv-drop) for that case — joining that same user namespace with
`--preserve-credentials` (already done regardless) already reproduces the
container's own view correctly via that same kernel mapping, with nothing
further needed. Verified end-to-end on this same rootless dev machine: a
daemonized `-r --run` busybox session with no declared user, `--exec`'d with no
`--user`, now correctly shows `uid=0(root)` (previously would have attempted,
and failed, a priv-drop to the host-mapped uid); an explicit `--exec --user 0`
against the same session correctly resolves to `0:0` and skips the priv-drop
step; `--exec --user portage` against it correctly resolves the name to its
real `250:250` via the fetched `/etc/passwd` and then fails clearly (helper not
bind-mounted, since the session itself had no declared user) rather than
silently running as the wrong identity.
- `config_file.{h,cpp}``load_config_file()` reads and parses (via libyaml's
document API, `<yaml.h>`) the `global` and `volumes` sections of the local YAML
config file located by `config_file_path()` (`$XDG_CONFIG_HOME/slocker-lite/config.yaml`,