Skip to content

procfs: let every task read its own /proc/self - #233

Draft
congwang-mk wants to merge 7 commits into
mainfrom
proc-self-reads
Draft

congwang-mk wants to merge 7 commits into
mainfrom
proc-self-reads

Conversation

@congwang-mk

Copy link
Copy Markdown
Contributor

Fixes #218.

Problem

/proc/self resolves per caller, but a Landlock rule binds to the inode that exists when the rule is added. A read grant on /proc/self/maps therefore covered only the first process; every forked child has a different /proc/<pid>/maps and was denied. Landlock cannot express "the caller's own entry", and granting all of /proc instead would widen the policy and rely on a check-then-Continue that a CLONE_VM peer can race.

Change

What a task reads under /proc/self describes the task itself, so there is nothing for a grant to protect, while runtimes that read their own maps or status fail in obscure ways without one. These reads are now served for every task, with no grant:

  • handle_proc_open opens the path inside /proc/<tgid> of the caller (task/<tid> for /proc/thread-self) with openat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS) and injects the fd. The kernel never re-reads the child's path string.
  • Whatever the handler does not serve is left to Landlock, which holds no rule for it: writes are refused, and cwd, root, exe and fd/N are judged by their real target.
  • fs_deny is honored under the numeric spelling of the caller's own directory too, which the string precheck would miss.
  • /proc/self entries in the read list no longer become Landlock rules when a supervisor runs, so the first process is not treated differently from its children. With no_supervisor and in-process confine the rule stays, since it is then all there is. Chroot mode already services /proc on behalf of the child and is unchanged.
  • learn stops recording these reads.

Also fixed: /proc/net virtualization bypass

First commit, independent of #218. /proc/net is a link to self/net, and every /proc/<pid> and /proc/<pid>/task/<tid> carries the same tree, but the handler only matched the /proc/net spelling. With /proc readable, /proc/self/net/dev and /proc/self/net/tcp showed the host's interfaces and sockets that /proc/net/dev and /proc/net/tcp hide. All per-task spellings are now rewritten to /proc/net before matching. This also keeps the net subtree (the host's network namespace, not the task's own data) out of the always-allowed reads; it still needs a /proc grant.

Not covered

Write grants under /proc/self still only cover the first process: #232.

Tests

  • New integration tests in test_procfs.rs: forked child, the fd describing the caller rather than the first pid, a non-leader thread (/proc/self vs /proc/thread-self), and the refusals (writes, links out of /proc, the net subtree, fs_deny under both spellings), plus no_supervisor keeping a listed entry. Each refusal is paired with a read that must succeed, since /dev/null is not writable in these sandboxes. Disabling the link guard and the deny check each makes the matching test fail.
  • test_proc_net_virt_covers_per_task_spellings reads net/dev through five spellings and expects loopback only.
  • Two learn tests from fix(learn): agent workload bug fixes #209 that asserted /proc/self reads are recorded are replaced by one asserting they are not.
  • Locally: sandlock-core 774 lib and 455 integration tests pass; learn_test 39 pass. Python and Go suites were not run locally.

🤖 Generated with Claude Code

/proc/net is a link to self/net, and every /proc/<pid> and
/proc/<pid>/task/<tid> directory carries the same tree. The handler only
matched the /proc/net spelling, so with /proc readable a sandbox could
read /proc/self/net/dev or /proc/self/net/tcp and see the host's
interfaces and sockets that /proc/net/dev and /proc/net/tcp hide.

Sandlock creates no network namespace, so all of these name one tree.
Rewrite them to /proc/net before matching.

Signed-off-by: Cong Wang <cwang@multikernel.io>
/proc/self resolves per caller, but a Landlock rule binds to the inode
that exists when the rule is added. A read grant on /proc/self/maps
therefore covered only the first process: every child it forked has a
different /proc/<pid>/maps and was denied (issue #218). Landlock has no
way to say "the caller's own entry", and granting all of /proc instead
would widen the policy and lean on a check-then-Continue that a
CLONE_VM peer can race.

What a task reads under /proc/self describes the task itself, so there
is nothing for a grant to protect, while runtimes that read their own
maps or status fail in obscure ways without one. Serve these reads for
every task instead of asking for a grant: the open handler opens the
path inside /proc/<tgid> of the caller with openat2 (RESOLVE_BENEATH |
RESOLVE_NO_SYMLINKS) and injects the fd. Whatever it does not serve is
left to Landlock, which holds no rule for it, so a write is refused and
cwd, root, exe and fd/N are judged by their real target. The net
subtree is the host's network namespace rather than the task's own
data; it is already spelled /proc/net by the time this runs and keeps
needing a /proc grant. fs_deny is honored under the numeric spelling of
the caller's directory too, which the string precheck would miss.

/proc/self entries in the read list no longer become Landlock rules, so
the first process is not treated differently from its children. Entries
under net are the exception: nothing else serves them, so they stay
rules and behave as before. So does everything when no supervisor runs,
since the rule is then all there is. Chroot mode already services /proc
on behalf of the child and is left alone.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Issue #218 went unnoticed because every existing test read /proc/self
from the first process, the one pid the old Landlock rule happened to
cover. Exercise forked children and a non-leader thread, and pin the
limits: writes, links that lead out of /proc, the net subtree and
fs_deny entries stay refused. Each refusal is paired with a read that
must succeed, since /dev/null is not writable in these sandboxes and a
redirect there fails regardless of what is under test.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Every task can now read its own /proc/self without a grant, and such
entries no longer become Landlock rules, so recording them only adds
dead lines to the profile. Reads under net still need a grant and
writes are untouched.

Signed-off-by: Cong Wang <cwang@multikernel.io>
learn kept its own copy of the rule for reads the supervisor serves
unasked. The two agree today, but the core's side is about to learn
about more entries than net, and a copy that lags would make learned
profiles drop a read that needs a grant, or carry grants that do
nothing. The core's predicate is now public and learn calls it.

Signed-off-by: Cong Wang <cwang@multikernel.io>
The own-entry reads treated everything under /proc/self as the task's
own data except net. The mount files are the same kind: they show the
host's mount namespace, and sandlock replaces them with a virtual table.
Only /proc/mounts, /proc/self/mounts and /proc/self/mountinfo reached
that virtualization, so /proc/thread-self/mounts, the numeric and
task/<tid> spellings, and all of mountstats were served as they are, to
every sandbox and with no /proc grant.

One list now names the per-task entries that belong to a namespace
rather than to the task, and every spelling of them is rewritten to the
name the virtualization matches. mountstats had no virtual form, so it
gets one, generated from the same --fs-mount list as the other two. The
own-entry reads refuse the whole list, so an entry added to it later
cannot reach the real file before it has a virtual one.

Signed-off-by: Cong Wang <cwang@multikernel.io>
The file is one of a task's own reads, so every sandbox could open it
with no grant, and it names the host's cgroup path: the user slice, the
session scope, the terminal that launched the sandbox. None of that
describes the task. Every per-task spelling now returns "0::/", which is
what a task reads from inside a cgroup namespace of its own.

Signed-off-by: Cong Wang <cwang@multikernel.io>
@congwang-mk
congwang-mk marked this pull request as draft September 21, 2026 05:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

run: /proc/self/* grants only cover the top-level process, child processes are denied

1 participant