Skip to content

LXC exec timeout reaper can be defeated by the sandboxed workload #871

Description

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.

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions