Skip to main content

proot/exec/
nested.rs

1//! Nested proot and bwrap: a view of their own instead of a supervisor of
2//! their own.
3//!
4//! The kernel allows one tracer per process and one seccomp listener per
5//! filter chain, so a proot started inside the guest could never supervise
6//! anything.  It does not have to: the supervisor already serves every
7//! descendant, and all a nested launch needs is a different translation
8//! table for one subtree.  So an `execve` of the supervisor's own binary, or
9//! of any ELF named `proot` or `bwrap` (the latter unless `PROOT_NEST=0`),
10//! is not carried out.  The command line is parsed here, on the tracee's
11//! behalf, with the tracee's view, working directory, environment and
12//! descriptors; every path in it is composed through that view into a
13//! [`View`] in host terms, which the process enters at the exec of the
14//! real command and its descendants inherit.  The process that called
15//! `proot` becomes the command, so the caller's child, exit status and
16//! signals are the kernel's own.
17//!
18//! What only the process itself can do (`chdir`, `setsid`, the death signal,
19//! the caller's seccomp programs, the status descriptors, a replaced
20//! environment, a usage message on its own stderr) is handed to the exec
21//! trampoline through its record, see [`super::shim`].
22//!
23//! A nested view can see no more than the view it was asked from: `-r /`
24//! inside a guest is that guest's root.  Options that would change which
25//! syscalls the filter notifies (`-0`, `-k`) cannot be served unless the
26//! outer launch already notifies them, and are refused with a message.
27
28use super::{ExecPlan, Trampoline};
29use crate::cli::{self, CliError, host::Host};
30use crate::config::{Binding, Config};
31use crate::fs::Fs;
32use crate::fs::binding::{is_prefix, join_prefix, rest_after};
33use crate::fs::translate::{detranslate_path, guest_cwd, read_link, translate_path};
34use crate::note;
35use crate::state::{Ctx, View};
36use crate::sys::Result;
37use crate::tracee::mem::MAX_ARG_STRLEN;
38use std::ffi::OsString;
39use std::os::unix::ffi::{OsStrExt, OsStringExt};
40use std::sync::{Arc, OnceLock};
41
42/// Upper bound on environment entries read from the tracee.
43const MAX_ENV: usize = 1 << 16;
44
45/// Which command-line dialect a nested launch speaks.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum FrontEnd {
48    Proot,
49    Bwrap,
50}
51
52/// Program names intercepted by name (the supervisor's own binary is
53/// intercepted by inode, whatever it is called).
54const NAMES: &[&[u8]] = &[b"proot", b"proot-next", b"bwrap"];
55
56/// Decides whether an exec is a nested launch, and in which dialect.
57///
58/// # Arguments
59/// * `host_prog` - Host path of the ELF about to run (after `#!` expansion).
60/// * `user_path` - Program path as the tracee passed it.
61/// * `guest_prog` - Canonical guest path of the program.
62/// * `argv0` - The tracee's `argv[0]`, which selects the dialect as `main` does.
63/// * `is_elf` - The program is an ELF the tracee named directly, not a script.
64///
65/// # Returns
66/// The dialect, or `None` for an ordinary exec.
67pub fn detect(
68    host_prog: &[u8],
69    user_path: &[u8],
70    guest_prog: &[u8],
71    argv0: &[u8],
72    is_elf: bool,
73) -> Option<FrontEnd> {
74    let name = super::exec_name(user_path, guest_prog);
75    let ours = is_elf && same_file_as_us(host_prog);
76    let by_name = is_elf && by_name_enabled() && NAMES.contains(&name);
77    if !ours && !by_name {
78        return None;
79    }
80    let argv0_name = argv0.rsplit(|&b| b == b'/').next().unwrap_or(argv0);
81    Some(if argv0_name.ends_with(b"bwrap") || name.ends_with(b"bwrap") {
82        FrontEnd::Bwrap
83    } else {
84        FrontEnd::Proot
85    })
86}
87
88/// Whether programs named `proot` or `bwrap` are intercepted; `PROOT_NEST=0` turns it off.
89fn by_name_enabled() -> bool {
90    static ENABLED: OnceLock<bool> = OnceLock::new();
91    *ENABLED.get_or_init(|| std::env::var_os("PROOT_NEST").is_none_or(|v| v != "0"))
92}
93
94/// Whether `host` is the file the supervisor itself is running from.
95fn same_file_as_us(host: &[u8]) -> bool {
96    static US: OnceLock<Option<(u64, u64)>> = OnceLock::new();
97    let us = US.get_or_init(|| dev_ino(b"/proc/self/exe"));
98    us.is_some() && *us == dev_ino(host)
99}
100
101/// Device and inode of a host path, symlinks followed.
102fn dev_ino(host: &[u8]) -> Option<(u64, u64)> {
103    use std::os::unix::fs::MetadataExt;
104    let m = std::fs::metadata(std::ffi::OsStr::from_bytes(host)).ok()?;
105    Some((m.dev(), m.ino()))
106}
107
108/// Where a nested launch takes its environment from.
109pub enum EnvSource {
110    /// The `envp` the tracee passed to `execve`: its address (0 for none)
111    /// and the pointer width of the tracee's ABI.
112    Address { envp: u64, ptr_size: usize },
113    /// An environment already in hand (a launch nested inside a nested one).
114    Given(Vec<(Vec<u8>, Vec<u8>)>),
115}
116
117/// The requesting tracee, as the command-line parsers see it.
118struct Tracee<'a> {
119    view: &'a View,
120    pid: u32,
121    env: Vec<(Vec<u8>, Vec<u8>)>,
122}
123
124impl Host for Tracee<'_> {
125    fn exists(&self, path: &[u8]) -> bool {
126        translate_path(&self.view.fs, self.pid, libc::AT_FDCWD, path, true)
127            .map(|h| std::fs::metadata(std::ffi::OsStr::from_bytes(&h)).is_ok())
128            .unwrap_or(false)
129    }
130
131    fn is_dir(&self, path: &[u8]) -> bool {
132        translate_path(&self.view.fs, self.pid, libc::AT_FDCWD, path, true)
133            .map(|h| std::fs::metadata(std::ffi::OsStr::from_bytes(&h)).is_ok_and(|m| m.is_dir()))
134            .unwrap_or(false)
135    }
136
137    fn host_path(&self, path: &[u8]) -> Vec<u8> {
138        if let Ok(h) = translate_path(&self.view.fs, self.pid, libc::AT_FDCWD, path, true) {
139            return h;
140        }
141        let mut guest = if path.first() == Some(&b'/') {
142            Vec::new()
143        } else {
144            self.cwd()
145        };
146        if guest != b"/" && !guest.is_empty() {
147            guest.push(b'/');
148        }
149        guest.extend_from_slice(path);
150        self.view.fs.substitute_guest_to_host(&guest)
151    }
152
153    fn cwd(&self) -> Vec<u8> {
154        guest_cwd(&self.view.fs, self.pid).unwrap_or_else(|_| b"/".to_vec())
155    }
156
157    fn var(&self, name: &[u8]) -> Option<Vec<u8>> {
158        self.env.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone())
159    }
160
161    fn vars(&self) -> Vec<(Vec<u8>, Vec<u8>)> {
162        self.env.clone()
163    }
164
165    fn read_fd(&self, fd: i32) -> std::io::Result<Vec<u8>> {
166        if fd < 0 {
167            return Err(std::io::Error::from_raw_os_error(libc::EBADF));
168        }
169        std::fs::read(format!("/proc/{}/fd/{fd}", self.pid))
170    }
171
172    fn fd_path(&self, fd: i32) -> std::io::Result<Vec<u8>> {
173        let link = read_link(format!("/proc/{}/fd/{fd}", self.pid).as_bytes())
174            .map_err(std::io::Error::from_raw_os_error)?;
175        if link.first() != Some(&b'/') {
176            return Err(std::io::Error::from_raw_os_error(libc::ENOTDIR));
177        }
178        Ok(link)
179    }
180}
181
182/// Plans a nested launch: the command of the nested command line, run in a
183/// view composed from the requester's.
184///
185/// # Arguments
186/// * `ctx` - Shared supervisor state.
187/// * `view` - The requester's view.
188/// * `pid` - Requesting tracee thread id.
189/// * `front` - Dialect of the command line.
190/// * `argv` - The tracee's full argument vector (`argv[0]` is the program).
191/// * `env` - The tracee's environment.
192///
193/// # Returns
194/// A plan that always goes through the trampoline: for the real command,
195/// or to print a message on the tracee's own stdout or stderr and exit.
196///
197/// # Errors
198/// Errno from reading the tracee.
199pub fn plan(
200    ctx: &Ctx,
201    view: &View,
202    pid: u32,
203    front: FrontEnd,
204    argv: Vec<Vec<u8>>,
205    env: EnvSource,
206) -> Result<ExecPlan> {
207    let env = match env {
208        EnvSource::Address { envp, ptr_size } => read_env(ctx, pid, envp, ptr_size)?,
209        EnvSource::Given(e) => e,
210    };
211    let tracee = Tracee { view, pid, env };
212    let args: Vec<OsString> = argv
213        .iter()
214        .skip(1)
215        .map(|a| OsString::from_vec(a.clone()))
216        .collect();
217    let parsed = match front {
218        FrontEnd::Proot => cli::parse(args, &tracee),
219        FrontEnd::Bwrap => cli::bwrap::parse(args, &tracee),
220    };
221    let message = |code: i32, text: String, to_stdout: bool| -> Result<ExecPlan> {
222        note!(ctx, 1, "pid {pid} nested launch: {}", text.trim_end());
223        Ok(ExecPlan::message(Trampoline {
224            exit: Some((code, text.into_bytes(), to_stdout)),
225            ..Trampoline::default()
226        }))
227    };
228    let config = match parsed {
229        Ok(c) => c,
230        Err(CliError::Exit { code, text }) => return message(code, text, true),
231        Err(CliError::Usage(msg)) => return message(125, format!("proot: {msg}\n"), false),
232    };
233    let uname_served = ctx.config.kernel_release.is_some() || ctx.config.hostname.is_some();
234    if config.fake_root && !ctx.root.fake_root {
235        return message(
236            1,
237            "proot: -0 inside proot-next needs the outer proot started with -0\n".into(),
238            false,
239        );
240    }
241    if config.kernel_release.is_some() && !uname_served {
242        return message(
243            1,
244            "proot: -k inside proot-next needs the outer proot started with -k\n".into(),
245            false,
246        );
247    }
248    let mut config = config;
249    if !uname_served {
250        config.hostname = None;
251    }
252    compose_sub_bindings(&view.fs, &mut config);
253    let keep = std::env::var_os("PROOT_BWRAP_KEEP").is_some();
254    let nested = Arc::new(View::new(&config, ctx.procs.clone(), ctx.cache.clone(), !keep));
255    note!(
256        ctx,
257        1,
258        "pid {pid} nested {:?} launch: root {} with {} bindings",
259        front,
260        String::from_utf8_lossy(&nested.fs.root),
261        nested.fs.bindings.len()
262    );
263    let (cwd, pwd, warning) = nested_cwd(&tracee, &nested, &config);
264    let cwd_host = translate_path(&nested.fs, pid, libc::AT_FDCWD, &cwd, true)
265        .unwrap_or_else(|_| nested.fs.root.clone());
266    let command = match resolve_command(&nested, pid, &config, &tracee, &cwd) {
267        Ok(c) => c,
268        Err(e) => {
269            let name = config
270                .command
271                .first()
272                .map(|c| c.as_bytes().to_vec())
273                .unwrap_or_default();
274            let reason = std::io::Error::from_raw_os_error(e);
275            let reason = reason.to_string();
276            let reason = reason.split(" (os error").next().unwrap_or(&reason).to_string();
277            let text = if config.bwrap_mode {
278                format!("bwrap: execvp {}: {reason}\n", String::from_utf8_lossy(&name))
279            } else {
280                format!(
281                    "proot: cannot execute {}: {reason}\n",
282                    String::from_utf8_lossy(&name)
283                )
284            };
285            return message(if e == libc::ENOENT { 127 } else { 126 }, text, false);
286        }
287    };
288    let mut cmd_argv: Vec<Vec<u8>> = config.command.iter().map(|c| c.as_bytes().to_vec()).collect();
289    if let Some(a0) = &config.argv0 {
290        cmd_argv[0] = a0.as_bytes().to_vec();
291    }
292    let mut inner_env: Vec<(Vec<u8>, Vec<u8>)> = match &config.env {
293        Some(e) => e
294            .iter()
295            .map(|c| {
296                let b = c.as_bytes();
297                match b.iter().position(|&x| x == b'=') {
298                    Some(i) => (b[..i].to_vec(), b[i + 1..].to_vec()),
299                    None => (b.to_vec(), Vec::new()),
300                }
301            })
302            .collect(),
303        None => tracee.env.clone(),
304    };
305    if config.bwrap_mode {
306        // bubblewrap exports the directory it entered (see `bwrap::with_pwd`).
307        inner_env.retain(|(k, _)| k != b"PWD");
308        inner_env.push((b"PWD".to_vec(), pwd));
309    }
310    let mut plan = super::plan_in(
311        ctx,
312        &nested,
313        pid,
314        libc::AT_FDCWD,
315        &command,
316        cmd_argv,
317        true,
318        EnvSource::Given(inner_env),
319    )?;
320    if let Some(t) = plan.trampoline.as_mut() {
321        if t.exit.is_some() {
322            return Ok(plan);
323        }
324        merge_setup(t, &config, warning);
325        return Ok(plan);
326    }
327    let mut setup = Trampoline {
328        enter: Some((nested, cwd)),
329        chdir: if ctx.no_ptrace { None } else { Some(cwd_host) },
330        ..Trampoline::default()
331    };
332    merge_setup(&mut setup, &config, warning);
333    plan.trampoline = Some(setup);
334    plan.prefix = plan.argv.len();
335    plan.changed = true;
336    Ok(plan)
337}
338
339/// Adds what the nested command line asks the trampoline to do before the exec.
340///
341/// Carries the requester's bindings beneath a nested bind along with it, the
342/// way a recursive bind mount carries the submounts under its source.
343///
344/// The front ends translate the host side of every nested binding through
345/// the requester's view, which flattens the table to one host path per entry
346/// and drops whatever the requester had bound further down.  pressure-vessel
347/// binds the host `/` at `/run/host` and, finding a 32-bit loader at the
348/// host's `/usr/lib/ld-linux.so.2` (one the outer launch bound there),
349/// symlinks the container's `/lib/ld-linux.so.2` to it; without the outer
350/// binding carried under `/run/host` that link dangles and no 32-bit program
351/// in the container can execute.  The nested root is left alone: it is a
352/// plain directory, as PRoot's rootfs is, not a bind of the requester's `/`.
353///
354/// # Arguments
355/// * `outer` - The requester's view.
356/// * `config` - The parsed nested launch, whose bindings already name host
357///   paths; bindings found beneath them are appended.
358fn compose_sub_bindings(outer: &Fs, config: &mut Config) {
359    let targets: Vec<(Vec<u8>, Vec<u8>, bool)> = config
360        .bindings
361        .iter()
362        .map(|b| (b.host.clone(), b.guest.clone(), b.read_only))
363        .collect();
364    for (host, guest, read_only) in targets {
365        let source = detranslate_path(outer, &host);
366        for b in &outer.bindings {
367            if b.guest != source && is_prefix(&source, &b.guest) {
368                config.bindings.push(Binding {
369                    host: b.host.clone(),
370                    guest: join_prefix(&guest, rest_after(&source, &b.guest)),
371                    read_only: read_only || b.read_only,
372                });
373            }
374        }
375    }
376}
377
378/// A plan that already carries a trampoline (a launch nested inside the
379/// nested one) keeps the innermost view and gets the outer launch's
380/// process setup on top; the innermost environment wins when both set one.
381fn merge_setup(t: &mut Trampoline, config: &Config, warning: Option<String>) {
382    if t.env.is_none() {
383        t.env = config
384            .env
385            .as_ref()
386            .map(|e| e.iter().map(|c| c.as_bytes().to_vec()).collect());
387    }
388    t.new_session |= config.new_session;
389    t.die_with_parent |= config.kill_on_exit && config.bwrap_mode;
390    t.seccomp_programs.extend(config.seccomp_programs.iter().cloned());
391    t.info_fds.extend(config.info_fds.iter().copied());
392    t.json_status_fds.extend(config.json_status_fds.iter().copied());
393    if let Some(w) = warning {
394        t.warnings.push(w.into_bytes());
395    }
396}
397
398/// The working directory the nested command starts in, as a guest path of
399/// the nested view, and a warning when it is not the one asked for.
400///
401/// A directory given on the command line is a path of the nested view.
402/// Without one, PRoot starts in the caller's working directory when it is
403/// visible in the new view, else in `/`; bwrap mode tries `$HOME` before `/`,
404/// as bubblewrap does.
405///
406/// # Returns
407/// The canonical guest directory, the path as it was named (bwrap mode's
408/// `PWD`), and the warning for a directory the command line named in vain.
409fn nested_cwd(tracee: &Tracee, nested: &View, config: &Config) -> (Vec<u8>, Vec<u8>, Option<String>) {
410    let wanted: Option<Vec<u8>> = if config.cwd_default {
411        let host = tracee.host_path(&tracee.cwd());
412        nested.fs.substitute_host_to_guest(&host)
413    } else if config.cwd.first() == Some(&b'/') {
414        Some(config.cwd.clone())
415    } else {
416        let mut p = b"/".to_vec();
417        p.extend_from_slice(&config.cwd);
418        Some(p)
419    };
420    let canonical = |guest: &[u8]| {
421        translate_path(&nested.fs, tracee.pid, libc::AT_FDCWD, guest, true)
422            .ok()
423            .filter(|h| std::fs::metadata(std::ffi::OsStr::from_bytes(h)).is_ok_and(|m| m.is_dir()))
424            .map(|h| detranslate_path(&nested.fs, &h))
425    };
426    if let Some(wanted) = &wanted
427        && let Some(c) = canonical(wanted)
428    {
429        return (c, wanted.clone(), None);
430    }
431    if config.cwd_default {
432        let home = config.env.as_deref().and_then(|env| {
433            env.iter()
434                .find_map(|e| e.as_bytes().strip_prefix(b"HOME=").map(<[u8]>::to_vec))
435        });
436        if let Some(home) = home.filter(|_| config.bwrap_mode)
437            && let Some(c) = canonical(&home)
438        {
439            return (c, home, None);
440        }
441        return (b"/".to_vec(), b"/".to_vec(), None);
442    }
443    (
444        b"/".to_vec(),
445        b"/".to_vec(),
446        Some(format!(
447            "proot: can't chdir to {} in the guest rootfs, using /\n",
448            String::from_utf8_lossy(&wanted.unwrap_or_default())
449        )),
450    )
451}
452
453/// The guest path of the nested command: as given when it has a slash
454/// (relative to the nested working directory), else found in `PATH`.
455///
456/// # Errors
457/// `ENOENT` when nothing by that name is executable, or the errno of what was found.
458fn resolve_command(nested: &View, pid: u32, config: &Config, tracee: &Tracee, cwd: &[u8]) -> Result<Vec<u8>> {
459    let name = config
460        .command
461        .first()
462        .map(|c| c.as_bytes().to_vec())
463        .ok_or(libc::ENOENT)?;
464    if name.is_empty() {
465        return Err(libc::ENOENT);
466    }
467    let candidates: Vec<Vec<u8>> = if name.contains(&b'/') {
468        if name.first() == Some(&b'/') {
469            vec![name]
470        } else {
471            let mut p = cwd.to_vec();
472            if p != b"/" {
473                p.push(b'/');
474            }
475            p.extend_from_slice(&name);
476            vec![p]
477        }
478    } else {
479        let path_var = config
480            .env
481            .as_ref()
482            .and_then(|e| {
483                e.iter()
484                    .find_map(|c| c.as_bytes().strip_prefix(b"PATH=").map(|v| v.to_vec()))
485            })
486            .or_else(|| tracee.var(b"PATH"))
487            .unwrap_or_else(|| b"/usr/local/bin:/usr/bin:/bin".to_vec());
488        path_var
489            .split(|&c| c == b':')
490            .filter(|d| d.first() == Some(&b'/'))
491            .map(|d| {
492                let mut p = d.to_vec();
493                if !p.ends_with(b"/") {
494                    p.push(b'/');
495                }
496                p.extend_from_slice(&name);
497                p
498            })
499            .collect()
500    };
501    let mut last = libc::ENOENT;
502    for guest in candidates {
503        let Ok(host) = translate_path(&nested.fs, pid, libc::AT_FDCWD, &guest, true) else {
504            continue;
505        };
506        match executable(&host) {
507            Ok(()) => return Ok(guest),
508            Err(e) => {
509                if e != libc::ENOENT {
510                    last = e;
511                }
512            }
513        }
514    }
515    Err(last)
516}
517
518/// `execve`'s own checks on a host path: it exists, is not a directory, and
519/// is executable.
520fn executable(host: &[u8]) -> Result<()> {
521    let c = std::ffi::CString::new(host.to_vec()).map_err(|_| libc::ENOENT)?;
522    let mut st: libc::stat = unsafe { std::mem::zeroed() };
523    if unsafe { libc::stat(c.as_ptr(), &mut st) } < 0 {
524        return Err(crate::sys::errno());
525    }
526    if st.st_mode & libc::S_IFMT == libc::S_IFDIR {
527        return Err(libc::EACCES);
528    }
529    if unsafe { libc::access(c.as_ptr(), libc::X_OK) } < 0 {
530        return Err(crate::sys::errno());
531    }
532    Ok(())
533}
534
535/// Reads the tracee's environment as `(name, value)` pairs.
536///
537/// # Arguments
538/// * `ctx` - Shared supervisor state.
539/// * `pid` - Tracee thread id.
540/// * `envp` - Address of the pointer array, or 0 for an empty environment.
541/// * `ptr_size` - Pointer width of the tracee's ABI.
542fn read_env(ctx: &Ctx, pid: u32, envp: u64, ptr_size: usize) -> Result<Vec<(Vec<u8>, Vec<u8>)>> {
543    if envp == 0 {
544        return Ok(Vec::new());
545    }
546    let ptrs = ctx.mem.read_ptr_array_sized(pid, envp, MAX_ENV, ptr_size)?;
547    let mut out = Vec::with_capacity(ptrs.len());
548    for p in ptrs {
549        let entry = ctx.mem.read_string(pid, p, MAX_ARG_STRLEN)?;
550        match entry.iter().position(|&b| b == b'=') {
551            Some(i) => out.push((entry[..i].to_vec(), entry[i + 1..].to_vec())),
552            None => out.push((entry, Vec::new())),
553        }
554    }
555    Ok(out)
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    #[test]
563    fn detects_our_binary_by_inode_and_others_by_name() {
564        let us = std::fs::read_link("/proc/self/exe").unwrap();
565        let us = us.as_os_str().as_bytes();
566        assert_eq!(
567            detect(us, b"/x/anything", b"/x/anything", b"anything", true),
568            Some(FrontEnd::Proot)
569        );
570        assert_eq!(
571            detect(us, b"/x/anything", b"/x/anything", b"bwrap", true),
572            Some(FrontEnd::Bwrap)
573        );
574        assert_eq!(
575            detect(us, b"/x/anything", b"/x/anything", b"anything", false),
576            None
577        );
578        assert_eq!(
579            detect(b"/bin/true", b"/usr/bin/bwrap", b"/usr/bin/bwrap", b"bwrap", true),
580            Some(FrontEnd::Bwrap)
581        );
582        assert_eq!(
583            detect(b"/bin/true", b"proot", b"/usr/bin/proot", b"proot", true),
584            Some(FrontEnd::Proot)
585        );
586        assert_eq!(
587            detect(b"/bin/true", b"/usr/bin/true", b"/usr/bin/true", b"true", true),
588            None
589        );
590        assert_eq!(
591            detect(
592                b"/bin/true",
593                b"/usr/bin/bwrap",
594                b"/usr/bin/bwrap",
595                b"bwrap",
596                false
597            ),
598            None
599        );
600    }
601}