Repository navigation
Stop a contained process typing into your terminal - #186
Merged
Merged
Conversation
A contained process gets nvx's terminal as its stdin, shared with nvx, so the terminal is its controlling terminal. ioctl(fd, TIOCSTI, &c) pushes a byte into that terminal's input queue, and after nvx exits the user's shell reads the queue as typed input. A package postinstall could leave a command and an Enter behind that run as the user outside the sandbox. TIOCLINUX does the same by pasting a selection on a Linux virtual console. This is the class of bubblewrap's CVE-2017-5226. Linux: add a seccomp filter that refuses ioctl with request TIOCSTI or TIOCLINUX, returning EPERM. It compares the low 32 bits of the request, as Flatpak and bubblewrap do, because the kernel reads the request as 32 bits. It is a filter of its own, installed where the network filter is, so it applies in every network mode including open and on amd64 and arm64. Kernels from 6.2 refuse TIOCSTI themselves when dev.tty.legacy_tiocsti is 0, but older kernels and any with the sysctl at 1 allow it, and Ubuntu 22.04's 5.15 is one of them. Measured on a 5.15 kernel in a QEMU VM, a contained process typed a marker into the terminal before the filter and the terminal echoed it; with the filter both requests return EPERM. On WSL2 6.18, where the kernel refuses TIOCSTI with EIO, the filter's EPERM is distinguishable from that refusal. TestContainedProcessCannotTypeIntoTheTerminal drives the real chain on a pseudo-terminal and TestTerminalFilterRefusesTypingAndNothingElse checks the filter on the running kernel, both in the privileged CI step. macOS: deny TIOCSTI in the Seatbelt profile by command number, (deny file-ioctl (ioctl-command 2147578994)) after the write allow. file-ioctl is a separate Seatbelt operation, not implied by file-read* or file-write*, so (deny default) already refuses it and the profile grants file-ioctl nowhere; the explicit deny is insurance for the macOS versions where that default-deny of this one command is not confirmed. The command is decimal because the TIOCSTI symbol and a hex literal do not parse under sandbox-exec on macOS 13 and 14. TestSeatbeltDeniesTerminalInputInjection pins the rule and that nothing re-grants file-ioctl. sandbox-terminal-injection-macos.sh runs the real attempt on a controlling terminal in the macOS CI job, with an uncontained control that must inject. Windows: the OS already refuses WriteConsoleInput to an AppContainer process on the shared console. TestAContainedProcessCannotInjectConsoleInput (NVX_PROBE=1) opens CONIN$, the inherited stdin and the parent's console via AttachConsole, and all three writes are refused with Access denied, measured on Windows 11. It is kept as a regression probe. Document the guarantee per platform in SECURITY.md and the enforcement matrix, and add a CHANGELOG entry.
Two CI failures on the first run of this branch. Ubuntu, the unprivileged unit step: the proxy case of TestContainedProcessCannotTypeIntoTheTerminal needs a network namespace, and Ubuntu 24.04 lets an unprivileged user namespace be created but refuses CAP_NET_ADMIN inside it, so the supervisor's bringUpLoopback gets EPERM and fails closed with "Network isolation failed". requireNamespaceSupport cannot catch this, because creating the namespace succeeds and only configuring it is refused, so the target never started and the case failed. failOrSkip now treats that refusal as a skip, as it already did for the mount-namespace refusal the open case hits. The privileged step runs both under sudo, where neither fires, so coverage is kept. The decision is a pure function, terminalStartupSkipReason, with a test that skips on both refusals and the exact message CI produced and does not skip a genuine failure. macOS, the terminal-injection step: the contained attempt never ran. The helper path reached the spawned process through an environment variable, which nvx scrubs from a contained process, so the path was undefined; and the helper was the runner's /usr/bin/python3, an Xcode shim that writes an xcrun cache outside the sandbox's writable paths. The ioctl is now a small C helper compiled in the script, with no run-time dependency on xcrun, and its path is passed as an argument and resolved against the working directory. The whole chain was exercised on Linux first (forkpty, nvx contains node, node spawns the helper, the helper does the ioctl on the controlling terminal): the contained attempt returned EPERM from the seccomp filter, the control returned the host kernel's own refusal.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
A contained process is given nvx's terminal as its stdin, shared with nvx, so
the terminal is its controlling terminal.
ioctl(fd, TIOCSTI, &c)pushes a byteinto that terminal's input queue, and after nvx exits the user's shell reads the
queue as typed input. A package postinstall could leave
curl evil | shand anEnter behind, and it would run as the user outside the sandbox.
TIOCLINUXdoesthe same by pasting a selection on a Linux virtual console. This is the class of
bubblewrap's CVE-2017-5226.
Linux
A new seccomp filter refuses
ioctlwith requestTIOCSTIorTIOCLINUXandreturns
EPERM. It compares the low 32 bits of the request, as Flatpak andbubblewrap do, because the kernel reads the request as 32 bits. It is a filter of
its own, installed where the network filter is, so it applies in every network
mode including
open, and on amd64 and arm64.Kernels from 6.2 refuse
TIOCSTIthemselves whendev.tty.legacy_tiocstiis 0,but kernels before 6.2 and any with the sysctl at 1 allow it. Ubuntu 22.04's
5.15 is one of them, and nvx supports it because Landlock needs 5.13.
Measured on a 5.15 kernel in a QEMU VM (built from
ubuntu:22.04'slinux-image-generic), a contained process typednvx-typed-thisinto theterminal before this change and the terminal echoed it; with the filter both
requests return
EPERM, unprivileged and as root. On WSL2 6.18, where the kernelrefuses
TIOCSTIwithEIO, the filter'sEPERMis distinguishable from thatrefusal.
TestContainedProcessCannotTypeIntoTheTerminaldrives the real chain ona pseudo-terminal and
TestTerminalFilterRefusesTypingAndNothingElsechecks thefilter on the running kernel; both run in the privileged CI step and both fail on
the old code (EIO/ENOTTY instead of EPERM).
A fresh pty the contained process opens itself is its own and reaches nothing
outside the sandbox; on Linux the sandbox's filesystem view has no
/dev/ptmx,so it cannot open one anyway, and the filter would block it regardless.
macOS
The Seatbelt profile now denies
TIOCSTIby command number,(deny file-ioctl (ioctl-command 2147578994))after the write allow.file-ioctlis a separate Seatbelt operation, not implied byfile-read*orfile-write*, so(deny default)already refuses it and the profile grantsfile-ioctlnowhere; the explicit deny is insurance for the macOS versions wherethe default-deny of this one command is not confirmed. The command is decimal
because the
TIOCSTIsymbol and a hex literal do not parse undersandbox-execon macOS 13 and 14.
TestSeatbeltDeniesTerminalInputInjectionpins the rule and that nothingre-grants
file-ioctl.scripts/sandbox-terminal-injection-macos.sh, in a newmacOS CI step, runs the real attempt on a controlling terminal built with
forkpty: a contained node spawns the runner'spython3, which inherits thesandbox and the terminal and attempts
TIOCSTI, with an uncontained control thatmust inject. This step is the one piece I could not run locally; it iterates
through this PR's CI.
Windows
The OS already refuses
WriteConsoleInputto an AppContainer process on theconsole it shares with the shell.
TestAContainedProcessCannotInjectConsoleInput(
NVX_PROBE=1) opensCONIN$, the inherited stdin and the parent's console viaAttachConsole, and all three writes are refused with "Access is denied",measured on Windows 11. The read-back path was confirmed to catch an injection by
planting the same records uncontained and reading them back. No code change was
needed; it is kept as a regression probe.
Docs
A new row in
docs/enforcement-matrix.mdandSECURITY.md, per platform withevidence, plus a CHANGELOG entry under Security.
Verification
gofmt -l internal cmdclean;go vet ./...,GOOS=linux go vet ./...,GOOS=darwin go vet ./...clean.go test ./internal/nvx -count=1passes on Windows.both fail on the old code.
NVX_PROBE=1.bash -non the new script;ci.ymlparses.Do not merge.