Skip to content

#667 [security][tools] OS の隔離の中から制御端末へ TIOCSTI で入力を注入でき、/dev/pts の他の端末へ書ける - #674

Merged
Kewton merged 8 commits into
developfrom
feature/issue-667-security-tools-os-tiocsti-dev-pts
Oct 14, 2026
Merged

Kewton merged 8 commits into
developfrom
feature/issue-667-security-tools-os-tiocsti-dev-pts

Conversation

@Kewton

@Kewton Kewton commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

概要

OS の隔離(landlock・Seatbelt)の中のコマンドが、次のことをできた問題を閉じます。

  • 制御端末へ TIOCSTI で入力を注入する。
  • /dev/pts の他の端末へ書き込む。
  • run の端末をパスで開き直して、利用者が打つキーを読む(merge 前レビュー H-32 の blocker B1。この PR の中で直しました)。

Refs #667

変更(develop 比 5 ファイル、+1384 −16、6 commit)

  • 子の session(src/bounded_process.rs)
    • bounded_process で起動する子は、pre_exec の setsid() で新しい session に入り、制御端末を持たなくなります。これまでの process_group(0) の代わりで、PID = PGID は保つので、終了処理(-pid だけに送る)は変わりません。
    • kernel は TIOCSTI を呼んだ側の制御端末にしか許さないので、注入は拒否されます。
    • 端末が stdin のときは、同じ pre_exec で /dev/null に付け替えます。setsid の後は job control による読み取りの拒否が効かないためです。端末でない stdin はそのままです。
    • setsid が入るのは spawn_child の 1 箇所だけで、run_with_timeout* もそこを通ります。
  • Linux(src/tools/os_sandbox/landlock.rs)
    • 書き込みと IoctlDev の許可から /dev/pts・/dev/tty を外しました。
    • /dev/pts・/dev/tty・/dev/console・/dev/tty* を読み取りの拒否に加え、expand_read_allow の補集合から外しました。/dev には ReadDir だけを与えます(ls /dev は動き、端末の子は ReadFile されません)。必要な ABI は ABI1 のままです。
  • macOS(src/tools/os_sandbox/seatbelt.rs):profile の最後で /dev/tty* の書き込みと読み取りを再び拒否します(/dev/null などは今どおり使えます)。
  • SECURITY.md:端末の隔離の節を書き直しました。
    • 隔離の中は、端末デバイスをパスで読めず書けません。
    • 引き継いだ stdout・stderr の fd は再検査されず、境界の外です。ただし製品の隔離された起動点(Bash、verifier、依存の導入)は、stdout・stderr を pipe にしています。
    • 隔離なしで起動した子は境界ではありません。
  • 試験(tests/issue502_os_sandbox_write.rs、試験が自分で作った pty だけを使います):
    • session と PGID の確認。
    • TIOCSTI の注入:正の対照があり、対照が成り立たなければ UNVERIFIED。
    • 他の端末への書き込み。
    • 端末の stdin の付け替え。
    • /dev/null の fd が残らないこと。
    • 端末をパスで開き直す読み取り(reopen・setsid_reopen)が、隔離の中で ERR になること(隔離の外の正の対照では読める)。
    • 単体試験:Seatbelt の規則と順序、landlock の端末デバイスの選び方。

利用者に見える挙動の変化

  • 隔離の中のコマンドは pty を使えません。/dev/ptmx は開けますが、slave(Linux の /dev/pts/*、macOS の /dev/ttys*)が拒否されるため、openpty・script・expect・pexpect は失敗します。[security][tools] OS の隔離の中から制御端末へ TIOCSTI で入力を注入でき、/dev/pts の他の端末へ書ける #667 の前は使えました。端末の読み書きを閉じるための代償で、SECURITY.md に書いています。
  • 子は制御端末を持ちません。/dev/tty を直接開いてキーを待つ対話の経路(たとえば対話の pager)は、制御端末がないと見えるようになります。
    • git の pager の非対話の経路(出力を捕まえて、pager が stdout に書く)は変わりません。
    • setsid が効く起動点は、bounded_process を通る 22 箇所です(製品コード。Bash、verifier、依存の導入、git の状態の確認、各 probe、dev server など)。いずれも stdin は null・pipe・継承のどれかで、出力を捕まえています。

検証(head 56c67689)

  • 15 ゲート:passed 15/15(最後の commit の後、clean)。
  • cargo deny ok、Linux の確認(cargo-zigbuild clippy --all-targets --target x86_64-unknown-linux-gnu)ok。
  • macOS の適用の試験(--include-ignored):write 29/29、read 10/10、network 1+9。
  • Docker(Linux 7.0.14、uid 1000、--network none、mount なし):write 28/28、read 12/12、network 7/7。端末の試験(TIOCSTI・stdin・/dev/null の fd・パスでの読み取り)はすべて BLOCKED、UNVERIFIED 0。
  • CI:GitHub Actions の runner は legacy_tiocsti=0 で、TIOCSTI の正の対照も EIO になるため、TIOCSTI の試験は測れず UNVERIFIED になります($GITHUB_STEP_SUMMARY に記録)。TIOCSTI の拒否は、Docker(legacy_tiocsti=1)と macOS の正の対照つきの BLOCKED で確かめています。
  • 二点測定:
    • session/TIOCSTI と stdin の試験は、base(develop)で FAILED、HEAD で pass。
    • /dev/null の fd の試験は、変異を手で入れた状態で FAILED、HEAD で pass。
    • B1 の試験は、569e85de で FAILED(隔離の中で READ)、HEAD で pass(Docker・macOS の両方)。
  • 字句検査の probe(2507 行):base と HEAD の差 0(緩む 0、締まる 0)。
  • mutants:子を起動しない単体試験(macOS、env -i・一時 HOME)と、Docker の中(uid 1000、--network none)だけで流しました。signalable_process_group・group_is_signalable は除外しています([tests][mutants] 変異試験が防御を外したまま kill(-1) などを実行し、利用者の全プロセスを止める #673)。
    • bounded_process(configure_child_session・detach_terminal_stdin)12 本:caught 8、missed 4。missed 4 は、fork した子では失敗しない setsid・dup2 の失敗の経路と、等価な open の判定で、受け入れました。
    • Seatbelt profile 2 本:caught 2。
    • landlock(差分の行)12 本:caught 11、unviable 1(create_ruleset → Ok(Default)、OwnedFd に Default がない)。

レビュー

  • H-32(Claude_Sub):blocker B1。隔離の中から run の端末をパスで開き直すとキーを読めました(base では job control の EIO)。端末デバイスの読み取りの拒否と試験・文書で直しました。
  • H-33(fix-only の再確認):merge 可、blocker 0。指摘の F1(pty の記述)は文書とコメントだけで直しました。

後続の論点

🤖 Generated with Claude Code

Kewton and others added 7 commits October 14, 2026 01:25
Issue #667 (F1/F2). A bounded child used `process_group(0)`, so it stayed in
the caller's session and could inject into the controlling terminal with
`TIOCSTI`, and Landlock allowed writes to the whole `/dev/pts` directory, so it
could write to another terminal's slave.

- `bounded_process`: start each child with `setsid()` instead of
  `process_group(0)`. The child keeps a process group whose id equals its pid,
  so `terminate_process_group*` still signals exactly its own group, and it now
  has no controlling terminal, so the kernel refuses `TIOCSTI`.
- `landlock`: drop `/dev/tty` and `/dev/pts` from the write allow list. On ABI5+
  this also leaves them out of the handled `IoctlDev` access. `/dev/ptmx` stays.
- `seatbelt`: keep the `/dev` write allow and re-deny `/dev/tty*` last, so
  `/dev/null` and friends still work but terminal slaves do not.
- Tests: session/process-group detachment, a pty-based `TIOCSTI` probe with a
  positive and negative control, a terminal-slave write denial on both backends,
  and a `git` pager case; plus a Seatbelt profile-order unit test.
- SECURITY.md documents the change and the visible difference for a program that
  opens `/dev/tty` directly.

判断: removed `/dev/tty` from the Linux write allow as well as `/dev/pts`, so
`TIOCSTI` is closed even below Landlock ABI5 where device ioctls cannot be
filtered; the child has no controlling terminal, so it has no use for `/dev/tty`.
判断: kept `/dev/ptmx` writable and, on macOS, kept the broad `/dev` allow with a
last-match `/dev/tty*` deny instead of enumerating device literals, to keep the
change minimal and avoid breaking the required-tool cases.
判断: relied on `setsid()` for `TIOCSTI` rather than adding a dedicated ABI5
`IoctlDev` rule; a `TIOCSTI` attempt also needs the terminal to be the process's
controlling terminal, which the child no longer has.
本文に無い指摘: an interactive pager that reads keypresses from `/dev/tty`
directly now sees no controlling terminal; standard streams and the captured
(non-interactive) pager path are unchanged.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Issue #667 follow-up. `setsid()` removes the controlling terminal, so the
job-control check no longer applied and a child that inherited the caller's
terminal on fd 0 could read the keys the user types during a run (previously a
background process group was refused with `SIGTTIN`/`EIO`).

- `bounded_process`: in the same `pre_exec`, after `setsid()`, replace a
  terminal fd 0 with `/dev/null` (`isatty`/`open`/`dup2`/`close` only). A
  `Stdio::null()`, piped, or file stdin is unchanged, as are stdout and stderr.
- Tests: a probe that makes its own pty the controlling terminal and fd 0, then
  starts a child with stdin left unset through `bounded_process` in both the
  isolated and unisolated launches, and asserts `isatty(0)` is false and the
  read returns EOF at once.
- `SECURITY.md`: correct the stdin statement and list the launch points that
  inherited a terminal stdin.

判断: only a terminal stdin is replaced; pipes, files, `/dev/null`, and every
standard output and error are left alone, so a caller that pipes input keeps
working.
判断: the probe asserts the unisolated launch always and the isolated launch only
when the sandbox backend probes as available, printing which legs it measured.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Issue #667 follow-up. To replace a terminal stdin the launcher opens
`/dev/null` in the forked child and duplicates it onto fd 0, then closes the
descriptor it opened. No test pinned that `close`, so mutating the guard
`if devnull != 0` to `devnull == 0` left the descriptor open without failing
anything, and the exec'd command inherited an extra `/dev/null` descriptor.

- Tests: a probe that makes its own pty the controlling terminal and fd 0, then
  starts a child with stdin left unset through `bounded_process` in both the
  isolated and unisolated launches. The child reports every descriptor above the
  standard three whose device is `/dev/null`, and the probe asserts that list is
  empty. A positive control opens an extra `/dev/null` in the child first, so the
  scan is shown to see a retained descriptor.
- The probe runs in a fresh process, like the existing stdin probe, and only
  ever uses the pty the probe created.

判断: only the test file changes; the product guard in `bounded_process` is
unchanged. The `devnull == 0` mutation now fails the new test
(`DEVNULL_FDS 9`), while the unmutated build passes it.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Issue #667 (H-32). `setsid()` closes `TIOCSTI`, but a confined child can still
re-open the run's terminal by path (`/dev/pts/N`, `/dev/ttys*`, `/dev/tty`) with
`O_RDONLY|O_NOCTTY` and read the keys the user types. Base refused that read only
through job control (`SIGTTIN`/`EIO`), which `setsid()` removed, so `SECURITY.md`
claimed the read was impossible. Keep `setsid()` (it is what closes `TIOCSTI`)
and close the path instead.

- `seatbelt`: re-deny `^/dev/tty` for `file-read*` after the terminal write
  re-deny, so a confined command cannot re-open a terminal slave for reading;
  `/dev/null`, `/dev/urandom`, `/dev/ptmx`, and the rest of `/dev` are unchanged,
  and the already-open standard descriptors are not re-checked.
- `landlock`: add `/dev/pts`, `/dev/tty`, and every existing `/dev/tty*` and
  `/dev/console` to the read-deny list, so the expansion leaves them (and their
  subtrees) out of the read allow list. `/dev` is an ancestor of a denied
  location, so it is granted `ReadDir` alone through a new `list_only` rule: the
  listing keeps working (`ls /dev`) while `ReadFile` on the terminal children
  stays denied. The read layer still needs only ABI1.
- Tests: a pty probe that makes its own pty the controlling terminal and fd 0,
  writes one typed line to the master, and starts a child through
  `bounded_process` that re-opens the slave by path and reads it; `reopen` and
  `setsid_reopen` must fail (`ERR`) inside the isolation and must read outside it
  as the positive control, with `UNVERIFIED` when the backend is unavailable.
  Unit tests pin the seatbelt rule and its order and the landlock read allow.
- `SECURITY.md`: a confined command cannot open a terminal device by path for
  reading or writing; a terminal stdin becomes `/dev/null`; an inherited
  stdout/stderr descriptor is not re-checked, but the confined launches set both
  to pipes; an unconfined launch is not a boundary.

判断: the read deny is by path on the device, so it closes the keystroke read
without touching the inherited stdio; the product's confined launches pipe
stdout/stderr, so no confined command holds a terminal descriptor.
判断: landlock keeps `/dev` listable with a `ReadDir`-only grant instead of
allowing the whole tree, so the terminal children stay unreadable, and
`read_isolation_gaps` keeps reporting the same meaning.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Issue #667 follow-up. `terminal_read_deny_paths` selects the terminal devices
with `name == "console" || name.starts_with("tty")`, but no unit test pinned the
selection, so the mutants `||` to `&&` and `==` to `!=` were not detected; both
widen the read allow list and make the physical console and serial terminals
readable inside the isolation.

- Split the reader into `terminal_read_deny_paths_in(dev: &Path)`, which builds
  the hardcoded `pts` and `tty` entries relative to `dev` and enumerates `dev`,
  so a test can drive it against a tempdir; `terminal_read_deny_paths()` now
  returns `terminal_read_deny_paths_in(Path::new("/dev"))` and is unchanged for
  the real `/dev`.
- Tests: a tempdir with `pts/`, `shm/`, and `tty`, `console`, `tty0`, `ttyS0`,
  `ttyUSB0`, `null`, `urandom`, `ptmx` must yield exactly the six terminal
  entries, so the `||`/`&&` and `==`/`!=` mutants change the set and fail it.
  The existing `terminal_read_deny_paths_names_the_pty_directory_and_the_controlling_tty`
  test stays.

判断: only the read-deny selection is refactored; `terminal_read_deny_paths()`
returns the same list for `/dev`, so the product behavior is unchanged.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Issue #667 follow-up (H-33). The comments and SECURITY.md said `/dev/ptmx` stays
usable "so allocating a pty still works". Inside the isolation `/dev/ptmx` is
openable but the pty slave it creates is refused (Linux: `/dev/pts` is neither
readable nor writable; macOS: `/dev/tty*` is denied), so `openpty`, `script`,
`expect`, and `pexpect` cannot use a pty. That pty loss is the cost of closing
terminal reads and writes; it worked before #667.

- `landlock.rs`: correct the `DEVICE_PATHS` comment; the `/dev/pts` denial
  already documents why the slave is refused.
- `seatbelt.rs`: correct the `WRITE_PROFILE` comment the same way.
- `SECURITY.md`: state that `/dev/ptmx` is openable but the pty slave is refused,
  so a confined command cannot allocate a pty, and that this is a #667 cost.

判断: comments and one documentation sentence only; no code line or test changes,
so the behavior is unchanged.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Issue #667 (PR #674 CI). On a runner with `legacy_tiocsti=0` the positive
control cannot inject, so the probe correctly reports UNVERIFIED — but
`record_unverified` wrote it with `eprintln!`, and the outer test
`isolated_child_cannot_inject_into_the_controlling_terminal` looks for the
verdict only on stdout, so it failed as if no verdict were reported. The stdin,
devnull-fd, and terminal-read probes already print their UNVERIFIED verdict on
stdout.

- Print the verdict with `println!`; the `$GITHUB_STEP_SUMMARY` line is still
  recorded, so an unmeasurable environment is still not a silent pass.

判断: the verdict line only moves from stderr to stdout; no expectation or other
line changes.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
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