3 · Namespaces e rootfs#
Este é o capítulo que transforma um processo normal num container. Segue a ordem em que as coisas acontecem — e a ordem é o conteúdo: cada passo depende do anterior.
Passo 1 — o supervisor entra nos namespaces#
O supervisor faz um único unshare com cinco flags:
unshare(
CloneFlags::CLONE_NEWUSER
| CloneFlags::CLONE_NEWPID
| CloneFlags::CLONE_NEWUTS
| CloneFlags::CLONE_NEWIPC
| CloneFlags::CLONE_NEWNET,
)?;
O mnt fica de fora de propósito: quem faz o unshare(NEWNS) é o init, mais tarde. Assim o supervisor mantém a vista do host do sistema de ficheiros, e pode gravar o state.json e apagar o cgroup depois de o container morrer.
E o pid? Lembra-te: CLONE_NEWPID só vale para os filhos. Por isso a seguir vem um fork — o filho é o PID 1.
Passo 2 — mapear os ids (e a armadilha do getuid)#
Um user namespace acabado de criar não tem mapa: és «ninguém» (uid 65534). Sem mapa, nem sequer podes escrever num ficheiro. O processo escreve o seu próprio mapa em /proc/self:
/// Mapeamento de um só uid/gid (root dentro = o teu utilizador fora). Para vários uids seria
/// preciso `newuidmap` + `/etc/subuid`.
fn map_ids(uid: nix::unistd::Uid, gid: nix::unistd::Gid) -> Result<()> {
let w = |f: &str, v: String| {
fs::write(format!("/proc/self/{f}"), v).ctx(|| format!("writing /proc/self/{f}"))
};
w("setgroups", "deny".into())?; // obrigatório antes do gid_map sem privilégio
w("uid_map", format!("0 {uid} 1"))?;
w("gid_map", format!("0 {gid} 1"))
}Três detalhes, todos aprendidos a falhar:
setgroups=denyprimeiro. Sem privilégio, o kernel só deixa escrever ogid_mapse se prometer nunca usarsetgroups(2)(senão um utilizador poderia largar grupos que o restringem).- Mapeia um só uid:
0 <uid> 1— «o uid 0 dentro é o meu uid fora, num intervalo de 1». - Os ids são parâmetros, lidos antes do
unshare. O primeiro código chamavagetuid()dentro da função, e depois dounshareisso devolve 65534 → o kernel recusa o mapa comEPERM, sem dizer porquê. Passaruid/gidcomo argumentos torna a ordem impossível de errar:
// Ler os ids ANTES do unshare: depois dele `getuid()` devolve o uid «overflow» (65534)
// e o `uid_map` seria recusado com EPERM.
let (uid, gid) = (nix::unistd::getuid(), nix::unistd::getgid());
Passo 3 — o lo do namespace de rede#
Um net namespace novo nasce só com o lo, em baixo. Sem o subir, 127.0.0.1 não responde. Uma das poucas unsafe do projecto — um ioctl que o nix não embrulha:
/// Sobe `lo` no netns novo (ioctl SIOCSIFFLAGS). Sem isto `127.0.0.1` não responde.
fn loopback_up() -> Result<()> {
// SAFETY: `ifreq` é POD de zeros válido; o socket é fechado no fim; nomes ≤ IFNAMSIZ.
unsafe {
let sock = libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0);
if sock < 0 {
return Err(Error::Sys(nix::Error::last()));
}
let mut ifr: libc::ifreq = std::mem::zeroed();
for (d, s) in ifr.ifr_name.iter_mut().zip(b"lo\0") {
*d = *s as libc::c_char;
}
ifr.ifr_ifru.ifru_flags = (libc::IFF_UP | libc::IFF_RUNNING) as libc::c_short;
let rc = libc::ioctl(sock, libc::SIOCSIFFLAGS as _, &ifr);
let err = nix::Error::last();
libc::close(sock);
if rc < 0 { Err(Error::Sys(err)) } else { Ok(()) }
}
}Repara no que se faz antes de fechar o socket: guarda-se o errno (nix::Error::last()) antes do close, porque o close pode sobrescrevê-lo.
Passo 4 — o init monta o rootfs#
Agora, já como PID 1 num user namespace onde o processo tem todas as capabilities (sobre o namespace), o init prepara o seu mundo:
/// PID 1 do container. Só regressa com erro — no sucesso faz `exec`.
fn container_init(
spec: &Spec,
rootfs: &Path,
fifo: &Path,
report: &OwnedFd,
) -> Result<std::convert::Infallible> {
unshare(CloneFlags::CLONE_NEWNS)?;
// Sem isto os nossos mounts propagavam para o host.
mount(None::<&str>, "/", None::<&str>, MsFlags::MS_REC | MsFlags::MS_PRIVATE, None::<&str>)?;
// pivot_root exige que o novo root seja um ponto de montagem.
mount(Some(rootfs), rootfs, None::<&str>, MsFlags::MS_BIND | MsFlags::MS_REC, None::<&str>)?;
let mut dev_mounted = false;
for m in &spec.mounts {
apply_mount(rootfs, m)?;
dev_mounted |= m.destination == Path::new("/dev");
}
if dev_mounted {
setup_dev(rootfs)?;
}
// Aberto ANTES do pivot: depois dele o caminho do FIFO já não existe.
// O_RDWR não bloqueia na abertura; o `read` bloqueia até `start` escrever.
let fifo_fd = nix::fcntl::open(fifo, OFlag::O_RDWR | OFlag::O_CLOEXEC, Mode::empty())?;
chdir(rootfs)?;
pivot_root(".", ".")?; // truque: raiz nova e antiga empilhadas no mesmo sítio…
umount2(".", MntFlags::MNT_DETACH)?; // …e a antiga desmonta-se.
chdir("/")?;
if spec.root.readonly {
remount_ro("/")?;
}
if let Some(h) = &spec.hostname {
sethostname(h)?;
}
drop_capabilities()?;
// SAFETY: prctl com argumentos inteiros constantes.
if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
return Err(Error::Sys(nix::Error::last()));
}
// Tudo pronto: diz ao supervisor e espera pelo `start`.
send(report, b"K");
let mut byte = [0u8; 1];
nix::unistd::read(&fifo_fd, &mut byte)?;
drop(fifo_fd);
chdir(spec.process.cwd.as_str())?;
let args: Vec<CString> = spec.process.args.iter().map(|a| cstr(a)).collect::<Result<_>>()?;
let env: Vec<CString> = spec.process.env.iter().map(|a| cstr(a)).collect::<Result<_>>()?;
let err = nix::unistd::execvpe(&args[0], &args, &env).unwrap_err();
// Já depois do `start`: o relatório fechou; diz-se no stderr e sai-se como a shell (127).
eprintln!("mc: cannot exec {:?}: {err}", spec.process.args[0]);
std::process::exit(if err == nix::Error::ENOENT { 127 } else { 126 });
}Vamos por partes.
MS_PRIVATE em toda a árvore#
mount(None::<&str>, "/", None::<&str>, MsFlags::MS_REC | MsFlags::MS_PRIVATE, None::<&str>)?;
Sem isto, os mounts que fazemos propagariam para o host (a propagation por omissão em muitas distros é shared). É a primeira linha depois do unshare(NEWNS) em qualquer runtime, e a mais fácil de esquecer.
O rootfs como ponto de montagem#
O pivot_root exige que a nova raiz seja um ponto de montagem. Uma pasta comum não é — fazemos um bind mount dela sobre si própria.
Mounts do config.json, sem sair do rootfs#
fn apply_mount(rootfs: &Path, m: &Mount) -> Result<()> {
let target = resolve_in_root(rootfs, &m.destination, true)?;
let (mut flags, mut ro) = (MsFlags::empty(), false);
let mut data: Vec<&str> = Vec::new();
for o in &m.options {
match o.as_str() {
"ro" => ro = true,
"nosuid" => flags |= MsFlags::MS_NOSUID,
"nodev" => flags |= MsFlags::MS_NODEV,
"noexec" => flags |= MsFlags::MS_NOEXEC,
"bind" => flags |= MsFlags::MS_BIND,
"rbind" => flags |= MsFlags::MS_BIND | MsFlags::MS_REC,
"rw" | "relatime" | "strictatime" => {}
other => data.push(other),
}
}
let kind = m.kind.as_deref().unwrap_or("bind");
let bind = kind == "bind" || flags.contains(MsFlags::MS_BIND);
match kind {
"proc" | "tmpfs" | "bind" => {}
other => return Err(Error::Unsupported(format!("mount type {other:?}"))),
}
let source = m.source.as_deref();
if bind {
let src = source.ok_or_else(|| Error::Spec("bind mount without source".into()))?;
if src.is_file() {
// o alvo de um bind de ficheiro tem de ser um ficheiro
fs::remove_dir(&target).ok();
fs::File::create(&target).ctx(|| format!("creating {}", target.display()))?;
}
mount(Some(src), &target, None::<&str>, flags | MsFlags::MS_BIND, None::<&str>)?;
if ro || !(flags - MsFlags::MS_BIND - MsFlags::MS_REC).is_empty() {
let t = target.to_string_lossy();
let mut f = MsFlags::MS_BIND | MsFlags::MS_REMOUNT | flags | inherited_flags(&t);
if ro {
f |= MsFlags::MS_RDONLY
}
mount(None::<&str>, &target, None::<&str>, f, None::<&str>)?;
}
} else {
let data = data.join(",");
if ro {
flags |= MsFlags::MS_RDONLY
}
mount(source, &target, Some(kind), flags, if data.is_empty() { None } else { Some(data.as_str()) })?;
}
Ok(())
}O destino de cada mount vem de um ficheiro que não controlamos. Se fosse rootfs.join(destino), uma imagem com um symlink etc -> / faria um bind mount para etc/passwd acabar em /etc/passwd do host. Por isso passa por:
Resolver caminhos sem sair do rootfs#
/// Junta `rel` a `root` recusando `..`, prefixos e — sobretudo — **symlinks** pelo caminho.
///
/// Porque não basta `root.join(rel)`: uma imagem pode plantar `etc -> /` e um bind mount
/// para `etc/passwd` acabaria no `/` do *host*. Resolvemos componente a componente e
/// recusamos qualquer symlink (é o que o delonix faz em `safe_bind_target`).
/// Com `create`, os directórios que faltam são criados.
pub fn resolve_in_root(root: &Path, rel: &Path, create: bool) -> Result<PathBuf> {
let mut cur = root.to_path_buf();
for comp in rel.components() {
match comp {
Component::RootDir | Component::CurDir => {}
Component::Normal(name) => {
cur.push(name);
match fs::symlink_metadata(&cur) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(Error::UnsafePath(cur));
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound && create => {
fs::create_dir(&cur).ctx(|| format!("creating {}", cur.display()))?;
}
Err(e) => return Err(Error::io(format!("stat {}", cur.display()), e)),
}
}
Component::ParentDir | Component::Prefix(_) => {
return Err(Error::UnsafePath(rel.to_path_buf()));
}
}
}
Ok(cur)
}Percorre o caminho componente a componente, e recusa .., prefixos e — o essencial — qualquer symlink. Tem duas variantes (create: cria os directórios em falta). O teste planta o symlink hostil e afirma a recusa:
std::os::unix::fs::symlink("/", dir.path().join("etc")).unwrap();
assert!(matches!(
resolve_in_root(dir.path(), Path::new("/etc/passwd"), true),
Err(Error::UnsafePath(_))
));
O motor tem o equivalente, e o comentário dele conta porquê:
/// Resolves `target` (an in-container path, already lexically validated by
/// `mount_target_safe`) against `rootfs`, component-by-component, refusing to
/// descend through any symlink an image layer may have planted along the
/// way. `mount_target_safe`'s lexical `..` check is not enough on its own:
/// `create_dir_all`/`OpenOptions::open` (called on the joined path right
/// after this) FOLLOW symlinks, and `bind_volume` runs BEFORE `pivot_root`
/// while `/` is still the real host filesystem — an absolute symlink at ANY
/// path component (a malicious image shipping e.g. `/etc -> /root`, or the
/// final mount-target component itself already existing as one) redirects
/// the destination to an arbitrary real host path, and the subsequent
/// create/open then create real directories/files on the host, as the
/// engine's own uid. Mirrors the confinement technique `cmd::build::
/// confine_to` already uses for the build's COPY (a sibling of this exact
/// bug class, fixed there first) — this is the engine-side equivalent.
fn safe_bind_target(rootfs: &str, target: &str) -> Option<std::path::PathBuf> {
let mut current = std::path::PathBuf::from(rootfs);
for comp in std::path::Path::new(target).components() {
let std::path::Component::Normal(c) = comp else {
continue;
};
current.push(c);
if let Ok(meta) = std::fs::symlink_metadata(¤t) {
if meta.file_type().is_symlink() {
return None;
}
}
}
Some(current)
}bind_volume corre ANTES do pivot_root
Enquanto o / ainda é o real, create_dir_all e open seguem symlinks. Um symlink absoluto plantado pela imagem redirecciona o destino para um caminho arbitrário do host, e o motor cria ali directórios e ficheiros com o seu próprio uid. Não é hipotético: foi um achado de auditoria.
/dev sem mknod#
Um container precisa de /dev/null, /dev/zero… mas criar device nodes exige CAP_MKNOD no namespace inicial, que não temos. A solução do runtime rootless: um tmpfs em /dev (vem do config.json) e, por cima, bind mounts dos dispositivos do host:
/// Sem root não há `mknod`: os dispositivos básicos são *bind-mounts* dos do host.
fn setup_dev(rootfs: &Path) -> Result<()> {
let dev = resolve_in_root(rootfs, Path::new("/dev"), false)?; // sem symlinks pelo caminho
for name in ["null", "zero", "full", "random", "urandom", "tty"] {
let target = dev.join(name);
fs::File::create(&target).ctx(|| format!("creating {}", target.display()))?;
mount(
Some(&*PathBuf::from("/dev").join(name)),
&target,
None::<&str>,
MsFlags::MS_BIND,
None::<&str>,
)?;
}
for (link, to) in [
("fd", "/proc/self/fd"),
("stdin", "/proc/self/fd/0"),
("stdout", "/proc/self/fd/1"),
("stderr", "/proc/self/fd/2"),
] {
std::os::unix::fs::symlink(to, dev.join(link)).ctx(|| format!("symlink /dev/{link}"))?;
}
Ok(())
}O truque do pivot_root(".", ".")#
chdir(rootfs)?;
pivot_root(".", ".")?; // raiz nova e antiga empilhadas no mesmo sítio…
umount2(".", MntFlags::MNT_DETACH)?; // …e a antiga desmonta-se
chdir("/")?;
A forma «manual» exigia criar um directório .old dentro do rootfs para pendurar a raiz antiga e depois apagá-lo — um passo que escreve no rootfs, que pode ser só de leitura. Com pivot_root(".", ".") a raiz antiga fica empilhada por baixo da nova, e o MNT_DETACH desfaz-se dela. Não sobra nada por onde fugir.
Rootfs só de leitura, e as flags «trancadas»#
if spec.root.readonly { remount_ro("/")?; }
Um remount em user namespace tem uma regra subtil: as flags (nosuid, nodev, noexec) herdadas do mount do host estão trancadas — um remount que as retire dá EPERM. Por isso o remount_ro lê-as (statvfs) e volta a pô-las:
fn remount_ro(path: &str) -> Result<()> {
let flags = MsFlags::MS_BIND | MsFlags::MS_REMOUNT | MsFlags::MS_RDONLY | inherited_flags(path);
mount(None::<&str>, path, None::<&str>, flags, None::<&str>)?;
Ok(())
}
O resultado, visto de dentro:
$ mc run ro -b bundle touch: /x: Read-only file system touch /x -> rc=1 touch /tmp/x -> ok [exit 0]
/x (raiz) recusa a escrita; /tmp (um tmpfs) aceita. É a diferença entre um rootfs imutável e um sítio para trabalhar.
Verifica#
cargo test -p minicontainer --test e2e runs_as_pid_1
cargo test -p minicontainer --test e2e readonly_root
Já temos um container isolado e com uma raiz própria. Falta limitá-lo e tirar-lhe poder: cgroups e capabilities.