Add --user/--group to run as a different uid/gid as root

bwrap --uid/--gid require --unshare-user, which is never requested
when running as root (since last session's fix), and user namespaces
aren't a near-term option anyway -- the actual Android target doesn't
support them.

--user <name-or-uid> / --group <name-or-gid> work around this:
user_spec.cpp resolves them against the *mounted image's own*
/etc/passwd and /etc/group (names like "git" only mean anything inside
that image's user database), and bwrap.cpp bind-mounts a small helper
into the sandbox to do the actual privilege drop before exec'ing the
real command, since bwrap itself can't switch uid/gid without a user
namespace.

The helper has to be a separate, statically-linked binary
(priv_drop_helper.cpp -> slocker-lite-priv-drop, built with -static)
rather than slocker_lite's own binary: bind-mounting a dynamically
linked executable into an arbitrary container image fails ("error
while loading shared libraries") since that image's own /lib won't
have slocker_lite's dependencies. find_priv_drop_helper() locates it
next to slocker_lite's own binary; run_bwrap() fails fast if it's
missing rather than silently running as root.

Only works without a user namespace (root): under --unshare-user the
sandbox's uid map has only one valid entry, so the helper's own
setuid() fails cleanly there instead of doing nothing silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 07:06:13 +00:00
parent 095ae8397a
commit 69df728e09
8 changed files with 386 additions and 21 deletions
+36 -4
View File
@@ -45,7 +45,37 @@ Source layout (all under `src/`):
triggers the kernel's unprivileged-userns setgroups() restriction, which showed up
as every other supplementary group collapsing to the overflow gid ("nobody") in
`id`, and `su` inside the sandbox failing with "can't set groups: Operation not
permitted".
permitted". Because of that, bwrap's own `--uid`/`--gid` (which require
`--unshare-user`) aren't usable when running as root either — `--user`/`--group`
work around this: when set, `build_bwrap_args()`/`run_bwrap()` bind-mount the
separate `slocker-lite-priv-drop` helper (see below) into the sandbox at a fixed
hidden path and route the real command through it as
`<uid>:<gid> -- <command...>`. This only actually works without a user namespace
(i.e. running as root) — under `--unshare-user`, the sandbox's uid map has only
one valid entry, so the helper's own `setuid()` fails cleanly there instead of
silently doing nothing. `run_bwrap()` fails fast (returns -1) if the helper can't
be found next to this binary when `--user` was requested, rather than silently
running the command as root.
- `priv_drop_helper.cpp` → the separate `slocker-lite-priv-drop` binary (its own
`executable()` target in `meson.build`, **built with `-static`**). Deliberately
has zero dependencies on the rest of this project (no fmt/spdlog/etc.) and is
fully statically linked: it gets bind-mounted *into the container image's own
filesystem*, which won't have `slocker_lite`'s own shared library dependencies —
a dynamically linked binary bind-mounted that way fails outright ("error while
loading shared libraries"), which is exactly what happened before this was split
out (the original approach bind-mounted `slocker_lite`'s own — dynamically linked
— binary via `/proc/self/exe` and reexeced it; kept only as a lesson, not as
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`.
- `user_spec.{h,cpp}``resolve_user_and_group()` resolves `--user`/`--group` (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.
- `process.{h,cpp}` — argv-based subprocess helpers (fork/execvp, no shell):
`run_process()` captures stdout (used for `containers-storage` calls),
`run_process_foreground()` inherits all of stdio (used for the interactive `bwrap`
@@ -82,11 +112,13 @@ out to already be directly visible.
Build directory is `buildDir/` (already configured).
- Configure (only needed if `buildDir/` is missing or deleted): `meson setup buildDir`
- Build: `meson compile -C buildDir` (or `ninja -C buildDir`)
- Build: `meson compile -C buildDir` (or `ninja -C buildDir`) — also builds
`buildDir/slocker-lite-priv-drop`, the statically-linked helper `-r --user` needs
(see `priv_drop_helper.cpp` in "Project state")
- Run the executable: `./buildDir/slocker_lite -m <image.tar>` (see `--help` for the
full flag list: `-m/--mount`, `-r/--run`, `-u/--umount`, `-c/--cleanup`,
`-l/--list-images`, `-n/--no-nsenter`, `-t/--test`, `--log-level`, `-h/--help`,
`-V/--version`)
`-l/--list-images`, `-n/--no-nsenter`, `--user`, `--group`, `-t/--test`,
`--log-level`, `-h/--help`, `-V/--version`)
- Run tests: `meson test -C buildDir`
## Code style
+9 -1
View File
@@ -18,11 +18,19 @@ configure_file(output : 'config.h', configuration : conf_data)
slocker_lite = executable('slocker_lite',
['src/main.cpp', 'src/process.cpp', 'src/oci_image.cpp', 'src/containers_storage.cpp',
'src/bwrap.cpp'],
'src/bwrap.cpp', 'src/user_spec.cpp'],
include_directories : include_directories('.'),
dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep],
install : true)
# Bind-mounted into the sandbox by -r/--run's --user/--group handling (src/bwrap.cpp),
# so it must be dependency-free and statically linked to run regardless of what
# libc/libraries the container image itself has.
priv_drop_helper = executable('slocker-lite-priv-drop',
['src/priv_drop_helper.cpp'],
link_args : ['-static'],
install : true)
fixture_tar = custom_target('oci-fixture',
output : 'fixture.tar',
command : [find_program('python3'), files('tests/gen_fixture.py'), '@OUTPUT@'])
+58 -3
View File
@@ -36,6 +36,33 @@
namespace {
// Hidden path inside the sandbox where the priv-drop helper binary is bind-mounted
// when dropping privileges to a --user/--group (see build_bwrap_args()).
constexpr const char* kPrivDropPath = "/.slocker-lite-priv-drop";
// Name of the statically-linked helper binary built alongside slocker_lite
// (src/priv_drop_helper.cpp) -- it has to be a separate, dependency-free static
// binary rather than slocker_lite's own binary, since bind-mounting a dynamically
// linked executable into an arbitrary container image fails ("error while loading
// shared libraries") when that image's own /lib lacks slocker_lite's dependencies.
constexpr const char* kPrivDropHelperName = "slocker-lite-priv-drop";
// Locates the priv-drop helper installed next to this process's own binary (found
// via /proc/self/exe), which holds whether run from buildDir/ or after a proper
// `meson install` -- both put slocker_lite and the helper in the same directory.
std::optional<std::filesystem::path> find_priv_drop_helper() {
std::error_code ec;
auto self_path = std::filesystem::read_symlink("/proc/self/exe", ec);
if (ec) {
return std::nullopt;
}
std::filesystem::path candidate = self_path.parent_path() / kPrivDropHelperName;
if (access(candidate.c_str(), X_OK) != 0) {
return std::nullopt;
}
return candidate;
}
struct NamespaceProbe {
int clone_flag;
const char* bwrap_arg;
@@ -150,7 +177,8 @@ std::vector<std::string> detect_bwrap_unshare_args() {
}
std::vector<std::string> build_bwrap_args(const std::string& root,
const std::vector<std::string>& command) {
const std::vector<std::string>& command,
std::optional<ResolvedUser> user) {
// --new-session detaches from the controlling terminal, which breaks job
// control for an interactive foreground shell ("can't access tty"). Re-enable
// once background/daemonized runs are implemented, where that's the point.
@@ -208,15 +236,42 @@ std::vector<std::string> build_bwrap_args(const std::string& root,
args.insert(args.end(), {"--setenv", "TERM", term});
}
bool bound_priv_drop_helper = false;
if (user) {
auto helper_path = find_priv_drop_helper();
if (helper_path) {
args.insert(args.end(), {"--ro-bind", helper_path->string(), kPrivDropPath});
bound_priv_drop_helper = true;
} else {
spdlog::warn("could not find the {} helper next to this binary; --user/--group will "
"have no effect",
kPrivDropHelperName);
}
}
args.push_back("--");
if (bound_priv_drop_helper) {
args.push_back(kPrivDropPath);
args.push_back(fmt::format("{}:{}", user->uid, user->gid));
args.push_back("--");
}
args.insert(args.end(), command.begin(), command.end());
return args;
}
int run_bwrap(const std::string& root, const std::vector<std::string>& command, bool use_nsenter) {
int run_bwrap(const std::string& root, const std::vector<std::string>& command, bool use_nsenter,
std::optional<ResolvedUser> user) {
std::vector<std::string> argv;
if (user && !find_priv_drop_helper()) {
spdlog::error("could not find the {} helper next to this binary; --user/--group requires it",
kPrivDropHelperName);
return -1;
}
if (use_nsenter) {
auto pid = find_fuse_overlayfs_pid(root);
if (!pid) {
@@ -231,7 +286,7 @@ int run_bwrap(const std::string& root, const std::vector<std::string>& command,
fmt::format("--mount=/proc/{}/ns/mnt", *pid), "--"};
}
auto bwrap_args = build_bwrap_args(root, command);
auto bwrap_args = build_bwrap_args(root, command, user);
argv.insert(argv.end(), bwrap_args.begin(), bwrap_args.end());
return run_process_foreground(argv);
+17 -4
View File
@@ -16,6 +16,7 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
@@ -26,11 +27,20 @@
// --unshare-xxx flag to bwrap would make it fail outright.
std::vector<std::string> detect_bwrap_unshare_args();
struct ResolvedUser {
int uid;
int gid;
};
// Assembles the full bwrap argv (program name included) to run `command` with
// `root` bound as the sandbox's filesystem root, using whichever --unshare-xxx
// flags the kernel supports (see detect_bwrap_unshare_args()).
// flags the kernel supports (see detect_bwrap_unshare_args()). If `user` is set,
// the command is wrapped so it drops to that uid/gid before running -- see
// run_bwrap() for how, since bwrap's own --uid/--gid require --unshare-user, which
// isn't requested when running as root (see detect_bwrap_unshare_args()).
std::vector<std::string> build_bwrap_args(const std::string& root,
const std::vector<std::string>& command);
const std::vector<std::string>& command,
std::optional<ResolvedUser> user);
// Runs bwrap against `root` (the merged mount path from mount_layer()) in the
// foreground and waits for it to exit. If `use_nsenter` is true, first locates the
@@ -40,5 +50,8 @@ std::vector<std::string> build_bwrap_args(const std::string& root,
// plain child process on kernels where fuse-overlayfs isolates it that way. Pass
// use_nsenter=false on kernels where the mount is already directly visible
// (observed on kernels older than 4.18, per fuse-overlayfs's own release notes).
// Returns bwrap's exit code, or -1 on failure to launch.
int run_bwrap(const std::string& root, const std::vector<std::string>& command, bool use_nsenter);
// If `user` is set, this process's own binary is bind-mounted into the sandbox and
// used to drop privileges to that uid/gid before running `command` -- see
// build_bwrap_args(). Returns bwrap's exit code, or -1 on failure to launch.
int run_bwrap(const std::string& root, const std::vector<std::string>& command, bool use_nsenter,
std::optional<ResolvedUser> user);
+48 -9
View File
@@ -34,6 +34,7 @@
#include "containers_storage.h"
#include "oci_image.h"
#include "process.h"
#include "user_spec.h"
namespace {
@@ -41,11 +42,14 @@ constexpr std::array<std::string_view, 2> kRequiredTools = {"containers-storage"
enum class Mode { kNone, kMount, kUnmount, kTest, kRun, kCleanup, kListImages };
// --log-level has no short form (freed up so -l could become --list-images), so it
// needs a long-option val outside the printable-char range short options use.
// --log-level/--user/--group have no short form (--log-level's was freed up so -l
// could become --list-images; -u is already --umount), so they need long-option
// vals outside the printable-char range short options use.
constexpr int kLogLevelOpt = 256;
constexpr int kUserOpt = 257;
constexpr int kGroupOpt = 258;
constexpr std::array<struct option, 11> kLongOptions = {{
constexpr std::array<struct option, 13> kLongOptions = {{
{"help", no_argument, nullptr, 'h'},
{"version", no_argument, nullptr, 'V'},
{"test", no_argument, nullptr, 't'},
@@ -56,6 +60,8 @@ constexpr std::array<struct option, 11> kLongOptions = {{
{"cleanup", required_argument, nullptr, 'c'},
{"no-nsenter", no_argument, nullptr, 'n'},
{"list-images", required_argument, nullptr, 'l'},
{"user", required_argument, nullptr, kUserOpt},
{"group", required_argument, nullptr, kGroupOpt},
{nullptr, 0, nullptr, 0},
}};
@@ -87,6 +93,13 @@ void print_usage(const char* prog) {
" (this is automatic when running as root, where\n"
" the mount is already directly visible; pass\n"
" this to force it off otherwise)\n"
" --user <user> with --run, run the command as this user (name\n"
" or numeric uid) instead of root, resolved\n"
" against the image's own /etc/passwd; only takes\n"
" effect when --run executes as root (no user\n"
" namespace involved)\n"
" --group <group> with --user, use this group (name or numeric\n"
" gid) instead of the user's own primary group\n"
" -l, --list-images <dir> list OCI Image Layout tars (*.tar, *.tar.*) found\n"
" directly in <dir>, with their name:tag\n"
" -t, --test run the test suite\n"
@@ -242,13 +255,23 @@ int list_images_command(const std::filesystem::path& dir) {
}
int run_container(const std::filesystem::path& image_tar,
const std::vector<std::string>& requested_command, bool use_nsenter) {
const std::vector<std::string>& requested_command, bool use_nsenter,
const std::optional<std::string>& user, const std::optional<std::string>& group) {
auto mounted = mount_image(image_tar);
if (!mounted) {
return 1;
}
fmt::print("mounted image at: {} (layer {})\n", mounted->merged_path, mounted->top_layer_id);
bool ok = true;
std::optional<ResolvedUser> resolved_user;
if (user) {
resolved_user = resolve_user_and_group(*user, group, mounted->merged_path);
if (!resolved_user) {
ok = false;
}
}
std::vector<std::string> command = requested_command;
if (command.empty()) {
auto config = read_oci_image_config(image_tar);
@@ -256,9 +279,12 @@ int run_container(const std::filesystem::path& image_tar,
: std::vector<std::string>{"/bin/sh"};
}
int exit_code = run_bwrap(mounted->merged_path, command, use_nsenter);
if (exit_code < 0) {
spdlog::error("failed to run bwrap");
int exit_code = -1;
if (ok) {
exit_code = run_bwrap(mounted->merged_path, command, use_nsenter, resolved_user);
if (exit_code < 0) {
spdlog::error("failed to run bwrap");
}
}
if (!unmount_layer(mounted->top_layer_id)) {
@@ -268,7 +294,7 @@ int run_container(const std::filesystem::path& image_tar,
spdlog::error("failed to clean up layer {}", mounted->top_layer_id);
}
return exit_code < 0 ? 1 : exit_code;
return (!ok || exit_code < 0) ? 1 : exit_code;
}
int main(int argc, char* argv[]) {
@@ -277,6 +303,8 @@ int main(int argc, char* argv[]) {
Mode mode = Mode::kNone;
std::string mode_arg;
bool disable_nsenter = false;
std::optional<std::string> user_flag;
std::optional<std::string> group_flag;
opterr = 0;
int opt;
@@ -334,6 +362,12 @@ int main(int argc, char* argv[]) {
return 1;
}
break;
case kUserOpt:
user_flag = optarg;
break;
case kGroupOpt:
group_flag = optarg;
break;
case ':':
spdlog::error("option requires an argument: -{}", static_cast<char>(optopt));
print_usage(argv[0]);
@@ -354,6 +388,11 @@ int main(int argc, char* argv[]) {
print_usage(argv[0]);
return 1;
}
if (group_flag && !user_flag) {
spdlog::error("--group requires --user");
print_usage(argv[0]);
return 1;
}
if (mode == Mode::kTest) {
return run_tests();
@@ -377,7 +416,7 @@ int main(int argc, char* argv[]) {
if (geteuid() == 0 && !disable_nsenter) {
spdlog::debug("running as root; skipping nsenter (the mount is already directly visible)");
}
return run_container(mode_arg, command, use_nsenter);
return run_container(mode_arg, command, use_nsenter, user_flag, group_flag);
}
auto mounted = mount_image(mode_arg);
+61
View File
@@ -0,0 +1,61 @@
// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
// Minimal standalone helper, built fully statically (no shared library dependencies
// at all) so it can be bind-mounted into an arbitrary container image and still run
// regardless of what libc/libraries that image does or doesn't have. Deliberately
// avoids fmt/spdlog/anything else from the rest of this project -- a dynamically
// linked binary bind-mounted into a container fails with "error while loading
// shared libraries", since the container's own /lib won't have slocker_lite's
// dependencies.
//
// Usage: slocker-lite-priv-drop <uid>:<gid> -- <command> [args...]
// Drops supplementary groups, then gid, then uid (in that order -- dropping gid
// needs CAP_SETGID, which is lost once setuid() drops root), then execs <command>.
#include <grp.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
int main(int argc, char* argv[]) {
if (argc < 4 || std::strcmp(argv[2], "--") != 0) {
std::fprintf(stderr, "usage: %s <uid>:<gid> -- <command> [args...]\n", argv[0]);
return 1;
}
char* colon = std::strchr(argv[1], ':');
if (!colon) {
std::fprintf(stderr, "%s: invalid uid:gid spec: %s\n", argv[0], argv[1]);
return 1;
}
*colon = '\0';
uid_t uid = static_cast<uid_t>(std::strtol(argv[1], nullptr, 10));
gid_t gid = static_cast<gid_t>(std::strtol(colon + 1, nullptr, 10));
if (setgroups(0, nullptr) != 0 || setgid(gid) != 0 || setuid(uid) != 0) {
std::fprintf(stderr, "%s: failed to drop privileges to %u:%u: %s\n", argv[0],
static_cast<unsigned>(uid), static_cast<unsigned>(gid), std::strerror(errno));
return 1;
}
execvp(argv[3], argv + 3);
std::fprintf(stderr, "%s: failed to exec %s: %s\n", argv[0], argv[3], std::strerror(errno));
return 127;
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#include "user_spec.h"
#include <algorithm>
#include <cctype>
#include <fstream>
#include <sstream>
#include <spdlog/spdlog.h>
namespace {
std::vector<std::string> split(const std::string& line, char delim) {
std::vector<std::string> fields;
std::istringstream stream(line);
std::string field;
while (std::getline(stream, field, delim)) {
fields.push_back(field);
}
return fields;
}
bool is_all_digits(const std::string& s) {
return !s.empty() && std::all_of(s.begin(), s.end(), [](unsigned char c) { return std::isdigit(c); });
}
std::optional<int> parse_int(const std::string& s) {
try {
return std::stoi(s);
} catch (const std::exception&) {
return std::nullopt;
}
}
// Looks up `key` by name (field 0) or numeric id (field `id_field`) in a
// colon-separated database file (/etc/passwd or /etc/group). Malformed lines are
// skipped rather than treated as errors.
std::optional<std::vector<std::string>> lookup_entry(const std::filesystem::path& db_file,
const std::string& key, size_t id_field) {
std::ifstream in(db_file, std::ios::binary);
if (!in) {
return std::nullopt;
}
std::string line;
while (std::getline(in, line)) {
auto fields = split(line, ':');
if (fields.size() <= id_field) {
continue;
}
if (fields[0] == key || fields[id_field] == key) {
return fields;
}
}
return std::nullopt;
}
} // namespace
std::optional<ResolvedUser> resolve_user_and_group(const std::string& user,
const std::optional<std::string>& group,
const std::filesystem::path& image_root) {
std::filesystem::path passwd_file = image_root / "etc" / "passwd";
int uid = 0;
int gid = 0;
if (is_all_digits(user)) {
uid = *parse_int(user);
auto entry = lookup_entry(passwd_file, user, 2);
if (entry && entry->size() > 3) {
gid = parse_int((*entry)[3]).value_or(uid);
} else {
gid = uid;
}
} else {
auto entry = lookup_entry(passwd_file, user, 2);
if (!entry || entry->size() <= 3) {
spdlog::error("could not resolve user '{}' in the image's /etc/passwd", user);
return std::nullopt;
}
auto entry_uid = parse_int((*entry)[2]);
auto entry_gid = parse_int((*entry)[3]);
if (!entry_uid || !entry_gid) {
spdlog::error("malformed /etc/passwd entry for user '{}'", user);
return std::nullopt;
}
uid = *entry_uid;
gid = *entry_gid;
}
if (group) {
if (is_all_digits(*group)) {
gid = *parse_int(*group);
} else {
std::filesystem::path group_file = image_root / "etc" / "group";
auto entry = lookup_entry(group_file, *group, 2);
auto entry_gid = entry ? parse_int((*entry)[2]) : std::nullopt;
if (!entry_gid) {
spdlog::error("could not resolve group '{}' in the image's /etc/group", *group);
return std::nullopt;
}
gid = *entry_gid;
}
}
return ResolvedUser{uid, gid};
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#pragma once
#include <filesystem>
#include <optional>
#include <string>
#include "bwrap.h"
// Resolves --user (name or numeric uid) and --group (optional, name or numeric gid)
// against the mounted image's own /etc/passwd and /etc/group (rooted at
// `image_root`, the merged mount path) -- NOT the host's. A numeric `user` with no
// `group` and no matching /etc/passwd entry defaults gid to the same numeric value
// as the uid; a named `user` always requires a resolvable /etc/passwd entry (for its
// uid and default gid). Logs a specific error and returns nullopt if a named
// user/group can't be resolved, or if /etc/passwd is missing entirely.
std::optional<ResolvedUser> resolve_user_and_group(const std::string& user,
const std::optional<std::string>& group,
const std::filesystem::path& image_root);