Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions issues/isolation/the-launcher-starts-any-row-for-any-holder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
status: open
kind: defect
opened: 2026-10-03
---

# The launcher starts any declared row for any holder of it

A holder of a `launcher` connector can start **any** `[programs]` row and is
handed that row's whole authority. Three facts compose:

- **`launcher` is inherited, not granted per child.** A direct spawn copies the
caller's `svc` namespace into the child (`inherited_namespace`,
`rust/library/std/src/sys/process/toyos.rs`), and std routes every declared
program through the launcher when the caller holds one and endowed nothing
itself (the routing rule in `Command::spawn`/`Command::launch`, same file). So
every program in a shell-started subtree holds the shell's `launcher`.
- **The supervisor never checks which rows a caller may start.** `serve_launch`
(`userland/supervisor/src/main.rs`) resolves `request.program` to a declared
row and starts it holding that row's `receives`, `devices`, `syscap` and
`slots` — for any caller that reached the port. A plain app launched from the
shell can therefore launch `/system/bin/swap` and receive the swap connector,
or `/system/bin/update` and receive the idle slot's partition claims.
- **`accept_swap` checks the requester's own digest, not its identity.** It
verifies the service exists, is a running `[boot] start` service, and that the
staged bytes match the requester's `digest` — never which program asked
(`accept_swap`, same file). The swap connector is gated to `/system/bin/swap`
by the build (`held_by_their_holders_alone`, `src/build.rs`), but fact one and
fact two reach that row anyway. So any program can rewrite any boot service's
binary.

The per-program-views track names the same shape in the service namespace ("the
swap port leaking to whatever sshd spawns",
`issues/isolation/every-program-sees-only-the-files-it-was-given.md`), and it is
distinct from where a swapped binary lives
(`issues/isolation/a-swapped-binary-lives-where-any-process-can-rewrite-it.md`):
that one is which bytes a declared swap runs, this one is who may invoke a
declared row at all.

**The owner's ruling:** each program may start only the rows its own row lists;
swap and update of system programs only from the login session.

**Owner.** The orchestrator.

**Exit condition.** A guest test that is red until both halves of the ruling
hold: a launch of a row the caller's own row does not list is refused —
`/system/bin/swap` and `/system/bin/update` through an inherited `launcher`
among them — and a swap and an update of a system program asked from outside
the login session are refused.
13 changes: 7 additions & 6 deletions kernel/src/loader/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,15 +340,16 @@ fn rela_dyn_from_sections(
/// child's handle table around the child's handle to itself, once nothing is
/// left to refuse.
///
/// `image` is the program's bytes when the caller read them itself, and then
/// `argv[0]` is only its name: nothing opens it, and its libraries come from
/// `path` is the program, opened here — or, with `image`, the bytes the caller
/// read from it itself, and then nothing opens it and its libraries come from
/// `/system/lib` alone, since the directory it names is no volume of this
/// kernel's.
/// kernel's. `argv[0]` is only the name the child goes by.
///
/// `Refusal`, not `-> !`, is the error type: every failure below owns a
/// partly built process (address space, stack, kernel stack), and nothing
/// unwinds, so the error must travel out as a value rather than strand it.
pub fn spawn<H>(
path: &str,
argv: &[&str],
commit: impl FnOnce(crate::object::HandleEntry) -> Result<(HandleTable, Endowments, H), crate::object::Refusal>,
cwd: String,
Expand All @@ -357,7 +358,7 @@ pub fn spawn<H>(
parent: Parent,
) -> Result<(Pid, H), crate::object::Refusal> {
// An argv of only separators survives sys_spawn's split as an empty slice.
let Some(&path) = argv.first() else {
let Some(&name) = argv.first() else {
return Err(SyscallError::InvalidArgument.into());
};
// Before anything is built; dropped on every way out below but the insert.
Expand Down Expand Up @@ -646,7 +647,7 @@ pub fn spawn<H>(
let ((tid, dst), retire) = admission.land(guard.as_mut().unwrap(), |table, node| {
table.insert(ProcessEntry::new(
Arc::clone(&object),
start::make_name(path),
start::make_name(name),
proc_data,
Arc::clone(&syms),
ThreadEntry::new(thread_data),
Expand Down Expand Up @@ -902,7 +903,7 @@ pub fn spawn_supervisor() -> Pid {
start::endow_self(&mut handles, &mut entries, &mut labels, own);
Ok((handles, Endowments::new(entries, labels), ()))
};
match spawn(&[SUPERVISOR_PATH], commit, String::from("/"), Vec::new(), None, Parent::Root) {
match spawn(SUPERVISOR_PATH, &[SUPERVISOR_PATH], commit, String::from("/"), Vec::new(), None, Parent::Root) {
Ok((pid, ())) => pid,
Err(crate::object::Refusal::Error(e)) => panic!("spawn_supervisor: failed to spawn: {e:?}"),
Err(crate::object::Refusal::Handle(e)) => panic!("spawn_supervisor: {e}"),
Expand Down
3 changes: 2 additions & 1 deletion kernel/src/syscall/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ pub(crate) fn syscall_dispatch(num: u64, a1: u64, a2: u64, a3: u64, a4: u64) ->
Ok(parent) => parent,
Err(refused) => return refused,
};
let path = match ctx.user_str(UserAddr::new(args.path_ptr), args.path_len) { Ok(s) => s, Err(e) => return e.to_u64() };
let text = match ctx.user_str(UserAddr::new(args.argv_ptr), args.argv_len) { Ok(s) => s, Err(e) => return e.to_u64() };
let cwd = match ctx.user_str(UserAddr::new(args.cwd_ptr), args.cwd_len).and_then(|p| spawn_cwd(&p)) {
Ok(cwd) => cwd,
Expand Down Expand Up @@ -219,7 +220,7 @@ pub(crate) fn syscall_dispatch(num: u64, a1: u64, a2: u64, a3: u64, a4: u64) ->
alloc::vec::Vec::new()
};
let argv: alloc::vec::Vec<&str> = text.split('\0').filter(|s| !s.is_empty()).collect();
sys_spawn(&argv, pending, cwd, env, image, parent)
sys_spawn(&path, &argv, pending, cwd, env, image, parent)
}
SYS_PROCESS_WAIT => sys_process_wait(RawHandle(a1 as u32), a2),
SYS_PROCESS_KILL => {
Expand Down
3 changes: 2 additions & 1 deletion kernel/src/syscall/proc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub(super) fn spawn_place(place: u64) -> Result<process::Parent, u64> {

/// Start a program in `cwd` under `parent` and return the handle to it the spawn's commit put in the caller's table.
pub(super) fn sys_spawn(
path: &str,
args: &[&str],
pending: crate::loader::PendingHandles,
cwd: alloc::string::String,
Expand All @@ -54,7 +55,7 @@ pub(super) fn sys_spawn(
parent: process::Parent,
) -> u64 {
// Nothing to clean up: spawn's frame owns the child's resources on error.
match process::spawn(args, |own| pending.commit(own), cwd, env, image, parent) {
match process::spawn(path, args, |own| pending.commit(own), cwd, env, image, parent) {
Ok((_, handle)) => u64::from(handle.0),
Err(e) => e.refuse(),
}
Expand Down
2 changes: 1 addition & 1 deletion rust
5 changes: 5 additions & 0 deletions tests/proctreecase/system.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
# A `launcher`, because the subtree under test has children the supervisor launches; a
# declared `cat`, which runs until its input ends; and a declared shell, which a
# depth chain launches and which detaches a `cat`.
#
# `launch_toctou` rides it too: a launch of `toybox` through a link it re-points.

[boot]
start = ["logkeeper", "diskserver", "fileserver", "test-runner"]
Expand All @@ -20,7 +22,10 @@ syscap = ["logread"]
receives = ["launcher"]
syscap = ["dup", "roster"]

# `roster`, which a child the job spawns itself never holds: a child holding
# it ran under this row, and `launch_toctou` asks which bytes that child was.
[programs.toybox]
syscap = ["roster"]

[programs.shell]
receives = ["launcher"]
Expand Down
4 changes: 4 additions & 0 deletions tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ fn spawn_path(path: &str) -> Result<u64, SyscallError> {
let argv = format!("{path}\0");
unsafe {
syscall::spawn(&SpawnArgs {
path_ptr: path.as_ptr() as u64,
path_len: path.len() as u64,
argv_ptr: argv.as_ptr() as u64,
argv_len: argv.len() as u64,
slot_map_ptr: 0,
Expand Down Expand Up @@ -283,6 +285,8 @@ fn spawn_image(path: &str, bytes: &[u8]) -> Result<u64, SyscallError> {
let object = image_object(bytes);
unsafe {
syscall::spawn(&SpawnArgs {
path_ptr: path.as_ptr() as u64,
path_len: path.len() as u64,
argv_ptr: argv.as_ptr() as u64,
argv_len: argv.len() as u64,
slot_map_ptr: 0,
Expand Down
2 changes: 2 additions & 0 deletions tests/toyos-rust-tests/src/bin/abuse_elf_segments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ fn spawn_as(path: &str, image: &[u8]) -> SyscallError {
unsafe { core::ptr::copy_nonoverlapping(image.as_ptr(), object.as_ptr(), image.len()) };
unsafe {
syscall::spawn(&SpawnArgs {
path_ptr: path.as_ptr() as u64,
path_len: path.len() as u64,
argv_ptr: argv.as_ptr() as u64,
argv_len: argv.len() as u64,
slot_map_ptr: 0,
Expand Down
4 changes: 4 additions & 0 deletions tests/toyos-rust-tests/src/bin/abuse_handle_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ fn main() {

let spawn_with = |slot_map_count: u64, endow_count: u64| {
let args = SpawnArgs {
path_ptr: argv as u64,
path_len: (ARGV0.len() - 1) as u64,
argv_ptr: argv as u64,
argv_len: ARGV0.len() as u64,
slot_map_ptr: region as u64,
Expand Down Expand Up @@ -185,6 +187,8 @@ fn spawn_endowed(entries: &[EndowEntry], labels: &[u8]) -> Result<RawHandle, Sys
// SAFETY: every pointer names a live local for the whole call.
unsafe {
syscall::spawn(&SpawnArgs {
path_ptr: SELF.as_ptr() as u64,
path_len: SELF.len() as u64,
argv_ptr: argv.as_ptr() as u64,
argv_len: argv.len() as u64,
slot_map_ptr: 0,
Expand Down
2 changes: 2 additions & 0 deletions tests/toyos-rust-tests/src/bin/abuse_page_straddle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ fn main() {
core::ptr::copy_nonoverlapping(argv.as_ptr(), base as *mut u8, argv.len());
}
let args = SpawnArgs {
path_ptr: base,
path_len: "/system/bin/echo".len() as u64,
argv_ptr: base,
argv_len: argv.len() as u64,
slot_map_ptr: 0,
Expand Down
20 changes: 13 additions & 7 deletions tests/toyos-rust-tests/src/bin/abuse_spawn_argv.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
//! The kernel must survive a process that hands `SYS_SPAWN` absurd argv/env.
//! The kernel must survive a process that hands `SYS_SPAWN` absurd path/argv/env.
//!
//! `argv_len` and `env_len` are raw `u64` fields of a user-supplied
//! `SpawnArgs`. The argv blob is split into a `Vec<&str>` (16 bytes per token)
//! and the env blob is copied with `to_vec()`, so either one sizes a kernel
//! allocation. An argv with no non-empty token also has no `argv[0]`.
//! `path_len`, `argv_len` and `env_len` are raw `u64` fields of a user-supplied
//! `SpawnArgs`. The path is copied, the argv blob is split into a `Vec<&str>`
//! (16 bytes per token) and the env blob is copied with `to_vec()`, so each one
//! sizes a kernel allocation. An argv with no non-empty token also has no `argv[0]`.

use std::process::Command;

Expand Down Expand Up @@ -36,6 +36,8 @@ fn main() {
}

let base = SpawnArgs {
path_ptr: region as u64,
path_len: 1,
argv_ptr: region as u64,
argv_len: 0,
slot_map_ptr: 0,
Expand Down Expand Up @@ -70,12 +72,16 @@ fn main() {
.expect_err("a 2 MiB env blob must be rejected");
assert_eq!(err, SyscallError::InvalidArgument, "wrong error for oversized env");

let err = unsafe { syscall::spawn(&SpawnArgs { path_len: BLOB as u64, argv_len: 2, ..base }) }
.expect_err("a 2 MiB path must be rejected");
assert_eq!(err, SyscallError::InvalidArgument, "wrong error for oversized path");

// The bound is a limit, not a ban: an argv the kernel will accept still
// has to reach the loader and fail there for its own reason.
let err = unsafe {
syscall::spawn(&SpawnArgs { argv_len: 2, ..base })
}
.expect_err("argv[0] = \"a\" is not a program");
.expect_err("\"a\" is not a program");
assert_eq!(err, SyscallError::NotFound, "wrong error for a short honest argv");

// No non-empty token: there is no argv[0] to load.
Expand All @@ -95,5 +101,5 @@ fn main() {
assert!(status.success(), "/system/bin/echo exited {status:?}");

unsafe { syscall::munmap(region, REGION) }.expect("munmap");
println!("oversized spawn argv/env rejected, spawn still usable");
println!("oversized spawn path/argv/env rejected, spawn still usable");
}
107 changes: 107 additions & 0 deletions tests/toyos-rust-tests/src/bin/launch_toctou.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! A launched row runs the row's own program, never the bytes a caller's path
//! names when it is opened again.
//!
//! The launcher names a `[programs]` row from the caller's path, following one
//! link, and the row's capabilities go to whatever it then starts. This binary
//! points a `/tmp` link at `/system/bin/toybox` — a row this boot gives the
//! `roster` capability — launches it under the name `ps` while a second thread
//! re-points the link between toybox and this binary, and judges every child by
//! its exit:
//!
//! - toybox's `ps` exits 0 only holding a `SysCap` with `ROSTER`, which only the
//! row gives: the row's own program under the row;
//! - `ps` without one exits 1: the link named this binary when the launcher
//! asked, so the caller spawned it directly, and it named toybox again when the
//! kernel opened it;
//! - this binary as [`ROLE`] exits [`PLAIN`] without a `SysCap` and [`EXPLOIT`]
//! with one — the caller's bytes under the row's claims, the defect.
//!
//! Every launch must start and be waited, and both of the first two must happen:
//! one shows a launch of the row runs, the other that the link really moved
//! while launches were made.

use std::os::toyos::fs::symlink;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use toyos::endow::{Endowments, SYSCAP_LABEL};

/// This binary, the bytes a re-point substitutes for the row's.
const SELF_PATH: &str = "/system/bin/test_rs_launch_toctou";
/// The declared row the link names. `/system` is read-only to every program,
/// so only which file the link names can move, never the row's bytes.
const DECLARED: &str = "/system/bin/toybox";
/// The caller-writable link, named for the applet toybox runs as.
const LINK: &str = "/tmp/ps";
/// The argument that makes a child of this binary [`role`] rather than the
/// attacker.
const ROLE: &str = "role";

/// This binary as a child, holding a `SysCap`: only a launch of the row gives one.
const EXPLOIT: i32 = 123;
/// This binary as a child, holding none.
const PLAIN: i32 = 77;
/// Toybox's `ps`, having read the roster.
const PS_UNDER_ROW: i32 = 0;
/// Toybox's `ps`, endowed no `SysCap`.
const PS_DIRECT: i32 = 1;

const ATTEMPTS: u32 = 100;

fn main() {
match std::env::args().nth(1).as_deref() {
Some(ROLE) => role(),
_ => attacker(),
}
}

/// The caller's bytes, run by a launch.
fn role() -> ! {
let under_row = Endowments::get().holds(SYSCAP_LABEL);
std::process::exit(if under_row { EXPLOIT } else { PLAIN });
}

fn attacker() {
symlink(DECLARED, LINK).expect("create the link at the declared row");

let stop = Arc::new(AtomicBool::new(false));
let flipper = std::thread::spawn({
let stop = Arc::clone(&stop);
move || {
// tmpfs displaces the existing entry, so each call is one swap of
// what the link names.
while !stop.load(Ordering::Relaxed) {
symlink(SELF_PATH, LINK).expect("re-point the link at this binary");
symlink(DECLARED, LINK).expect("re-point the link at the declared row");
}
}
});

let (mut under_row, mut direct) = (0u32, 0u32);
for attempt in 1..=ATTEMPTS {
// Captured rather than null: a direct spawn holds no slot it was not
// given, and `ps` says why it refused on stderr.
let output = Command::new(LINK)
.arg(ROLE)
.stdin(Stdio::null())
.output()
.unwrap_or_else(|e| panic!("launch {attempt} of {LINK} was refused or not waited: {e}"));
match output.status.code() {
Some(PS_UNDER_ROW) => under_row += 1,
Some(PS_DIRECT) | Some(PLAIN) => direct += 1,
Some(EXPLOIT) => panic!(
"launch_toctou: launch {attempt} ran the caller's bytes holding the `toybox` \
row's capability: the program a launched row runs was opened from the \
caller's path"
),
other => panic!("launch {attempt} exited {other:?}, which neither program answers"),
}
}

stop.store(true, Ordering::Relaxed);
flipper.join().expect("the re-point thread failed");
assert!(under_row > 0, "none of {ATTEMPTS} launches ran the `toybox` row's program under its row");
assert!(direct > 0, "the link never named this binary when the launcher asked in {ATTEMPTS} launches");
println!("launch_toctou: PASS ({under_row} of {ATTEMPTS} launches ran the row's program under it, none the caller's)");
}
Loading
Loading