Fix -e/--exec namespace resolution on kernels without CONFIG_CHECKPOINT_RESTORE

/proc/<pid>/task/<pid>/children doesn't exist on every kernel (confirmed
missing on a real Android target), so resolve_namespace_pid() fell back to
the outer bwrap pid itself and nsenter ended up with no namespace flags at
all. Add a portable fallback that scans /proc/<n>/stat for the child whose
ppid matches, the same information pstree uses to build its tree.

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 07:42:02 +00:00
parent fa0bf13732
commit 1c186b0365
2 changed files with 82 additions and 7 deletions
+15 -3
View File
@@ -422,9 +422,21 @@ Source layout (all under `src/`):
`/proc/<outer_pid>/ns/{pid,uts,ipc,cgroup}` against this process's own showed
them identical, while only `mnt`/`user` differed. `resolve_namespace_pid()`
reads `/proc/<pid>/task/<pid>/children` (the direct-children list `procfs`
exposes) to find that real inner process and joins *its* namespaces instead
falls back to `pid` itself (best-effort, not fatal) if that file can't be
read. For each of `{mnt→--mount, uts→--uts, ipc→--ipc, pid→--pid,
exposes) to find that real inner process and joins *its* namespaces instead.
**Real bug found via testing on a real target device, not assumed**: that
file requires `CONFIG_CHECKPOINT_RESTORE`, which not every kernel enables —
confirmed absent (not just unreadable — the file doesn't exist at all) on a
real Android device, where `-e/--exec` then fell back to the outer `bwrap`
pid itself and failed outright (`nsenter: no namespace specified`, since
every namespace type either matched the outer process's own or couldn't be
read at all). Fixed by adding `find_child_by_scanning_proc()`, a portable
fallback used only when the children file is missing/empty: scans
`/proc/<n>/stat` for any process whose ppid field equals `pid` — the same
information `pstree` itself reads to build its tree, which is how the actual
sandboxed child was located and confirmed correct on the same device via a
manual `nsenter -t <child_pid> -a -- /bin/sh` before the fix was written.
Picks the lowest matching pid if more than one child exists, for a
deterministic result. For each of `{mnt→--mount, uts→--uts, ipc→--ipc, pid→--pid,
cgroup→--cgroup, user→--user}` (`net` deliberately excluded — this project
never isolates networking, see `bwrap.cpp` below), `readlink()`s both
`/proc/<ns_pid>/ns/<type>` and `/proc/self/ns/<type>` and only passes
+67 -4
View File
@@ -18,9 +18,11 @@
#include <algorithm>
#include <array>
#include <cctype>
#include <filesystem>
#include <fstream>
#include <optional>
#include <sstream>
#include <system_error>
#include <fmt/core.h>
@@ -31,6 +33,64 @@
namespace {
// Fast path: /proc/<pid>/task/<pid>/children lists direct children with no
// scanning needed. Requires CONFIG_CHECKPOINT_RESTORE, which not every kernel
// enables -- absent (not just unreadable), confirmed by direct testing on a
// real Android target, where the file simply doesn't exist.
std::optional<pid_t> find_child_via_children_file(pid_t pid) {
std::ifstream children(fmt::format("/proc/{}/task/{}/children", pid, pid));
pid_t child = 0;
if (children >> child && child > 0) {
return child;
}
return std::nullopt;
}
// Portable fallback for kernels without the children file: scans /proc/<n>/stat
// for any process whose ppid field (the first whitespace-separated field after
// comm's closing ')' -- comm itself is parenthesized and may contain spaces or
// parens, so it can't just be split on whitespace) equals `pid`. This is the
// same information `pstree` itself reads to build its tree -- confirmed by
// direct testing: `pstree -p <bwrap_pid>` found the real sandboxed child on a
// device where the children file was missing. Picks the lowest matching pid if
// more than one child exists, for a deterministic result.
std::optional<pid_t> find_child_by_scanning_proc(pid_t pid) {
std::error_code ec;
std::optional<pid_t> found;
for (const auto& entry : std::filesystem::directory_iterator("/proc", ec)) {
if (ec) {
break;
}
const std::string name = entry.path().filename().string();
if (name.empty() || !std::all_of(name.begin(), name.end(),
[](unsigned char c) { return std::isdigit(c) != 0; })) {
continue;
}
std::ifstream stat_file(entry.path() / "stat");
std::string line;
if (!std::getline(stat_file, line)) {
continue;
}
auto close_paren = line.rfind(')');
if (close_paren == std::string::npos) {
continue;
}
std::istringstream rest(line.substr(close_paren + 1));
std::string state;
pid_t ppid = 0;
if (!(rest >> state >> ppid) || ppid != pid) {
continue;
}
pid_t candidate = std::stoi(name);
if (!found || candidate < *found) {
found = candidate;
}
}
return found;
}
// bwrap's own outer process (the one tracked in the session pid file) sets up the
// mount/user namespaces itself, then clone()s the actual sandboxed command into
// fresh pid/uts/ipc/cgroup/net namespaces -- clone()'s namespace-creation flags
@@ -43,10 +103,13 @@ namespace {
// child can't be determined -- callers still get a mount/user-namespace join out
// of that, just not the rest.
pid_t resolve_namespace_pid(pid_t pid) {
std::ifstream children(fmt::format("/proc/{}/task/{}/children", pid, pid));
pid_t child = 0;
if (children >> child && child > 0) {
return child;
if (auto child = find_child_via_children_file(pid)) {
return *child;
}
if (auto child = find_child_by_scanning_proc(pid)) {
spdlog::debug("pid {}: found sandboxed child {} by scanning /proc (no .../task/.../children file)",
pid, *child);
return *child;
}
spdlog::debug("could not determine pid {}'s sandboxed child process; joining its own namespaces only",
pid);