What
PR #849 added a timeout reaper for LXC execs (reap_marked_processes). It
finds the exec's processes by an inherited environment variable,
MXC_EXEC_ID. That works for a workload that is not trying to escape it, and
it is what leaks into the next exec in practice, but it is not a boundary
against untrusted code, which is what runs in the sandbox:
env -u MXC_EXEC_ID sh -c 'sleep 100 &'
The descendant carries no marker. The scan finds nothing, the reap returns
success, the timeout is reported, and that process survives into every later
exec of a container that is persistent by design.
#867 covered the leak and is closed by #849. This issue is the security
half: the reaper needs membership the workload cannot revoke.
Why the marker was shipped anyway
The alternative available in-round was a container restart, which discards
the ingress chain living in the container's network namespace along with the
rest of start-time enforcement — trading an orphaned process for an
unfiltered sandbox. The marker is documented in
src/backends/lxc/common/src/lxc_bindings.rs as hygiene rather than
containment, so nothing in the tree claims a guarantee it does not deliver.
What a real fix looks like
The membership has to be kernel-owned. Two candidates:
A per-exec PID namespace. lxc-attach joins the container's existing
namespaces through setns(); -s/--namespaces only selects which existing
ones to join, so it cannot create one
(lxc-attach(1)).
The namespace therefore has to be unshared by the attached command:
lxc-attach ... -- unshare --pid --fork /bin/sh -c '<command>'
The forked child becomes PID 1 of a nested namespace, and no descendant can
leave a PID namespace, so killing that PID 1 takes every descendant with it.
Open questions before this can ship:
- Is
unshare present in the container images we support, including busybox
builds?
- Does the container's user namespace grant the
CLONE_NEWPID privilege in
the unprivileged case?
--mount-proc implies a mount namespace and changes the command's view of
/proc; without it ps inside the exec reports the container's namespace.
Which is the right default?
- How does the reaper locate the nested PID 1 from outside? It is a child of
the unshare process, which is a descendant of lxc-attach.
- Does killing
lxc-attach on timeout orphan the nested init rather than
kill it?
A per-exec cgroup. cgroup.kill (kernel 5.14+) kills a whole cgroup
atomically. It needs cgroup v2 mounted and delegated inside the container,
and a workload running as root inside the container may be able to move
itself out, so it is likely weaker than the PID namespace.
Why this is not in #849
Every question above is a fact about a live LXC host. #849 was authored on a
box with no LXC, where tests/scripts/run_lxc_state_aware_test.sh cannot run
and exits 77. Shipping an untested containment mechanism is worse than
shipping a documented best-effort one: a boundary that silently fails is
trusted, and a documented non-boundary is not.
Acceptance
- An exec that times out leaves nothing running in the container, including
descendants that scrubbed their environment.
- Concurrent execs in the same container still do not reap each other.
- Verified against a live LXC host, not only at the command-construction
level.
What
PR #849 added a timeout reaper for LXC execs (
reap_marked_processes). Itfinds the exec's processes by an inherited environment variable,
MXC_EXEC_ID. That works for a workload that is not trying to escape it, andit is what leaks into the next exec in practice, but it is not a boundary
against untrusted code, which is what runs in the sandbox:
env -u MXC_EXEC_ID sh -c 'sleep 100 &'The descendant carries no marker. The scan finds nothing, the reap returns
success, the timeout is reported, and that process survives into every later
exec of a container that is persistent by design.
#867covered the leak and is closed by #849. This issue is the securityhalf: the reaper needs membership the workload cannot revoke.
Why the marker was shipped anyway
The alternative available in-round was a container restart, which discards
the ingress chain living in the container's network namespace along with the
rest of start-time enforcement — trading an orphaned process for an
unfiltered sandbox. The marker is documented in
src/backends/lxc/common/src/lxc_bindings.rsas hygiene rather thancontainment, so nothing in the tree claims a guarantee it does not deliver.
What a real fix looks like
The membership has to be kernel-owned. Two candidates:
A per-exec PID namespace.
lxc-attachjoins the container's existingnamespaces through
setns();-s/--namespacesonly selects which existingones to join, so it cannot create one
(lxc-attach(1)).
The namespace therefore has to be unshared by the attached command:
The forked child becomes PID 1 of a nested namespace, and no descendant can
leave a PID namespace, so killing that PID 1 takes every descendant with it.
Open questions before this can ship:
unsharepresent in the container images we support, including busyboxbuilds?
CLONE_NEWPIDprivilege inthe unprivileged case?
--mount-procimplies a mount namespace and changes the command's view of/proc; without itpsinside the exec reports the container's namespace.Which is the right default?
the
unshareprocess, which is a descendant oflxc-attach.lxc-attachon timeout orphan the nested init rather thankill it?
A per-exec cgroup.
cgroup.kill(kernel 5.14+) kills a whole cgroupatomically. It needs cgroup v2 mounted and delegated inside the container,
and a workload running as root inside the container may be able to move
itself out, so it is likely weaker than the PID namespace.
Why this is not in #849
Every question above is a fact about a live LXC host. #849 was authored on a
box with no LXC, where
tests/scripts/run_lxc_state_aware_test.shcannot runand exits 77. Shipping an untested containment mechanism is worse than
shipping a documented best-effort one: a boundary that silently fails is
trusted, and a documented non-boundary is not.
Acceptance
descendants that scrubbed their environment.
level.