Skip to content

[LXC] Rewrite the /etc/hosts proxy pin from Rust with an open-once, no-follow handle #875

Description

What

Rewrite the container's /etc/hosts proxy pin from Rust with an open-once,
no-follow file handle instead of the current grep + printf shell command
that lxc_runner sends through attach_run.

Why

The shell command is in LxcScriptRunner::build_hosts_pin_command and
build_hosts_unpin_command, both of which share
LxcScriptRunner::hosts_read_prologue
(src/backends/lxc/common/src/lxc_runner.rs). It reads the file into a shell
variable, then truncates the file and writes the variable back. Three known
gaps follow from that shape, and the doc comment on hosts_read_prologue
already records the last two as unclosed:

  1. The whole file is buffered in shell state. A workload that ran earlier
    in a reused container can enlarge /etc/hosts, and the command then does
    unbounded command-substitution work and holds the entire result in memory
    before the rewrite. Raised in review on PR [LXC] Enforce the deny-all-except-proxy network policy (model 2) #798.

  2. A successful read still loses NUL bytes, because a shell variable
    cannot hold one. A hosts file containing a NUL is rewritten truncated at
    that byte and still exits 0, so nothing observes the loss.

  3. A symlink swapped in between the -h test and the > redirect is still
    followed.
    The -h test covers the unraced shape only. A dangling
    symlink is the worst case: it fails -e as well, and a redirect onto a
    dangling link creates the link's target, which on a writable host bind
    mount lands outside the container.

All three come from the same constraint -- the command has to work on BusyBox,
so it is restricted to grep and printf, and neither can open a file once
and rewrite it in place.

Proposed fix

Do the rewrite from Rust against /proc/<init_pid>/root/etc/hosts, which is
the container's own mount namespace as seen from the host, so no helper binary
has to exist inside the container. Open with O_NOFOLLOW, stream the filter
rather than buffering the file, and write through the same descriptor.

Two things to get right, and neither is free:

  • O_NOFOLLOW only protects the final component. A hostile /etc inside the
    container still redirects the open, so this wants openat2 with
    RESOLVE_NO_SYMLINKS, or a component-by-component openat walk, rather
    than a single open on the joined path.
  • The current command runs inside the container's namespaces via attach_run.
    Reaching the file through /proc/<pid>/root from the host instead changes
    which credentials the write happens under, so the permission model needs to
    be re-checked rather than assumed equivalent.

Not urgent

The pin is only written when a proxy is configured, /etc/hosts is normally
a few hundred bytes, and gaps 2 and 3 both need a workload that is already
manipulating its own /etc/hosts. This is correctness and robustness debt,
not a live break.

Raised by Gudge (@MGudgin) in review on #798:
#798 (comment)

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions