diff --git a/CLAUDE.md b/CLAUDE.md index 1d38410..e6cc62c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + `: -- `. 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 : -- [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 ` (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 diff --git a/meson.build b/meson.build index 08ce5bb..2060480 100644 --- a/meson.build +++ b/meson.build @@ -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@']) diff --git a/src/bwrap.cpp b/src/bwrap.cpp index 9be937a..db55458 100644 --- a/src/bwrap.cpp +++ b/src/bwrap.cpp @@ -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 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 detect_bwrap_unshare_args() { } std::vector build_bwrap_args(const std::string& root, - const std::vector& command) { + const std::vector& command, + std::optional 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 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& command, bool use_nsenter) { +int run_bwrap(const std::string& root, const std::vector& command, bool use_nsenter, + std::optional user) { std::vector 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& 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); diff --git a/src/bwrap.h b/src/bwrap.h index 974e602..7e41c79 100644 --- a/src/bwrap.h +++ b/src/bwrap.h @@ -16,6 +16,7 @@ #pragma once +#include #include #include @@ -26,11 +27,20 @@ // --unshare-xxx flag to bwrap would make it fail outright. std::vector 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 build_bwrap_args(const std::string& root, - const std::vector& command); + const std::vector& command, + std::optional 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 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& 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& command, bool use_nsenter, + std::optional user); diff --git a/src/main.cpp b/src/main.cpp index cd9857b..38f7088 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 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 kLongOptions = {{ +constexpr std::array kLongOptions = {{ {"help", no_argument, nullptr, 'h'}, {"version", no_argument, nullptr, 'V'}, {"test", no_argument, nullptr, 't'}, @@ -56,6 +60,8 @@ constexpr std::array 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 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 with --user, use this group (name or numeric\n" + " gid) instead of the user's own primary group\n" " -l, --list-images list OCI Image Layout tars (*.tar, *.tar.*) found\n" " directly in , 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& requested_command, bool use_nsenter) { + const std::vector& requested_command, bool use_nsenter, + const std::optional& user, const std::optional& 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 resolved_user; + if (user) { + resolved_user = resolve_user_and_group(*user, group, mounted->merged_path); + if (!resolved_user) { + ok = false; + } + } + std::vector 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{"/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 user_flag; + std::optional 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(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); diff --git a/src/priv_drop_helper.cpp b/src/priv_drop_helper.cpp new file mode 100644 index 0000000..af12696 --- /dev/null +++ b/src/priv_drop_helper.cpp @@ -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 : -- [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 . + +#include +#include + +#include +#include +#include +#include + +int main(int argc, char* argv[]) { + if (argc < 4 || std::strcmp(argv[2], "--") != 0) { + std::fprintf(stderr, "usage: %s : -- [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(std::strtol(argv[1], nullptr, 10)); + gid_t gid = static_cast(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(uid), static_cast(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; +} diff --git a/src/user_spec.cpp b/src/user_spec.cpp new file mode 100644 index 0000000..a5616cd --- /dev/null +++ b/src/user_spec.cpp @@ -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 +#include +#include +#include + +#include + +namespace { + +std::vector split(const std::string& line, char delim) { + std::vector 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 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> 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 resolve_user_and_group(const std::string& user, + const std::optional& 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}; +} diff --git a/src/user_spec.h b/src/user_spec.h new file mode 100644 index 0000000..446555d --- /dev/null +++ b/src/user_spec.h @@ -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 +#include +#include + +#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 resolve_user_and_group(const std::string& user, + const std::optional& group, + const std::filesystem::path& image_root);