Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
598a585eb8 | ||
|
|
4c4bb1a83a | ||
|
|
ed16dda2ec | ||
|
|
3f4e96937c | ||
|
|
97fa625a50 | ||
|
|
4fed40e5cd | ||
|
|
c3ebf21e9c | ||
|
|
530582e826 |
+32
@@ -1 +1,33 @@
|
||||
# build output
|
||||
build/
|
||||
cmake-build-*/
|
||||
CMakeCache.txt
|
||||
CMakeFiles/
|
||||
cmake_install.cmake
|
||||
compile_commands.json
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
*.d
|
||||
|
||||
# state and scratch created by running or testing doos
|
||||
.doos/
|
||||
*.tmp
|
||||
*.log
|
||||
*.pid
|
||||
*.core
|
||||
core
|
||||
|
||||
# editors and OS
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# local AI tooling state
|
||||
.claude/
|
||||
|
||||
# python bytecode from docs/charts/make_charts.py
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# AGENTS.md
|
||||
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
||||
|
||||
Rules for any AI agent (or human) changing this repository. The user's global rules
|
||||
also apply; this file adds the project-specific ones. Keep this file current: it is
|
||||
part of every release.
|
||||
@@ -9,9 +11,11 @@ part of every release.
|
||||
`doos` is a user-level container tool in C++20. `attach` builds a user + mount + pid
|
||||
+ uts + ipc (+ net) namespace set, overlays either the host's `/usr`, `/opt` (host mode) or an
|
||||
extracted image (image mode) with a per-container upper dir, pivots into it and runs a command.
|
||||
Networking is slirp4netns, ids are mapped through newuidmap when possible, a watchdog enforces
|
||||
`--size`. No root at runtime. Authoritative behavior: `SPEC.md`. Design notes: `docs/ARCHITECTURE.md`.
|
||||
Measurements and gaps: `docs/RESEARCH.md`.
|
||||
Networking is slirp4netns or pasta, ids are mapped through newuidmap when possible, cgroup v2 limits
|
||||
and a watchdog enforce `--memory --pids --cpus --size`, capabilities and seccomp confine the command.
|
||||
No root at runtime. Authoritative behavior: [SPEC](SPEC.md). Design notes: [ARCHITECTURE](docs/ARCHITECTURE.md).
|
||||
Measurements and gaps: [RESEARCH](docs/RESEARCH.md). Positioning against other tools: [COMPARISON](COMPARISON.md).
|
||||
Usage: [README](README.md). History: [CHANGELOG](CHANGELOG.md).
|
||||
|
||||
## Mandatory release workflow (every change, same session)
|
||||
|
||||
@@ -21,11 +25,12 @@ Measurements and gaps: `docs/RESEARCH.md`.
|
||||
- `patch`: bug fix, docs, tests, tooling, no behavior change for users.
|
||||
- `minor`: new or changed user-visible behavior (flag, command, mount, limit).
|
||||
- `major`: breaking change to CLI, state layout, or container compatibility.
|
||||
4. Run `scripts/release.sh <patch|minor|major> "imperative commit subject"`.
|
||||
4. Run `make docs` (`charts` plus `check-docs`) (the release script does too: every document must link to every other, every relative link must resolve).
|
||||
5. Run `scripts/release.sh <patch|minor|major> "imperative commit subject"`.
|
||||
It bumps `VERSION`, builds, runs `tests/run.sh`, commits all changes, and creates
|
||||
the annotated tag `vX.Y.Z`. It aborts on any build or test failure.
|
||||
5. Verify live: run `build/doos` once against the real thing (the e2e suite does this).
|
||||
6. Report honestly what passed and what did not. Do not push unless the user asks.
|
||||
6. Verify live: run `build/doos` once against the real thing (the e2e suite does this).
|
||||
7. Report honestly what passed and what did not. Do not push unless the user asks.
|
||||
|
||||
Never hand-edit `VERSION` or create tags manually. Never skip the tests to release.
|
||||
|
||||
@@ -41,7 +46,14 @@ Never hand-edit `VERSION` or create tags manually. Never skip the tests to relea
|
||||
| A new measurement | `docs/RESEARCH.md` with machine, kernel, and what was run |
|
||||
| A new known gap or a closed gap | `docs/RESEARCH.md` gaps list and `README.md` |
|
||||
| These workflow rules | this file |
|
||||
| Image catalog, sources, verification | `SPEC.md` Images, `README.md`, `docs/RESEARCH.md` |
|
||||
| A measurement, or anything the charts show | `make bench` then `make charts` (rewrites `docs/charts/*.svg` and every `<!--bench:...-->` number and table in `COMPARISON.md` and `docs/RESEARCH.md`); never type those numbers by hand |
|
||||
| A feature or limit that appears in the comparison tables | `COMPARISON.md` (tables, the gaps list, the conclusion) |
|
||||
| A code path one of the `audit/CVE-*` checks targets (overlay writes, tar extraction, mount/directory creation, net namespace sharing, seccomp denylist) | re-run `make audit`; update that CVE's `README.md` if the verdict's reasoning changed |
|
||||
| Allocation shape of `containerInit`, `setupRoot`, `doMount` or `Sandbox::start` | re-run `make valgrind`; if a suppressed site's stack trace changed, regenerate it (see [valgrind/README.md](valgrind/README.md)) and update `valgrind/doos.supp`'s comment |
|
||||
| A new or renamed markdown file | `scripts/check-docs.sh` DOCS list, the nav line (`**Docs:**`) of every document, `README.md` Project docs |
|
||||
| Image catalog, sources, verification, pinned keys | `SPEC.md` Images, `README.md`, `docs/RESEARCH.md` |
|
||||
| Seccomp list, capability set, `/etc` allowlist | `SPEC.md` Hardening and filesystem, `README.md`, `docs/RESEARCH.md` |
|
||||
| Limits, cgroup layout | `SPEC.md` Resource limits, `docs/ARCHITECTURE.md` |
|
||||
| Anything above, in every release | `README.md` version line and `CHANGELOG.md` entry |
|
||||
|
||||
Docs describe what the system does today. No roadmap text. A gap is listed next to
|
||||
@@ -68,9 +80,17 @@ the script.
|
||||
|
||||
`tests/run.sh [path/to/doos]` runs the real path: real overlayfs, namespaces,
|
||||
slirp4netns, filesystem, real downloads of the Alpine and Ubuntu images, real apt/apk installs, the size
|
||||
freeze (about 3 minutes in total, needs network). Add a case for every behavior change. A green run is required
|
||||
freeze (about 4 minutes in total, needs network, `pasta`, `gpg` and cgroup v2 delegation). Add a case for every behavior change. A green run is required
|
||||
before release. On Ubuntu 24.04+ the AppArmor profile in `apparmor/doos` must be
|
||||
installed once (needs sudo, ask the user, do not do it yourself).
|
||||
installed once for the binary being tested (`doos apparmor`; needs sudo, ask the user, do not do it yourself).
|
||||
|
||||
## Make targets
|
||||
|
||||
`make help` lists them: `build`, `test`, `audit` (named CVE regression checks against `build/doos`,
|
||||
see [audit/README.md](audit/README.md)), `valgrind` (memcheck against a fixed set of invocations,
|
||||
see [valgrind/README.md](valgrind/README.md)), `check-docs`, `bench` (re-measure into `docs/charts/bench.json`),
|
||||
`charts` (SVGs and generated numbers from that file), `docs`, `release KIND=patch MSG="..."`, `clean`.
|
||||
The Makefile is a thin wrapper; the logic lives in `CMakeLists.txt`, `scripts/`, `audit/`, `valgrind/` and `docs/charts/`.
|
||||
|
||||
## Environment notes
|
||||
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# Changelog
|
||||
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
||||
|
||||
## 1.3.0 - 2026-10-24
|
||||
- Security review applied ([review.md](review.md)): `slirp4netns`, `pasta`, `newuidmap` and `newgidmap` are never searched on `PATH`; they exec via absolute paths with availability checked up front, and the helpers run with a scrubbed `PATH`.
|
||||
- Compat-directory mounts pin the parent through an `O_PATH` fd so a symlink swap between check and use cannot happen (TOCTOU); symlinks whose target cannot be pinned are not recreated, fail closed instead.
|
||||
- `--i-understand` is now required for `--hardening relaxed` and `--net host`; reduced sandboxing is also reported loudly at attach time.
|
||||
- Tests: the e2e suite covers all of the above (`passed=88 failed=0`). Docs: [RESEARCH](docs/RESEARCH.md), [SPEC](SPEC.md), [README](README.md) updated in the same change.
|
||||
|
||||
## 1.2.1 - 2026-10-23
|
||||
- `scripts/release.sh`: commit trailer model name corrected (no functional change).
|
||||
|
||||
## 1.2.0 - 2026-10-23
|
||||
- `audit/`: 10 more named, sourced CVEs (18 total): the overlayfs-in-unprivileged-userns family (`CVE-2021-3493`, `CVE-2023-2640`, `CVE-2023-32629`, `CVE-2015-1328`, directly doos's own mount on every attach), `CVE-2022-0492` (cgroup v1 release_agent; doos mounts no `/sys`), `CVE-2022-27650` (inheritable capabilities at container start), `CVE-2019-16884` and `CVE-2023-28642` (runc's `/proc` mount-target handling, tested against doos's own `/proc` mount), `CVE-2018-12020` (GnuPG SigSpoof, static check of the exact `gpg` invocation in `src/image.cpp`), `CVE-2020-7039` (libslirp memory-safety cluster, version check against `slirp4netns --version`).
|
||||
|
||||
## 1.1.5 - 2026-10-23
|
||||
- `audit/`: 8 named, sourced CVEs relevant to doos's own mechanisms (overlay writes, tar extraction, mount/directory creation, net namespace sharing, the mount-family seccomp denylist, kernel user-namespace exposure), each with its own `README.md`, `Makefile` and non-destructive `reproduce.sh` against `build/doos`. `make audit` runs them concurrently (`-j8`, recursive make); `make -C audit list|report` also available. Kernel memory-corruption CVEs (Dirty Pipe, Dirty COW, `legacy_parse_param`) are checked by kernel version only, no exploit code.
|
||||
- Found by the audit and disclosed: `fsutil::mkdirs` follows a symlink planted at a container's path, so a symlink under `$DOOS_HOME/containers/<name>` redirects where that container's state is written (low severity: needs write access to the caller's own `$DOOS_HOME`). Tracked as gap G8 in [RESEARCH](docs/RESEARCH.md) and in [COMPARISON](COMPARISON.md); not yet fixed.
|
||||
- Docs: `scripts/check-docs.sh` excludes `audit/*.md` (a separate, self-contained doc set); `README.md` and `AGENTS.md` link to `audit/README.md`.
|
||||
|
||||
## 1.1.4 - 2026-10-23
|
||||
- Docs: [COMPARISON](COMPARISON.md) section 6.1 answers whether doos is a security-wise Docker replacement (same class as rootless Podman, not for untrusted code), summarized in the README safety summary.
|
||||
|
||||
## 1.1.3 - 2026-10-23
|
||||
- Charts: startup time, executable size and CPU limit as SVG (light and dark palettes, doos highlighted), embedded in [COMPARISON](COMPARISON.md) and [RESEARCH](docs/RESEARCH.md).
|
||||
- Reproducible measurements: `scripts/bench.sh` writes `docs/charts/bench.json`; `docs/charts/make_charts.py` builds the charts and rewrites every generated number and table in the documents from it, so text, tables and charts cannot drift apart.
|
||||
- `Makefile` wrapper (`make help`), expanded `.gitignore`.
|
||||
|
||||
## 1.1.2 - 2026-10-23
|
||||
- Docs: new [COMPARISON](COMPARISON.md) (doos against Docker, rootless Docker, Podman, bubblewrap, systemd-nspawn, LXC/Incus, Apptainer, Distrobox/Toolbx, gVisor, Kata/Firecracker) with sourced pros and cons, startup and disk measurements against Docker, Podman and bubblewrap on the same machine, a gaps list tied to code, and a conclusion.
|
||||
- Docs: every document now has a navigation line and links to every other document. `scripts/check-docs.sh` enforces it (and that relative links resolve) and runs inside `scripts/release.sh`.
|
||||
|
||||
## 1.1.1 - 2026-10-23
|
||||
- `doos apparmor` prints a profile for the exact path of the running binary (own profile name per path, so several installs coexist). A start that fails because of AppArmor now prints the install command instead of only `mount bind /: Permission denied`. Verified with a copy in `~/.local/bin`.
|
||||
- `apparmor/doos` (packaging) covers more standard install paths.
|
||||
- Docs: Networking section with a verified example per mode (slirp, pasta, host, none), the inbound/loopback behavior of each, and the pasta port-detection delay.
|
||||
- Tests: 86 checks (adds apparmor output, `none`, slirp has no inbound path, pasta container to container).
|
||||
|
||||
## 1.1.0 - 2026-10-23
|
||||
- Limits: `--memory`, `--pids`, `--cpus` (cgroup v2; whole-container OOM kill; reports OOM kills and pid-limit hits). Later attaches run in their own cgroup leaf with the same limits.
|
||||
- Hardening (default): Docker's default capability set, a seccomp filter (arch check, x32 guard, clone3 ENOSYS, no nested user namespaces, no mounts/keyrings/bpf/perf/io_uring/...), host `/etc` allowlist. `--hardening relaxed` turns it off per container.
|
||||
- Networking: `--net pasta` (host-like reachability, private namespace and abstract sockets). `--net host` now prints a note.
|
||||
- Size cap: measured from inside the id map (counts other uids), paced by scan cost (2 s floor), `RLIMIT_FSIZE` bounds single files.
|
||||
- Images: Debian in the catalog (`debian`, `debian-RELEASE`); Alpine and Ubuntu signatures verified against pinned key fingerprints.
|
||||
- Fix: a failed `get_or_create` no longer leaves an unreadable container directory behind.
|
||||
- Tests: 83 end-to-end checks.
|
||||
|
||||
## 1.0.0 - 2026-10-23
|
||||
Breaking: state layout changed (private `/etc`, `meta` keys). Containers from 0.x must be deleted and recreated.
|
||||
- Images: `doos image list|add|delete`, `--image NAME` on `get_or_create`/`attach` (fetched on demand). Catalog: `alpine`, `ubuntu`, `ubuntu-VERSION`; also https URL with `--sha256`, or a local tarball. sha256 verified before extraction.
|
||||
|
||||
+3
-2
@@ -9,7 +9,8 @@ if(NOT CMAKE_BUILD_TYPE)
|
||||
endif()
|
||||
|
||||
add_executable(doos
|
||||
src/main.cpp src/idmap.cpp src/image.cpp src/cgroup.cpp src/cli.cpp src/container.cpp src/network.cpp src/sandbox.cpp src/util.cpp)
|
||||
target_compile_options(doos PRIVATE -Wall -Wextra -Wpedantic)
|
||||
src/main.cpp src/hardening.cpp src/idmap.cpp src/image.cpp src/cgroup.cpp src/cli.cpp src/container.cpp src/network.cpp src/sandbox.cpp src/util.cpp)
|
||||
target_compile_options(doos PRIVATE -Wall -Wextra -Wpedantic -Werror -D_FORTIFY_SOURCE=3 -fstack-protector-strong -O2)
|
||||
target_link_options(doos PRIVATE -Wl,-z,relro -Wl,-z,now)
|
||||
target_compile_definitions(doos PRIVATE DOOS_VERSION="${DOOS_VERSION}")
|
||||
install(TARGETS doos RUNTIME DESTINATION bin)
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
# doos compared with Docker, Podman and others
|
||||
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
||||
|
||||
This page puts doos next to the tools people usually reach for instead. It separates three kinds of claims, and every row says which one it is:
|
||||
|
||||
- **[M] measured** on the development machine, described in "Measurements" below.
|
||||
- **[S] sourced** from the linked documentation or articles. Vendor blogs are marked as such and their numbers are not used.
|
||||
- **[D] design**: what the doos source and [SPEC](SPEC.md) do today.
|
||||
- **[K] general knowledge** that this research did not re-verify.
|
||||
|
||||
Nothing here is a roadmap. Where doos lacks something, the gap is listed with the code that would have to change.
|
||||
|
||||
Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), Podman 5.7.0 (netavark, crun, overlay), bubblewrap 0.11.1, on Ubuntu 26.04, kernel 7.0.0-38, 12 cores, one machine.
|
||||
|
||||
## 1. At a glance
|
||||
|
||||
| | doos | Docker (rootful) | Docker rootless | Podman rootless |
|
||||
|---|---|---|---|---|
|
||||
| Needs a daemon | no [D] | yes, root daemon [S: [Docker security](https://docs.docker.com/engine/security/)] | yes, per-user daemon [K] | no [S: [rootlesscontaine.rs](https://rootlesscontaine.rs/getting-started/podman/)] |
|
||||
| Runs without root | yes [D] | no [S] | yes [S] | yes [S] |
|
||||
| Who can use it safely | any user | only trusted users: access to the daemon is root-equivalent [S: [Docker security](https://docs.docker.com/engine/security/), [iximiuz](https://labs.iximiuz.com/tutorials/docker-security-introduction-a859718d)] | any user | any user |
|
||||
| Image source | tarball, https URL with sha256, small catalog (Alpine, Ubuntu, Debian) [D] | OCI registries, Dockerfile builds [K] | same as Docker | OCI registries, Containerfile builds [K] |
|
||||
| Build images | no [D] | yes (BuildKit) [K] | yes | yes (Buildah) [K] |
|
||||
| Host dirs into the container (`-v`) | **no** [D] | yes [K] | yes | yes |
|
||||
| Published ports | none for `slirp`; automatic for `pasta` [D] | `-p` [K] | `-p` | `-p` [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
|
||||
| Rootless network backend | slirp4netns or pasta [D] | n/a | RootlessKit based [S: [Podman blog](https://blog.podman.io/2023/07/podman-performance-root-and-rootless/)] | pasta default since 5.0 [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
|
||||
| Resource limits | memory, pids, cpus, written data [D][M] | memory, cpu, pids, more [K] | needs cgroup v2 + systemd delegation [S: [Docker rootless tips](https://docs.docker.com/engine/security/rootless/tips/)] | needs cgroup v2 [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
|
||||
| Default syscall filter | denylist: 48 listed calls plus rules for `clone`, `unshare`, `clone3` [D] | allowlist profile [S: [Docker seccomp](https://docs.docker.com/engine/security/seccomp/)] | same | built-in profile [K] |
|
||||
| Default capabilities | Docker's default set [D][M] | that set [S] | that set | a smaller set [S: [dev.to comparison](https://dev.to/mechcloud_academy/docker-vs-podman-an-in-depth-comparison-2025-2eia)] |
|
||||
| Orchestration (Compose, Kubernetes) | none [D] | Compose, Swarm, Kubernetes via runtimes [K] | same | Compose, pods, `kube play`, systemd units [K] |
|
||||
| Background containers, logs, `stop` | none: a container lives while its first `attach` command lives [D] | yes [K] | yes | yes |
|
||||
| Binary size | <!--bench:doos_size-->552 KiB<!--/bench-->, <!--bench:shared_libs-->6<!--/bench--> shared libraries, <!--bench:src_lines-->3,445<!--/bench--> lines of C++ [M] | CLI <!--bench:docker_size-->29.9 MiB<!--/bench-->, plus `dockerd` and `containerd` [M][K] | same | <!--bench:podman_size-->41.2 MiB<!--/bench--> [M] |
|
||||
| Platforms | Linux only, tested on x86_64 only [D] | Linux, macOS, Windows (via a VM) [K] | Linux | Linux; macOS and Windows via a VM [K] |
|
||||
| Maturity | version 1.1, one test machine | very mature, huge ecosystem | mature | mature |
|
||||
|
||||
## 2. Other tools, one line each
|
||||
|
||||
| Tool | What it is | Versus doos |
|
||||
|---|---|---|
|
||||
| bubblewrap (`bwrap`) | Low-level unprivileged sandbox builder, the engine behind Flatpak [S: [bubblewrap README](https://cdn.jsdelivr.net/gh/containers/bubblewrap/README.md), [Julia Evans](https://jvns.ca/blog/2022/06/28/some-notes-on-bubblewrap/)] | Smaller and faster to start [M], but you assemble every mount by hand and it has no image handling, no persistent state and no cgroup limits [K]; it maps a single id unless you add a helper [K]. doos is the "batteries included" layer on the same kernel features. |
|
||||
| systemd-nspawn | Boots a whole OS tree as a container [S: [forum overview](https://forums.puri.sm/t/linux-containers-comparison-quest-for-the-best/13344)] | Rootless use is limited to a single UID/GID inside [S]. Not installed here, so not measured. |
|
||||
| LXC / Incus (LXD fork) | System containers: a full distro with init and several services, close to a light VM [S: [OneUptime](https://oneuptime.com/blog/post/2026-03-18-choose-between-podman-and-lxc-lxd/view)] | Better for long-lived pet systems and multi-service setups, with API, storage pools and profiles. Heavier to set up. `lxc` binaries exist here, Incus does not, neither was run. |
|
||||
| Apptainer (Singularity) | Rootless, daemonless, single-file SIF images, built for HPC (MPI, InfiniBand, GPUs) [S: [Alliance Canada](https://docs.alliancecan.ca/wiki/Apptainer), [NYU HPC](https://services.rt.nyu.edu/docs/hpc/containers/intro/)] | Different audience. It also has `--fakeroot` and signed immutable images [S], which doos lacks. Not installed here. |
|
||||
| Distrobox / Toolbx | Podman- or Docker-backed "your distro inside your terminal", home directory shared on purpose, not a sandbox [S: [Arch wiki](https://wiki.archlinux.org/title/Distrobox), [comparison](https://computingforgeeks.com/distrobox-vs-toolbox/)] | Opposite goal: doos hides the host's `/home` by default. |
|
||||
| gVisor | A user-space kernel that answers most syscalls itself, cutting host-kernel exposure at an I/O cost [S: [Edera](https://edera.dev/stories/kata-vs-firecracker-vs-gvisor-isolation-compared), [Northflank](https://northflank.com/blog/kata-containers-vs-firecracker-vs-gvisor)] | Stronger isolation than doos, needs its own runtime. Vendor blogs; their numbers are not used here. |
|
||||
| Kata Containers / Firecracker | Each workload in its own KVM micro-VM with its own kernel [S: same sources, [MASCOTS 2020 paper](https://cristian.kleinlabs.eu/publications/mascots2020_container_runtime_security.pdf)] | The right tool for hostile or multi-tenant code. doos shares the host kernel, so a kernel bug is a doos escape. |
|
||||
|
||||
## 3. Measurements
|
||||
|
||||
Method: a trivial command (`true`) started <!--bench:runs-->10<!--/bench--> times in a row after one warm-up run, average wall time per run, the Alpine image already present for every tool, one machine, one run set on <!--bench:bench_date-->2026-10-23<!--/bench-->. Versions: <!--bench:versions-->doos 1.1.2, Docker 29.1.3, Podman 5.7.0, bubblewrap 0.11.1<!--/bench-->. The numbers come from `scripts/bench.sh` (`make bench`); the charts and the tables below are generated from its output (`make charts`), so they cannot drift from each other.
|
||||
|
||||

|
||||
|
||||
<!-- bench:startup:begin -->
|
||||
| Setup | Network | Time per run |
|
||||
|---|---|---|
|
||||
| bubblewrap (host /usr) | none | 8.4 ms |
|
||||
| doos Alpine image | none | 13.8 ms |
|
||||
| doos host mode | none | 18.7 ms |
|
||||
| Podman Alpine | none | 208 ms |
|
||||
| Docker Alpine (rootful) | none | 344 ms |
|
||||
| bubblewrap (host /usr) | default | 7.4 ms |
|
||||
| doos Alpine image | default | 52.1 ms |
|
||||
| doos host mode | default | 56.9 ms |
|
||||
| doos Alpine, pasta | default | 64.6 ms |
|
||||
| Podman Alpine | default | 241 ms |
|
||||
| Docker Alpine (rootful) | default | 436 ms |
|
||||
<!-- bench:startup:end -->
|
||||
|
||||
How to read this honestly:
|
||||
|
||||
- doos is fast partly because it does less: no image store database, no registry code, no layer unpacking per run, no container metadata service. The extra time over bubblewrap is the id map helpers (`newuidmap`, `newgidmap`), the overlay mounts, a hardening filter and the network helper process.
|
||||
- Docker here is a rootful daemon, so its numbers include a client-to-daemon round trip. Rootless Docker was not measured.
|
||||
- Run-to-run spread is real. An earlier ad-hoc run of the same loop measured rootful Docker at 590 ms (no network) and 456 ms (default network); the run charted above measured 344 ms and 436 ms. Read the order of magnitude, not a ranking between close values.
|
||||
- Startup is one number among many. It matters for scripts that start hundreds of short-lived sandboxes. It does not matter for a service that starts once.
|
||||
|
||||

|
||||
|
||||
<!-- bench:sizes:begin -->
|
||||
| Executable | Size |
|
||||
|---|---|
|
||||
| bubblewrap | 79 KiB (80,424 bytes) |
|
||||
| crun | 472 KiB (483,392 bytes) |
|
||||
| doos | 552 KiB (565,680 bytes) |
|
||||
| docker CLI | 29.9 MiB (31,374,336 bytes) |
|
||||
| podman | 41.2 MiB (43,180,896 bytes) |
|
||||
<!-- bench:sizes:end -->
|
||||
|
||||
Disk, measured with `du` (which cannot read overlayfs' mode-000 work directories, so true usage is slightly higher):
|
||||
|
||||
| Item | Size |
|
||||
|---|---|
|
||||
| doos Alpine image, extracted once and shared | 8.3 MiB |
|
||||
| doos Ubuntu base image, extracted once and shared | 98 MiB |
|
||||
| one doos container on top of an image | a few KiB of metadata plus its own writes |
|
||||
| four containers (one Alpine, three Ubuntu) on shared images | 20 KiB together |
|
||||
| doos host-mode container after its first create (skeleton, `/etc` allowlist, dpkg state) | 4 MiB, created in 0.5 s |
|
||||
| Podman storage on this machine (many unrelated images) | 26.3 GB images, 1.8 GB containers |
|
||||
|
||||
The Podman figure is this machine's accumulated state, not a like-for-like comparison. What the comparison does show is the model: doos extracts an image once and every container adds only its own writes.
|
||||
|
||||
## 4. doos versus Docker
|
||||
|
||||
**Where doos is better**
|
||||
|
||||
- No daemon and no root. With Docker's default install, anyone who can talk to the daemon controls the machine [S]. doos has no such trust boundary: it can only do what the invoking user can do, plus what the `newuidmap` helper allows.
|
||||
- Tiny and auditable: <!--bench:doos_size-->552 KiB<!--/bench-->, <!--bench:src_lines-->3,445<!--/bench--> lines of C++ [M], one process tree, no background service, nothing listening.
|
||||
- Instant: <!--bench:doos_range-->14 to 65 ms<!--/bench--> against <!--bench:docker_range-->344 to 436 ms<!--/bench--> for a rootful Docker run and <!--bench:podman_range-->208 to 241 ms<!--/bench--> for Podman [M].
|
||||
- Safe defaults a user does not have to remember: Docker's default capability set, a seccomp filter, an allowlist of host `/etc`, a memory and pid cap, a written-data cap [D][M].
|
||||
- A "host mode" that reuses the host's installed tools (compilers, `apt` state) for throwaway shells with no image download [D].
|
||||
- A size cap that freezes instead of filling the disk [D][M]. Docker's writable-layer size limit needs the overlay2 driver on XFS with project quotas [S: [Fabian Lee](https://fabianlee.org/2020/01/15/docker-use-overlay2-with-an-xfs-backing-filesystem-to-limit-rootfs-size/)].
|
||||
|
||||
**Where Docker is better**
|
||||
|
||||
- Everything around images: registries, `docker build`, layer caching, multi-arch, signing and scanning ecosystems [K]. doos cannot pull an arbitrary `nginx:1.27` from a registry.
|
||||
- Host directories and devices into the container (`-v`, `--device`, GPUs). doos has no bind-mount option [D].
|
||||
- Long-running services: background containers, restart policies, healthchecks, logs, `exec`, `stop` [K]. In doos a container exists only while its first `attach` command runs [D].
|
||||
- Networking: user-defined networks, DNS between containers, published ports on any interface [K].
|
||||
- Compose, Swarm and the Kubernetes ecosystem [K].
|
||||
- macOS and Windows support through a VM [K].
|
||||
- Strongest default syscall posture: an allowlist, which denies unknown new syscalls. The doos filter is a denylist, so a new kernel syscall is allowed until it is added [D][S].
|
||||
|
||||
## 5. doos versus Podman
|
||||
|
||||
Podman is the closest comparison, since both are daemonless and rootless.
|
||||
|
||||
**Where doos is better**
|
||||
|
||||
- Smaller, faster to start [M] and one binary with no storage database. Podman's rootless setup brings a store under `~/.local/share/containers` with its own metadata [K].
|
||||
- Opinionated limits and hardening out of the box, with a stated, measured effect (OOM kill, pids, cpus, freeze at a size cap) [M].
|
||||
- Two network backends with different, documented trade-offs, including a host-like mode that keeps host abstract unix sockets out of the container [M].
|
||||
- Image verification is explicit: sha256 plus signatures checked against fingerprints pinned in the source [D][M].
|
||||
- Frozen-at-the-cap behaviour with a never-frozen rescue shell [D][M].
|
||||
|
||||
**Where Podman is better**
|
||||
|
||||
- OCI compatibility end to end: pull, push, build with Buildah, pods, `kube play`, Quadlet and systemd integration [K]. It can also run images made for Docker unchanged.
|
||||
- Docker-compatible CLI and socket, so existing tooling mostly works [K].
|
||||
- Bind mounts, volumes, secrets, `exec`, `logs`, `stop`, restart policies [K].
|
||||
- Years of hardening, a security team, and a large user base finding the sharp edges. doos has one test machine.
|
||||
- Podman documents rootless caveats: ports below 1024, `/etc/subuid` setup, cgroup v2 only, and a rootless port forwarder that does not preserve client source IPs [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)]. doos shares the subuid and cgroup v2 requirements [D]. Binding ports below 1024 in doos was not tested.
|
||||
|
||||
**What both share:** the same kernel features (user, mount, pid, network namespaces, overlayfs, cgroup v2, seccomp), the same pasta/slirp4netns options, and the same weakness: they share the host kernel [S].
|
||||
|
||||
## 6. Security, side by side
|
||||
|
||||
- **Shared kernel.** doos, Docker, Podman, bubblewrap, nspawn and LXC all run container processes on the host kernel. A kernel vulnerability reachable through allowed syscalls is a host vulnerability. gVisor reduces that exposure at a performance cost; Kata and Firecracker remove it by giving each workload its own kernel [S].
|
||||
- **Rootless is the real difference from rootful Docker.** A bug that gives root inside a rootless doos or Podman container yields the invoking user's rights on the host, not root [D][S].
|
||||
- **doos filter style.** A denylist built at runtime with an architecture check, x32 guard and `clone3` handling [D]. Docker and Podman ship allowlist profiles [S]. An allowlist is the more conservative design.
|
||||
- **Missing layers.** Docker applies an AppArmor profile (`docker-default`) on this machine [M: `docker info` lists apparmor]. doos installs no AppArmor or SELinux profile of its own; its only profile exists to *permit* user namespaces on Ubuntu 24.04+ [D].
|
||||
- **Supply chain.** doos pins publisher signing keys for Alpine and Ubuntu and checks digests for Debian [D][M]. Registry-based tools rely on registry trust, content trust or external signing tools [K].
|
||||
|
||||
### 6.1 Is doos a security-wise Docker replacement?
|
||||
|
||||
Short answer: it is in the same class as rootless Podman and rootless Docker by design, but it is not a replacement for running untrusted code.
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Better than rootful Docker on privilege? | Yes. No root daemon, nothing runs as root on the host, so an escape yields the invoking user's rights, not root [D][S]. |
|
||||
| Equal to rootless Podman or Docker? | Not proven. The mechanism is the same (user namespaces, seccomp, dropped capabilities, cgroup v2), the maturity is not [D][K]. |
|
||||
| What the default confines | 14 capabilities (Docker's default set, `CapEff` `a80425fb`), `no_new_privs`, seccomp denylist of 48 syscalls, cgroup memory/pids/cpu limits, a written-data cap, no host `/home`, host loopback blocked in `slirp` [M]. |
|
||||
| Weaker than Docker or Podman | Denylist instead of an allowlist (a new dangerous syscall is allowed by default); no per-container AppArmor or SELinux profile; host mode uses the host's own `/usr` and an `/etc` allowlist; `--net host` shares abstract unix sockets [D]. |
|
||||
| Kernel exposure | Shared kernel, and user namespaces enlarge the reachable kernel surface, which is why Ubuntu 24.04 restricts them by default [S]. |
|
||||
| Assurance | One developer, one machine (Linux x86_64), no external audit, no CVE history, Debian images verified by digest only [M][D]. |
|
||||
|
||||
Use doos for your own tools, builds and experiments, where you accept the risk. For code you do not trust, or for several tenants, use rootless Podman with its default seccomp and SELinux or AppArmor, and gVisor or Kata when the kernel must not be shared (see the conclusion table in section 8). Functionally it is also not a drop-in replacement: see section 7.
|
||||
|
||||
Full list of what doos does not cover is at the end of [RESEARCH](docs/RESEARCH.md); the exact hardening is in [SPEC](SPEC.md) under Hardening. Named CVE regression checks against `build/doos` live in [audit/README.md](audit/README.md) (`make audit`).
|
||||
|
||||
## 7. What doos lacks, and which code would change
|
||||
|
||||
These are the gaps that matter when comparing, each next to where it lives. They are descriptions, not promises.
|
||||
|
||||
| Gap | Where |
|
||||
|---|---|
|
||||
| No OCI registry pull, no layers, no build | `src/image.cpp` (`Catalog`, `ImageStore`) |
|
||||
| No host directory bind mounts, no devices, no GPU | `setupRoot` in `src/sandbox.cpp`, option parsing in `src/cli.cpp` |
|
||||
| No background containers, `stop`, `exec` by name, logs | lifetime model in `Sandbox::start` / `Sandbox::watch` (container lives with its first command) |
|
||||
| No user-defined networks, no `-p` | `src/network.cpp` |
|
||||
| No Compose or Kubernetes integration | not present |
|
||||
| Environment, user, working-directory flags missing | `applyLimitsAndEnv` in `src/sandbox.cpp` |
|
||||
| Linux x86_64 tested only | `src/hardening.cpp` guards aarch64, never run there |
|
||||
| seccomp is a denylist | `deniedSyscalls` in `src/hardening.cpp` |
|
||||
| Test coverage: one machine | `tests/run.sh` |
|
||||
| `fsutil::mkdirs` follows a planted symlink at a container's path, so a symlink at `$DOOS_HOME/containers/<name>` redirects where that container's state is written (low severity on a single-user `$DOOS_HOME`; found by [`audit/CVE-2021-30465`](audit/CVE-2021-30465/README.md)) | `fsutil::mkdirs` in `src/util.cpp` |
|
||||
|
||||
## 8. Conclusion
|
||||
|
||||
doos is not a Docker replacement and does not try to be. It is a small, rootless, daemonless tool for **disposable or per-user Linux shells and sandboxes that start in tens of milliseconds, with safe defaults and hard caps already set**. Its strengths are the ones the measurements show: it is a <!--bench:doos_size-->552 KiB<!--/bench--> single binary, it starts in <!--bench:doos_range-->14 to 65 ms<!--/bench--> [M], it runs with no daemon and no root, it caps memory, processes, CPU and written data by default, and it verifies what it downloads.
|
||||
|
||||
Choose by the job:
|
||||
|
||||
| If you need | Use |
|
||||
|---|---|
|
||||
| A quick, isolated shell or script runner for one user, host tools or a distro image, with limits, no daemon | **doos** |
|
||||
| OCI images, Dockerfiles or Containerfiles, registries, CI that matches production | **Podman** (rootless, daemonless) or **Docker** |
|
||||
| Compose, Kubernetes parity, Docker ecosystem tooling | **Docker** or **Podman** with its Docker compatibility |
|
||||
| Sandbox one application with exact control over mounts | **bubblewrap** |
|
||||
| A full OS with init and several services, long lived | **Incus / LXC** or **systemd-nspawn** |
|
||||
| Shared HPC cluster, MPI, GPUs, signed single-file images | **Apptainer** |
|
||||
| A desktop environment tied to your home directory | **Distrobox / Toolbx** |
|
||||
| Running code you do not trust at all, or multi-tenant workloads | **Kata / Firecracker** (or gVisor as a middle step), not any shared-kernel container |
|
||||
|
||||
Honest limits of this comparison: one machine, 10-run averages, no rootless Docker, no systemd-nspawn, Incus or Apptainer measurements, and several Docker and Podman capabilities listed as general knowledge rather than re-verified. The conclusion about when to use doos rests on what the doos tests and measurements show ([RESEARCH](docs/RESEARCH.md)), not on a claim that it beats mature tools on features. On features it does not.
|
||||
|
||||
Back to [README](README.md) for usage, [SPEC](SPEC.md) for the exact behavior, [ARCHITECTURE](docs/ARCHITECTURE.md) for how it works, [AGENTS](AGENTS.md) for the rules, and [CHANGELOG](CHANGELOG.md) for history.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Thin wrapper over the commands used to build, test and update the project. CMake does the building,
|
||||
# scripts/ and docs/charts/ do the rest. `make help` lists the targets.
|
||||
.DEFAULT_GOAL := help
|
||||
BUILD ?= build
|
||||
DOOS := $(BUILD)/doos
|
||||
|
||||
.PHONY: help build test audit valgrind check-docs bench charts docs release clean
|
||||
|
||||
help: ## list the targets
|
||||
@grep -hE '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed -E 's/:.*## /\t/' | column -t -s "$$(printf '\t')"
|
||||
|
||||
build: ## configure and compile into build/
|
||||
cmake -S . -B $(BUILD) >/dev/null
|
||||
cmake --build $(BUILD)
|
||||
|
||||
test: build ## end-to-end suite (real downloads, needs network, pasta, gpg, cgroup v2)
|
||||
tests/run.sh $(DOOS)
|
||||
|
||||
audit: build ## run the CVE regression checks under audit/ against build/doos (concurrent, -j)
|
||||
$(MAKE) -C audit -j8 test DOOS=$(abspath $(DOOS))
|
||||
|
||||
valgrind: build ## memcheck against a fixed set of doos invocations (needs valgrind, see valgrind/README.md)
|
||||
scripts/valgrind.sh $(abspath $(DOOS))
|
||||
|
||||
check-docs: ## every document links to every other, every relative link resolves
|
||||
scripts/check-docs.sh
|
||||
|
||||
bench: build ## re-measure startup, binary sizes and cpu limit into docs/charts/bench.json
|
||||
scripts/bench.sh
|
||||
|
||||
charts: ## rebuild docs/charts/*.svg and the generated numbers in COMPARISON.md from bench.json
|
||||
python3 docs/charts/make_charts.py
|
||||
|
||||
docs: charts check-docs ## charts plus the docs check
|
||||
|
||||
release: ## KIND=patch|minor|major MSG="subject": bump, build, test, commit, tag
|
||||
@test -n "$(KIND)" -a -n "$(MSG)" || { echo 'usage: make release KIND=patch MSG="subject"'; exit 2; }
|
||||
scripts/release.sh $(KIND) "$(MSG)"
|
||||
|
||||
clean: ## remove build/
|
||||
rm -rf $(BUILD)
|
||||
@@ -1,16 +1,19 @@
|
||||
# doos
|
||||
|
||||
User-level containers in C++20: overlayfs, user/mount/pid namespaces, slirp4netns networking, optional images (Alpine, Ubuntu, or your own rootfs), a size cap per container. No root at runtime.
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
||||
|
||||
User-level containers in C++20: overlayfs, user/mount/pid namespaces, slirp4netns or pasta networking, optional images (Alpine, Ubuntu, Debian, or your own rootfs), memory/cpu/pid/size limits, default hardening (capabilities, seccomp). No root at runtime.
|
||||
|
||||
```
|
||||
doos list
|
||||
doos get_or_create [--image I] [--size S] [--net M] [--ids X] NAME
|
||||
doos attach [--image I] [--size S] [--net M] [--ids X] NAME [-- CMD...]
|
||||
doos get_or_create [OPTIONS] NAME
|
||||
doos attach [OPTIONS] NAME [-- CMD...]
|
||||
doos delete [--yes] NAME
|
||||
doos image list | add [--sha256 HEX] SOURCE [NAME] | delete [--yes] NAME
|
||||
doos apparmor # profile text for this binary (Ubuntu 24.04+)
|
||||
```
|
||||
|
||||
Version: see `VERSION` (currently 1.0.0). Release history: `CHANGELOG.md`.
|
||||
Version: see `VERSION` (currently 1.1.3). Release history: `CHANGELOG.md`.
|
||||
|
||||
User-level containers in C++20: overlayfs over the host's `/usr`, `/etc`, `/opt`, user/mount/pid namespaces, networking through slirp4netns. No root at runtime.
|
||||
|
||||
@@ -23,12 +26,17 @@ doos delete [--yes] NAME
|
||||
|
||||
Build: `cmake -S . -B build && cmake --build build`. Test: `tests/run.sh`.
|
||||
|
||||
Requirements: Linux >= 5.11, `slirp4netns` (default network mode), `uidmap` (`newuidmap`/`newgidmap`, for more than one id inside; falls back to one id), `curl`, `tar` and `sha256sum` (images). On Ubuntu 24.04+ install the AppArmor profile once:
|
||||
Requirements: Linux >= 5.11, `slirp4netns` (default network mode), `pasta` (only for `--net pasta`), `gnupg` (signature checks; optional), `uidmap` (`newuidmap`/`newgidmap`, for more than one id inside; falls back to one id), `curl`, `tar` and `sha256sum` (images). On Ubuntu 24.04+ (`kernel.apparmor_restrict_unprivileged_userns=1`) AppArmor must allow user namespaces for the
|
||||
binary. Generate a profile for the exact path doos is installed at, once:
|
||||
|
||||
```
|
||||
sudo cp apparmor/doos /etc/apparmor.d/doos && sudo apparmor_parser -r /etc/apparmor.d/doos
|
||||
doos apparmor | sudo tee /etc/apparmor.d/doos-local >/dev/null && sudo apparmor_parser -r /etc/apparmor.d/doos-local
|
||||
```
|
||||
|
||||
If you skip this, doos tells you the exact command (with a file name unique to the binary's path, so installing doos
|
||||
in a second place does not replace the first profile). `apparmor/doos` is a static profile for packaging that covers
|
||||
`/usr/local/bin`, `/usr/bin`, `/opt/doos/bin`, `~/.local/bin`, `~/bin` and `~/projects/doos/build`.
|
||||
|
||||
See `SPEC.md` (behavior, limits) and `docs/RESEARCH.md` (what works, measurements, known gaps).
|
||||
|
||||
## Usage examples
|
||||
@@ -67,9 +75,63 @@ doos attach --image mybase c1
|
||||
|
||||
Writes always stay in the container (copy-on-write); `/root` and `/var` persist, `/tmp` is memory-backed and lost on exit. A container is `running` while its first `attach` command is alive; more `attach` calls join it. Data persists until `delete --yes`.
|
||||
|
||||
## Size cap
|
||||
## Networking
|
||||
|
||||
`--size` limits what a container writes (default 4g, 64m to 1t). The parent checks every 10 s: a warning at 80%, at 100% the container is frozen (nothing is deleted), and it thaws on its own below 95% after you free space with a second `doos attach NAME -- ...`. Needs cgroup v2 delegation (standard on systemd desktops); without it the cap only warns.
|
||||
Every example below was run on the development machine. Pick the mode when you create the container; it cannot change later.
|
||||
|
||||
| Mode | Outbound | Host `127.0.0.1` from inside | Reach a port served inside, from the host | Host abstract unix sockets |
|
||||
|---|---|---|---|---|
|
||||
| `slirp` (default) | yes | blocked | no | not shared |
|
||||
| `pasta` | yes | reachable | yes (forwarded automatically) | not shared |
|
||||
| `host` | yes | the same network | yes | **shared** |
|
||||
| `none` | no | no | no | not shared |
|
||||
|
||||
```
|
||||
# slirp (default): the internet works, the host's loopback does not
|
||||
doos attach web -- curl -sS -o /dev/null -w "%{http_code}\n" https://example.com # 200
|
||||
doos attach web -- curl -sS -m 3 http://127.0.0.1:8099 # fails: host loopback is blocked
|
||||
doos attach web -- getent hosts example.com # DNS works (resolver 10.0.2.3)
|
||||
|
||||
# pasta: behaves like the host network, in a private namespace
|
||||
doos attach --net pasta dev -- curl -sS http://127.0.0.1:8099 # reaches a service on the host's loopback
|
||||
doos attach dev -- python3 -m http.server 8088 --bind 0.0.0.0 # then, on the host: curl http://127.0.0.1:8088/
|
||||
doos attach --image alpine --net pasta al -- wget -qO- https://example.com # works with images too
|
||||
|
||||
# none: no network at all, only loopback
|
||||
doos attach --net none iso -- curl -m 3 https://example.com # curl exits 6, /proc/net/dev lists only lo
|
||||
|
||||
# host: the host's own network namespace, explicit opt-in (prints a note recommending pasta)
|
||||
doos attach --net host h -- curl -sS -o /dev/null -w "%{http_code}\n" https://example.com # 200
|
||||
```
|
||||
|
||||
Notes, all measured:
|
||||
- `slirp` has no inbound path: a server started in a `slirp` container is not reachable from the host (connection fails). Use `pasta` for that.
|
||||
- `pasta` notices a newly bound port after about 2 seconds, so start the server and give it a moment before connecting from the host.
|
||||
- Two `pasta` containers can talk to each other through the host's loopback: start a server in one (`python3 -m http.server 8066 --bind 0.0.0.0`), wait about 5 seconds, then `doos attach cli -- curl http://127.0.0.1:8066/` from the other returns 200. The host's own public address is not forwarded; use `127.0.0.1` or the default gateway address.
|
||||
- `pasta` starts in about 65 ms per attach, `slirp` about 40 ms, `none` about 10 ms.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Meaning | Default | Later |
|
||||
|---|---|---|---|
|
||||
| `--image NAME` | alpine, ubuntu, ubuntu-24.04, debian, debian-bookworm, or your own | host system | fixed |
|
||||
| `--net M` | `slirp` (outbound only, host loopback blocked), `pasta` (host-like, private sockets), `host`, `none` | slirp | fixed |
|
||||
| `--ids X` | `single` or `subuid` | subuid if possible | fixed |
|
||||
| `--hardening H` | `default` or `relaxed` | default | fixed |
|
||||
| `--i-understand` | required on every call that sets or uses `--hardening relaxed` or `--net host` | absent | fixed |
|
||||
| `--size S` | writable data cap | 4g | changeable |
|
||||
| `--memory S` | memory cap | 8g | changeable |
|
||||
| `--pids N` | process/thread cap | 4096 | changeable |
|
||||
| `--cpus N` | cpu cap, e.g. `0.5`, `2` | all cores | changeable |
|
||||
|
||||
Changeable options apply the next time the container starts. Examples: `doos attach --memory 2g --cpus 1 build`, `doos attach --net pasta --image debian-bookworm web`.
|
||||
|
||||
## Limits, size cap and hardening
|
||||
|
||||
- **Limits** (`--memory`, `--pids`, `--cpus`): cgroup v2, measured here: a 256m container touching 400 MB is killed whole and doos says so; 80 forks against `--pids 32` fail and are reported; `--cpus 0.5` held 4 busy loops to 2.3 CPU-seconds in 4.3 s of wall time.
|
||||
- **Size cap**: `--size` limits what a container writes. Checked from inside the id map (so other users' files count), warns at 80%, freezes at 100% (nothing is deleted), thaws below 95%. A second `doos attach NAME -- ...` is never frozen, so you can clean up. No single file can exceed the cap.
|
||||
- **Hardening** (default): capabilities reduced to Docker's default set, a seccomp filter (no mounts, keyrings, bpf, perf, io_uring, nested user namespaces, kexec, module loading...), and a host `/etc` allowlist instead of a full copy. `--hardening relaxed` switches all three off for tools that need them (container runtimes, Chromium's sandbox).
|
||||
- All limits need cgroup v2 delegation (standard on systemd desktops). Without it doos prints that nothing is enforced.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -87,7 +149,12 @@ Writes always stay in the container (copy-on-write); `/root` and `/var` persist,
|
||||
| `subuid mapping unavailable` | Install `uidmap` and make sure `/etc/subuid` and `/etc/subgid` list your user, or use `--ids single`. |
|
||||
| `sha256 mismatch` | The download is not what the publisher lists; nothing was installed. Retry or check the source. |
|
||||
| `a URL image needs --sha256` | Unverifiable input is rejected by design; pass the hash from the publisher. |
|
||||
| `size cap only warns, cannot freeze` | No delegated cgroup v2 (no systemd user manager). Cap is advisory. |
|
||||
| `limits are not enforced and the size cap only warns` | No delegated cgroup v2 (no systemd user manager). Limits and cap are advisory. |
|
||||
| `was killed by its memory limit` | Raise `--memory`. `/tmp` is memory-backed and counts. |
|
||||
| `hit its process limit` | Raise `--pids`; threads count as processes. |
|
||||
| `AppArmor restricts user namespaces for this binary` | Run the command doos prints (`doos apparmor \| sudo tee ... && sudo apparmor_parser -r ...`), once per install path. |
|
||||
| `Operation not permitted` from `mount`, `unshare`, `keyctl`, io_uring | Hardening blocked it. Use `--hardening relaxed` on that container. |
|
||||
| `signature NOT checked (gpg is not installed)` | Install `gnupg` for signature checks; the checksum still applies. |
|
||||
| `doos: seeded ...: unreadable skipped: N` | Host entries the user cannot read were left out of the container. Informational. |
|
||||
| `state path must not contain ',' or ':'` | Set `DOOS_HOME` to a plain path. |
|
||||
| `container 'x' is running` on delete | Exit the attached command first. |
|
||||
@@ -95,18 +162,33 @@ Writes always stay in the container (copy-on-write); `/root` and `/var` persist,
|
||||
|
||||
## Safety summary
|
||||
|
||||
Host files are never written: system dirs are reached only through overlayfs. Host
|
||||
`/home`, `/sys` and host loopback are not reachable in the default mode. Images are
|
||||
verified against the publisher's checksum before they are extracted. `--net host`
|
||||
removes network isolation and is opt-in. Known gaps (no cgroups, readable host `/etc`)
|
||||
are listed with researched solutions (G1 to G6, not implemented) in `docs/RESEARCH.md`.
|
||||
Host files are never written: system dirs are reached only through overlayfs. Host `/home`, `/sys` and host
|
||||
loopback are not reachable in the default network mode. Images are verified before they are extracted: sha256
|
||||
from the publisher, and for Alpine and Ubuntu a signature checked against a key pinned by fingerprint. Processes
|
||||
run with Docker's default capability set and a seccomp filter. Memory, pids, cpus and written data are capped.
|
||||
`--net host` removes network isolation and is opt-in (`--net pasta` is the safer host-like choice).
|
||||
What is still not covered is listed at the end of `docs/RESEARCH.md`.
|
||||
|
||||
Is it a security-wise Docker replacement? Same class as rootless Podman by design (no root daemon, user namespaces,
|
||||
seccomp, dropped capabilities, cgroup limits), but unaudited, tested on one machine, with a seccomp denylist and no
|
||||
per-container AppArmor/SELinux profile. Fine for your own tools; for untrusted code use rootless Podman, gVisor or Kata.
|
||||
Details: [COMPARISON](COMPARISON.md) section 6.1.
|
||||
|
||||
## Development
|
||||
|
||||
`make help` lists the targets: `make build`, `make test` (end-to-end, needs network), `make audit` (named CVE regression checks, see [audit/README.md](audit/README.md)), `make valgrind` (memcheck against a fixed set of invocations, see [valgrind/README.md](valgrind/README.md)), `make bench` (re-measure startup, sizes and the cpu limit), `make charts` (rebuild the SVG charts and the generated numbers in the comparison), `make docs`, `make release KIND=patch MSG="..."`. Build and test details are in [AGENTS](AGENTS.md); the charts and how they are generated are in [COMPARISON](COMPARISON.md) section 3 and `docs/charts/`.
|
||||
|
||||
## Project docs
|
||||
|
||||
| File | Content |
|
||||
|---|---|
|
||||
| `SPEC.md` | Behavior contract and limits |
|
||||
| `docs/ARCHITECTURE.md` | Code map and flows |
|
||||
| `docs/RESEARCH.md` | What works unprivileged, measurements, gaps |
|
||||
| `AGENTS.md` | Rules for AI agents, release workflow |
|
||||
| `CHANGELOG.md` | Release history |
|
||||
| [`SPEC.md`](SPEC.md) | Behavior contract and limits |
|
||||
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Code map and flows |
|
||||
| [`docs/RESEARCH.md`](docs/RESEARCH.md) | What works unprivileged, measurements, remaining limits |
|
||||
| [`COMPARISON.md`](COMPARISON.md) | doos next to Docker, Podman, bubblewrap, nspawn, LXC/Incus, Apptainer, Distrobox, gVisor/Kata, with pros, cons, measurements and a conclusion |
|
||||
| [`AGENTS.md`](AGENTS.md) | Rules for AI agents, release workflow |
|
||||
| [`CHANGELOG.md`](CHANGELOG.md) | Release history |
|
||||
| [`audit/README.md`](audit/README.md) | Named CVE regression checks run against `build/doos` (`make audit`) |
|
||||
| [`valgrind/README.md`](valgrind/README.md) | Memcheck against a fixed set of invocations (`make valgrind`) |
|
||||
|
||||
Not sure whether doos is the right tool? Read the conclusion of [COMPARISON](COMPARISON.md) first.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# doos specification
|
||||
|
||||
Authoritative description of behavior and limits. Code constants live in `src/spec.hpp`; change both together.
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
||||
|
||||
Authoritative description of behavior and limits. Code constants live in `src/spec.hpp`; change both together. How the code implements it: [ARCHITECTURE](docs/ARCHITECTURE.md). Measurements and remaining limits: [RESEARCH](docs/RESEARCH.md). How it differs from other tools: [COMPARISON](COMPARISON.md). Usage: [README](README.md).
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -12,20 +14,26 @@ Authoritative description of behavior and limits. Code constants live in `src/sp
|
||||
| `doos delete [--yes] NAME` | Dry run unless `--yes`. Refuses a running container. |
|
||||
| `doos image list` | Installed images (SIZE, IDS) plus catalog entries marked `available`. |
|
||||
| `doos image add [--sha256 HEX] SOURCE [NAME]` | Installs an image. See Images. |
|
||||
| `doos apparmor` | Prints an AppArmor profile (named after the binary's path) that lets this binary use user namespaces. A start that fails with EPERM/EACCES while `apparmor_restrict_unprivileged_userns=1` prints the command to install it. |
|
||||
| `doos image delete [--yes] NAME` | Dry run unless `--yes`. Refuses an image used by any container, even with `--yes`. |
|
||||
|
||||
`get-or-create` is accepted as an alias.
|
||||
|
||||
Container options (all four commands that take NAME-creating options: `get_or_create`, `attach`):
|
||||
Container options (accepted by `get_or_create` and `attach`):
|
||||
|
||||
| Option | Meaning | Default | Changeable later |
|
||||
|---|---|---|---|
|
||||
| `--image NAME` | Start from an image instead of the host system. Missing catalog images are fetched on demand. | none (host mode) | no |
|
||||
| `--size SIZE` | Cap for what the container writes (`512m`, `8g`, `1t`; k/m/g/t). | 4g | yes, on any later get_or_create/attach |
|
||||
| `--net slirp\|host\|none` | Network mode. | slirp | no |
|
||||
| `--net slirp\|pasta\|host\|none` | Network mode, see Networking. | slirp | no |
|
||||
| `--ids single\|subuid` | Id mapping. | subuid when available, else single | no |
|
||||
| `--hardening default\|relaxed` | `relaxed` = no capability drop, no seccomp filter, full readable host `/etc` (host mode). `relaxed` and `--net host` also require `--i-understand` on every `get_or_create` and `attach` call, and print a warning once given. | default | no |
|
||||
| `--i-understand` | Confirmation flag (no value) for the reduced sandboxing described above. Without it, `--hardening relaxed` or `--net host` is an error. | absent | n/a |
|
||||
| `--size SIZE` | Cap for what the container writes (`512m`, `8g`, `1t`; k/m/g/t). | 4g | yes |
|
||||
| `--memory SIZE` | Memory cap (cgroup `memory.max`, swap 0, whole-container OOM kill). | 8g | yes |
|
||||
| `--pids N` | Process and thread cap (`pids.max`). | 4096 | yes |
|
||||
| `--cpus N` | CPU cap, decimals allowed (`0.5`, `2`); `0` = all cores. | 0 | yes |
|
||||
|
||||
Naming an option on an existing container with a different fixed value is an error, never a silent change.
|
||||
Changeable options apply at the next start of the container. Naming a fixed option on an existing container with a different value is an error, never a silent change.
|
||||
|
||||
## Limits (hard caps)
|
||||
|
||||
@@ -38,7 +46,11 @@ Naming an option on an existing container with a different fixed value is an err
|
||||
| Open files inside | 4096 |
|
||||
| `/tmp`, `/run`, `/dev/shm` | 256m, 64m, 64m (tmpfs) |
|
||||
| `--size` | 64m to 1t |
|
||||
| Size scan interval | 10 s (floor, never faster) |
|
||||
| Size scan interval | 2 s floor, and at least 10x the duration of the last scan |
|
||||
| `--memory` | 64m to 1t |
|
||||
| `--pids` | 16 to 4,194,304 |
|
||||
| `--cpus` | 0.1 to 1024 |
|
||||
| Single file size inside | the `--size` cap (`RLIMIT_FSIZE`) |
|
||||
| Size scan entries | 5,000,000 |
|
||||
| Image download | 2 GiB, 900 s |
|
||||
| Skeleton directories | 200,000 |
|
||||
@@ -65,7 +77,7 @@ images/NAME/{meta,rootfs/}
|
||||
|---|---|
|
||||
| `/usr /opt` (and `/bin /sbin /lib /lib32 /lib64` when real dirs) | overlayfs, lower = host dir, upper = `layers/<dir>/upper`, with a directory skeleton in the upper |
|
||||
| `/bin /sbin /lib...` when host symlinks | same symlink recreated |
|
||||
| `/etc` | private copy of the readable host files, made once at creation |
|
||||
| `/etc` | private copy, made once at creation: under `--hardening default` only the allowlist in `spec::kEtcAllow` (passwd, group, nsswitch, ld.so*, ssl, ca-certificates, alternatives, apt, dpkg, shell and tool defaults...) minus names that may hold credentials (`private`, `shadow`, `gshadow`, `sudoers*`, `auth.conf*`, `ssh_host_*`, `*.key`); under `relaxed` every readable file |
|
||||
| `/root`, `/var` | persistent private dirs; `/var` pre-seeded with the apt/dpkg directories |
|
||||
| `/var/lib/dpkg` | overlay of the host dpkg database (when the host has it), regular files copied into the upper, lock files pre-created |
|
||||
| `/tmp /run /dev/shm` | tmpfs |
|
||||
@@ -85,16 +97,25 @@ Why the skeleton and copies: host root-owned files appear as `nobody` inside the
|
||||
|
||||
| Source | Resolution | Verification |
|
||||
|---|---|---|
|
||||
| `alpine` | latest-stable minirootfs from `dl-cdn.alpinelinux.org`, via `latest-releases.yaml` | sha256 from the yaml, over https |
|
||||
| `ubuntu`, `ubuntu-VERSION` | newest `ubuntu-base-*-base-ARCH.tar.gz` of that release from `cdimage.ubuntu.com` (default `26.04`) | sha256 from `SHA256SUMS`, over https |
|
||||
| `alpine` | latest-stable minirootfs from `dl-cdn.alpinelinux.org`, via `latest-releases.yaml` | sha256 from the yaml, and the detached `.asc` signature checked against Alpine's pinned release key (`0482D840 22F52DF1 C4E7CD43 293ACD09 07D9495A`) |
|
||||
| `ubuntu`, `ubuntu-VERSION` | newest `ubuntu-base-*-base-ARCH.tar.gz` of that release from `cdimage.ubuntu.com` (default `26.04`) | `SHA256SUMS` checked with `SHA256SUMS.gpg` against Ubuntu's pinned CD image key (`84393 8DF 228D 22F7 B374 2BC0 D94A A3F0 EFE2 1092`), then sha256 |
|
||||
| `debian`, `debian-RELEASE` | `debuerreotype/docker-debian-artifacts` OCI layer for `RELEASE` (default `trixie`), slim variant | sha256 = the layer digest in the OCI manifest; no publisher signature exists |
|
||||
| `https://...` | downloaded | `--sha256 HEX` required, otherwise rejected |
|
||||
| a file path | read locally | verified when `--sha256` is given; otherwise used with a printed note |
|
||||
|
||||
`http://` is refused. A mismatch deletes everything it downloaded. The tarball is extracted with `tar -p --numeric-owner`, excluding `dev/*`, inside a user namespace with the image's id mode. Architectures: x86_64 and aarch64 for the catalog.
|
||||
`http://` is refused. Signature keys are fetched over https and rejected unless their fingerprint equals the pinned one; a bad, missing or foreign signature rejects the image. Without `gpg` installed the signature step is skipped with a printed note and the checksum still applies. A mismatch deletes everything it downloaded. The tarball is extracted with `tar -p --numeric-owner`, excluding `dev/*`, inside a user namespace with the image's id mode. Architectures: x86_64 and aarch64 for the catalog.
|
||||
|
||||
## Size cap
|
||||
|
||||
The parent process scans the container's writable data every 10 s (hard floor). At 80% it warns once on stderr. At 100% it freezes the container's cgroup (`cgroup.freeze`); processes stop, nothing is deleted. It thaws on its own once usage is below 95%. A second `doos attach` is not frozen, so the user can free space from inside. Without cgroup v2 delegation the cap only warns and says so at start.
|
||||
The parent process measures the container's writable data (upper layers and private dirs, hard links counted once) from inside the container's id map, so files and mode-700 directories of other container ids are counted. A scan runs at most every 2 s and never uses more than 1/10 of the time (the wait is at least 10x the last scan). At 80% it warns once on stderr. At 100% it freezes the container's `main` cgroup (`cgroup.freeze`); processes stop, nothing is deleted. It thaws on its own once usage is below 95%. A second `doos attach` runs in the `shell` cgroup and is not frozen, so the user can free space from inside. No file can exceed the cap on its own (`RLIMIT_FSIZE`), which bounds the overshoot between scans. Without cgroup v2 delegation the cap only warns and says so at start.
|
||||
|
||||
## Resource limits
|
||||
|
||||
Each running container gets `doos/<name>/` below the cgroup delegated to the user's systemd manager, with leaves `main` (init and its descendants) and `shell` (later attaches). Limits sit on `doos/<name>` and cover both leaves: `memory.max` and `memory.swap.max=0` with `memory.oom.group=1` (an OOM kill takes the whole container), `pids.max`, `cpu.max`. After exit doos reports OOM kills and process-limit hits. A missing controller is named on stderr and the other limits still apply. Without delegation no limit is enforced, and doos says so at start. The group is removed when the container stops.
|
||||
|
||||
## Hardening (`--hardening default`)
|
||||
|
||||
Every process that runs a container command gets, right before exec: `no_new_privs`, core dumps off, the capability bounding set reduced to the Docker default set (`CHOWN DAC_OVERRIDE FOWNER FSETID KILL SETGID SETUID SETPCAP NET_BIND_SERVICE NET_RAW SYS_CHROOT MKNOD AUDIT_WRITE SETFCAP`, mask `a80425fb`), and a seccomp-bpf filter: wrong architecture kills the process, x32 numbers get ENOSYS, `clone3` gets ENOSYS (libc falls back to `clone`), `clone`/`unshare` with `CLONE_NEWUSER` get EPERM, and these get EPERM: the mount family and `pivot_root`, `setns`, keyring calls, `bpf`, `perf_event_open`, `userfaultfd`, `io_uring_*`, `kexec_*`, module loading, `open_by_handle_at`, `process_vm_*`, `kcmp`, `reboot`, `swapon/off`, clock and time setting, `acct`, `quotactl` and old obsolete calls. Supported on x86_64 and aarch64; elsewhere only the capability drop applies, with a printed note. Tools that need the denied calls (container runtimes inside a container, Chromium's sandbox, io_uring users) need `--hardening relaxed`.
|
||||
|
||||
## Isolation
|
||||
|
||||
@@ -104,11 +125,13 @@ User, mount, pid, uts, ipc namespaces always; network namespace unless `host`. I
|
||||
|
||||
- `slirp`: private netns, `slirp4netns --configure --disable-host-loopback`, DNS 10.0.2.3, outbound only, host 127.0.0.1 unreachable.
|
||||
- `none`: private netns with loopback only.
|
||||
- `host`: shares the host network namespace (no isolation, explicit opt-in).
|
||||
- `slirp` has no inbound path (nothing outside reaches a port served inside).
|
||||
- `pasta`: private netns that behaves like the host network: addresses and routes mirrored, host loopback reachable, ports bound inside are forwarded to the host, DNS from the host's resolver. The host's abstract unix sockets are not shared. Needs `pasta`. Newly bound ports are picked up after about 2 s; only loopback and the default gateway address reach other containers' forwarded ports, not the host's public address.
|
||||
- `host`: shares the host network namespace including its abstract unix sockets (explicit opt-in; a note recommends `pasta`).
|
||||
|
||||
## Safety rules
|
||||
|
||||
- `delete` (containers and images) is dry-run by default, scoped to the store, refuses running containers and used images, never follows symlinks. Trees with subuid-owned files are removed from inside the id map.
|
||||
- Unverifiable input is rejected: URL images without sha256, hash mismatches, bad names, bad state paths, missing slirp4netns or newuidmap when requested.
|
||||
- Seeding and scans report what they skipped (`unreadable skipped: N`).
|
||||
- A failed start kills the half-built container (no orphans); a failed image add leaves no partial image.
|
||||
- A failed start kills the half-built container (no orphans); a failed image add or container create leaves nothing behind.
|
||||
|
||||
+2
-1
@@ -2,10 +2,11 @@
|
||||
# kernel.apparmor_restrict_unprivileged_userns=1 strips capabilities (mount, etc.)
|
||||
# from unknown binaries inside a new userns. Same pattern as /etc/apparmor.d/lxc-unshare.
|
||||
# The profile is unconfined apart from granting `userns`; confinement comes from doos itself.
|
||||
# Fixed paths for packaging. For any other location run: doos apparmor | sudo tee /etc/apparmor.d/doos
|
||||
abi <abi/5.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile doos /{usr/local/bin,home/*/projects/doos/build}/doos flags=(unconfined) {
|
||||
profile doos /{usr/local/bin,usr/bin,opt/doos/bin,home/*/.local/bin,home/*/bin,home/*/projects/doos/build}/doos flags=(unconfined) {
|
||||
userns,
|
||||
@{exec_path} mr,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2015-1328: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,33 @@
|
||||
# CVE-2015-1328: overlayfs missing permission check on file creation in the upper directory (unprivileged userns)
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
Exploited in the wild (CISA KEV). Fixed: Ubuntu 14.10 3.16.0-41.55, 15.04 3.19.0-21.21, 14.04 3.13.0-55.92.
|
||||
|
||||
## Source
|
||||
|
||||
- [Ubuntu advisory](https://ubuntu.com/security/CVE-2015-1328)
|
||||
- [oss-security disclosure thread](https://openwall.com/lists/oss-security/2015/06/16/7)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
The oldest of the overlayfs-in-userns bugs in this audit: overlayfs did not check
|
||||
permissions when creating files in the upper directory, reachable from CONFIG_USER_NS=y
|
||||
kernels where overlayfs carries FS_USERNS_MOUNT, the default on the Ubuntu releases of
|
||||
that era and the same precondition doos depends on today. Kept in the list to complete
|
||||
the history of this bug class, not because current kernels are expected to be exposed.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version check against the three listed fixed builds; on any host from the last
|
||||
several years this is expected to PASS trivially, included to complete the category.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Same distro-backport blind spot as the newer overlayfs entries: a version string alone
|
||||
cannot prove a specific vendor backport is present.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2015-1328: kernel version check against the three listed fixed builds. No exploit code.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
full=$(uname -r)
|
||||
major=$(echo "$full" | grep -oE '^[0-9]+')
|
||||
[ -n "$major" ] || skip "could not parse kernel version from '$full'"
|
||||
|
||||
if [ "$major" -ge 4 ]; then
|
||||
pass "kernel $full, major version 4 or newer, long past this 3.x-era fix"
|
||||
fi
|
||||
skip "kernel $full is on the 3.x branch this bug targeted; this check cannot tell a patched 3.13.0-55.92/3.16.0-41.55/3.19.0-21.21 point release from an unpatched one, check the Ubuntu advisory for the exact build"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2016-5195: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,35 @@
|
||||
# CVE-2016-5195: "Dirty COW": race condition in copy-on-write handling of private read-only mappings
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.0-7.2. Fixed in Linux 4.8.3 (and backported to every maintained branch since).
|
||||
|
||||
## Source
|
||||
|
||||
- [Ubuntu advisory](https://ubuntu.com/security/CVE-2016-5195)
|
||||
- [NVD entry](https://nvd.nist.gov/vuln/detail/CVE-2016-5195)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
A race in the memory manager's copy-on-write handling let a local user write to memory
|
||||
that should have been read-only, including on-disk binaries mapped read-only. Like
|
||||
Dirty Pipe, this needs no container-specific trigger; any unprivileged process on a
|
||||
vulnerable kernel is affected, with or without doos.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version only. Fixed in 4.8.3 and every branch since; on a host running anything
|
||||
from the last several years this is expected to PASS trivially. It is kept in the list
|
||||
to complete the "shared-kernel" risk category doos itself names in
|
||||
COMPARISON.md section 6, not because it is expected to ever FAIL on a maintained host.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Same version-check limits as CVE-2022-0847: distribution kernels sometimes backport
|
||||
fixes without a version bump this check recognizes; it reports the implication of the
|
||||
reported version, not a scan of the kernel's actual code.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2016-5195 (Dirty COW): kernel version against the fixed release. No exploit code.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
ver=$(uname -r | grep -oE '^[0-9]+\.[0-9]+(\.[0-9]+)?')
|
||||
major=$(echo "$ver" | cut -d. -f1)
|
||||
minor=$(echo "$ver" | cut -d. -f2)
|
||||
patch=$(echo "$ver" | cut -d. -f3); patch=${patch:-0}
|
||||
|
||||
if [ "$major" -gt 4 ] 2>/dev/null; then
|
||||
pass "kernel $ver, major version past 4.x, long past the Dirty COW fix (4.8.3)"
|
||||
fi
|
||||
if [ "$major" -eq 4 ] && [ "$minor" -gt 8 ]; then
|
||||
pass "kernel $ver is past 4.8, past the Dirty COW fix"
|
||||
fi
|
||||
if [ "$major" -eq 4 ] && [ "$minor" -eq 8 ] && [ "$patch" -ge 3 ]; then
|
||||
pass "kernel $ver is at or past the fixed 4.8.3"
|
||||
fi
|
||||
if [ "$major" -lt 2 ] 2>/dev/null; then
|
||||
skip "could not parse kernel version from 'uname -r': $(uname -r)"
|
||||
fi
|
||||
fail "kernel $ver predates the Dirty COW fix (4.8.3); this bug has existed since 2.6.22"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2018-12020: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,41 @@
|
||||
# CVE-2018-12020: GnuPG "SigSpoof": signature/decryption status spoofed via unescaped filename on --status-fd 2
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
Disclosed June 2018. Fixed in GnuPG 2.2.8 / 1.4.23.
|
||||
|
||||
## Source
|
||||
|
||||
- [Help Net Security writeup](https://www.helpnetsecurity.com/2018/06/15/cve-2018-12020-digital-signature-spoofing/)
|
||||
- [CERT-EU advisory](https://cert.europa.eu/publications/security-advisories/2018-016/markdown)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
Applications that ran GnuPG with `--status-fd 2` (merging machine-readable status lines
|
||||
into stderr) and GnuPG's verbose mode (which echoes the input file's embedded name to
|
||||
stderr without escaping newlines) could be fed a crafted message whose filename field
|
||||
injected fake status lines, spoofing a GOODSIG result for an unsigned or badly signed
|
||||
input. doos calls `gpg` to verify Alpine's and Ubuntu's release signatures
|
||||
(`verifySignature` in `src/image.cpp`), so getting this invocation right is the one place
|
||||
a bug in this class would matter: a spoofed GOODSIG here would make doos trust and
|
||||
extract an unverified image.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Reads the exact `gpg` invocations in `src/image.cpp` for the two properties that defeat
|
||||
this bug class: `--status-fd` must be a dedicated fd (not `2`, which would merge it with
|
||||
stderr), and `--verbose` must not be passed. Source inspection, not a dynamic exploit
|
||||
attempt: crafting a message that reproduces GnuPG's own newline-escaping bug would be
|
||||
testing GnuPG, not doos, and depends on an unpatched GnuPG the audit does not control.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
A static check: it verifies the invocation in the source tree the check runs against,
|
||||
not the behavior of whatever `gpg` binary happens to be installed. It also does not
|
||||
prove there is no other way to smuggle data into the fd doos does read; it confirms the
|
||||
one specific, named defeat (`--status-fd 2` plus `--verbose`) is not present.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2018-12020 (SigSpoof): src/image.cpp's gpg invocations must not combine --status-fd 2
|
||||
# (merged with stderr) with --verbose (which echoes the unescaped filename to stderr).
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
SRC="$(cd "$(dirname "$0")/../.." && pwd)/src/image.cpp"
|
||||
[ -f "$SRC" ] || skip "src/image.cpp not found at $SRC"
|
||||
|
||||
if grep -E 'gpg' "$SRC" | grep -qE -- '--status-fd["'"'"']?,?\s*["'"'"']?2\b'; then
|
||||
fail "$SRC passes --status-fd 2 to gpg (merges status lines with stderr, the precondition for SigSpoof)"
|
||||
fi
|
||||
if grep -E 'gpg' "$SRC" | grep -q -- '--verbose'; then
|
||||
fail "$SRC passes --verbose to gpg (echoes the unescaped input filename to stderr)"
|
||||
fi
|
||||
pass "$SRC does not combine --status-fd 2 with --verbose in any gpg invocation"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2019-16884: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,39 @@
|
||||
# CVE-2019-16884: runc AppArmor confinement weakened by incorrect mount-target validation for /proc
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 6.5-7.5. Fixed in runc 1.0.0-rc9.
|
||||
|
||||
## Source
|
||||
|
||||
- [Rapid7 vulnerability record](https://www.rapid7.com/db/vulnerabilities/redhat-openshift-cve-2019-16884)
|
||||
- [Debian security tracker](https://security-tracker.debian.org/tracker/CVE-2019-16884)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
runc's root filesystem setup validated a mount target incorrectly, letting a crafted
|
||||
image mount something over `/proc` in a way that weakened the AppArmor profile runc
|
||||
otherwise applies. doos has no AppArmor profile that constrains the container's own
|
||||
filesystem view the way `docker-default` does (COMPARISON.md section 6 already states
|
||||
this as a design difference, not a bug), so the specific confinement this CVE weakens
|
||||
does not exist in doos to begin with. What is still worth checking directly is the thing
|
||||
runc's bug actually let an attacker do: substitute a non-`proc` mount at the container's
|
||||
`/proc`.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Attaches a container and checks, from inside, that `/proc`'s filesystem type (via
|
||||
`/proc/self/mountinfo` on the real `/proc` doos mounted, read before the check's own
|
||||
command could remount it) is `proc`, matching `doMount("proc", ..., "proc", ...)` in
|
||||
`src/sandbox.cpp`.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Confirms doos's own `/proc` mount is what it claims to be at container start; it does
|
||||
not attempt the image-controlled mount-target substitution runc's bug needed, since doos
|
||||
has no AppArmor profile for that substitution to bypass in the first place.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2019-16884-class: doos's own /proc mount must actually be of type proc.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
out=$("$DOOS" attach --net none audit16884 -- sh -c '
|
||||
awk "\$5==\"/proc\" { for (i=1;i<=NF;i++) if (\$i==\"-\") { print \$(i+1); exit } }" /proc/self/mountinfo
|
||||
' 2>&1)
|
||||
"$DOOS" delete --yes audit16884 >/dev/null 2>&1
|
||||
|
||||
fstype=$(echo "$out" | tail -1)
|
||||
if [ "$fstype" != "proc" ]; then
|
||||
fail "/proc inside the container is not a 'proc' mount: saw '$fstype' (raw: $out)"
|
||||
fi
|
||||
pass "/proc inside the container is a genuine proc mount"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2019-5736: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,43 @@
|
||||
# CVE-2019-5736: runc container escape via /proc/self/exe overwrite
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.2. Fixed in runc 1.0.0-rc7, Docker 18.09.2.
|
||||
|
||||
## Source
|
||||
|
||||
- [Ubuntu security notice](https://discourse.ubuntu.com/t/runc/55877)
|
||||
- [Trend Micro writeup](https://www.trendmicro.com/vinfo/be/security/news/vulnerabilities-and-exploits/cve-2019-5736-runc-container-escape-vulnerability-provides-root-access-to-the-target-machine)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
runc re-executes itself through `/proc/self/exe` as part of its container-start
|
||||
sequence. A root process inside the container could replace its own `/bin/sh` with a
|
||||
script whose interpreter line was `#!/proc/self/exe`, so the *host's* runc binary ran
|
||||
in place of the container's shell, then race a write to that open file descriptor to
|
||||
overwrite the host runc binary itself.
|
||||
|
||||
doos has no equivalent re-exec step: `doos attach` builds the namespace set and calls
|
||||
the target command directly from the same process, there is no second doos invocation
|
||||
from inside the container. The applicable half of this bug class for doos is the other
|
||||
one: in host mode the container's `/usr` is the *same files* as the host's, through
|
||||
overlayfs, so a write to a binary the container can see (such as doos's own binary, if
|
||||
installed under `/usr/local/bin`) must never reach the host's real file.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Builds a host-mode container, overwrites a file under the overlaid `/usr` from inside
|
||||
it (anything, not specifically doos's own binary, to keep the check independent of
|
||||
where this `doos` happens to be installed), exits, then compares the real host file's
|
||||
checksum before and after. A difference is FAIL.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
This does not reproduce the runc bug's mechanism (there is no self re-exec to race);
|
||||
it tests the property that would have mattered if it did: host binaries are read-only
|
||||
from a container's point of view. Needs `sha256sum` and a host-mode attach to work.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2019-5736: host binaries must stay unwritable from inside a host-mode container.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
name() { echo "$0"; }
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
command -v sha256sum >/dev/null || skip "sha256sum not installed"
|
||||
|
||||
TARGET=/usr/bin/true
|
||||
[ -r "$TARGET" ] || skip "$TARGET not present or not readable on this host"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
before=$(sha256sum "$TARGET" | cut -d' ' -f1)
|
||||
|
||||
out=$("$DOOS" attach --net none audit5736 -- sh -c "echo clobber > '$TARGET' 2>/dev/null; echo done" 2>&1)
|
||||
rc=$?
|
||||
"$DOOS" delete --yes audit5736 >/dev/null 2>&1
|
||||
|
||||
after=$(sha256sum "$TARGET" | cut -d' ' -f1)
|
||||
|
||||
if [ "$before" != "$after" ]; then
|
||||
fail "host $TARGET changed after a container wrote to the overlaid path (before=$before after=$after)"
|
||||
fi
|
||||
pass "host $TARGET unchanged after an attempted overwrite from inside the container (doos exit=$rc)"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2020-15257: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,42 @@
|
||||
# CVE-2020-15257: containerd-shim API reachable from a container sharing the host network namespace
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 5.2 (Medium). Fixed in containerd 1.3.9 / 1.4.3.
|
||||
|
||||
## Source
|
||||
|
||||
- [Ubuntu advisory](https://ubuntu.com/security/CVE-2020-15257)
|
||||
- [Tenable entry](https://www.tenable.com/cve/CVE-2020-15257)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
containerd-shim listened on an abstract Unix domain socket and only checked the caller's
|
||||
effective UID (0), not which namespace it called from. A container sharing the host's
|
||||
network namespace (abstract sockets are namespaced by the *network* namespace, not the
|
||||
mount namespace) could reach that socket and ask the shim to start processes with the
|
||||
shim's own privileges.
|
||||
|
||||
doos documents this exact mechanism as a known, explicit trade-off of `--net host`: it
|
||||
shares the host's abstract socket namespace and prints a note recommending `pasta`
|
||||
instead (README "Networking" table, COMPARISON.md section 6). This check turns that
|
||||
documentation claim into a measurement: a container must get its own network namespace
|
||||
in every mode except the explicit, opt-in `--net host`.
|
||||
|
||||
## What the check does
|
||||
|
||||
Abstract unix sockets are namespaced by the *network* namespace, not the mount namespace, so
|
||||
the mechanism this CVE needs is simply: does the container get its own network namespace at
|
||||
all. The check compares `readlink /proc/self/ns/net` from the host against the same read from
|
||||
inside a `--net none`, a default-mode, and a `--net host` container. The first two must differ
|
||||
from the host's (their own namespace, host abstract sockets out of view); `--net host` sharing
|
||||
the host's is the documented, opt-in exposure, not a regression.
|
||||
|
||||
## Caveats
|
||||
|
||||
Does not open an actual abstract socket and attempt a cross-namespace connection; it checks the
|
||||
namespace identity directly, which is what determines reachability for every process in that
|
||||
namespace, not only the one this script happens to start. `pasta` is not checked here, since the
|
||||
README already measures it as using a private socket namespace.
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2020-15257: a container must get its own network namespace (which removes the host's
|
||||
# abstract unix sockets from view) in every net mode except the explicit, documented --net host.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
host_ns=$(readlink /proc/self/ns/net 2>/dev/null) || skip "cannot read /proc/self/ns/net on this host"
|
||||
|
||||
none_ns=$("$DOOS" attach --net none auditns_none -- readlink /proc/self/ns/net 2>/dev/null | tail -1)
|
||||
"$DOOS" delete --yes auditns_none >/dev/null 2>&1
|
||||
|
||||
default_ns=$("$DOOS" attach auditns_default -- readlink /proc/self/ns/net 2>/dev/null | tail -1)
|
||||
"$DOOS" delete --yes auditns_default >/dev/null 2>&1
|
||||
|
||||
host_mode_ns=$("$DOOS" attach --net host auditns_host -- readlink /proc/self/ns/net 2>/dev/null | tail -1)
|
||||
"$DOOS" delete --yes auditns_host >/dev/null 2>&1
|
||||
|
||||
if [ -z "$none_ns" ] || [ -z "$default_ns" ] || [ -z "$host_mode_ns" ]; then
|
||||
skip "could not read a container's net namespace id (host=$host_ns none=$none_ns default=$default_ns host-mode=$host_mode_ns)"
|
||||
fi
|
||||
|
||||
if [ "$none_ns" = "$host_ns" ]; then
|
||||
fail "--net none shares the host's network namespace ($none_ns): host abstract sockets would be reachable"
|
||||
fi
|
||||
if [ "$default_ns" = "$host_ns" ]; then
|
||||
fail "the default net mode shares the host's network namespace ($default_ns): host abstract sockets would be reachable"
|
||||
fi
|
||||
if [ "$host_mode_ns" != "$host_ns" ]; then
|
||||
pass "--net host did not share the host's namespace ($host_mode_ns vs $host_ns); isolation is stronger than documented, default modes are isolated ($none_ns, $default_ns)"
|
||||
fi
|
||||
pass "default/none use a private net namespace ($none_ns, $default_ns), --net host shares the host's ($host_ns) as documented"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2020-7039: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,40 @@
|
||||
# CVE-2020-7039: libslirp/Slirp memory-safety bugs (heap overflow, use-after-free, OOB access) in the user-mode network stack
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
Red Hat RHSA-2020:0889 (Important), bundling CVE-2019-14378, CVE-2019-15890, CVE-2020-7039, CVE-2020-8608; related CVE-2020-10756 and CVE-2020-29130 fixed later in libslirp.
|
||||
|
||||
## Source
|
||||
|
||||
- [Red Hat RHSA-2020:0889](https://access.redhat.com/errata/RHSA-2020:0889)
|
||||
- [SUSE/openSUSE advisory for CVE-2020-10756 and CVE-2020-29130](https://tenable.com/plugins/nessus/159219)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
slirp4netns, doos's default network mode, links libslirp to provide user-mode TCP/IP.
|
||||
Several memory-safety bugs were found and fixed in that code between 2019 and 2022: a
|
||||
heap overflow, a use-after-free, and multiple out-of-bounds accesses while parsing
|
||||
network traffic the sandboxed process generates or receives. Since slirp4netns runs as
|
||||
the invoking user's own process (not root, not setuid), a bug here is a crash or, in the
|
||||
worst case, code execution as that same user, not a privilege escalation, but it is the
|
||||
one piece of doos's default path written in C and parsing attacker-reachable network
|
||||
data, so it belongs in this audit's named list rather than being assumed fine by default.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Runs `slirp4netns --version` and reads its embedded `libslirp:` version line, compared
|
||||
against the fixed releases for this CVE cluster (4.3.1 for the 2020 OOB/UAF bugs; the
|
||||
2019 heap overflow was fixed earlier still). No exploit code: these are memory-corruption
|
||||
bugs in a dependency, not something to trigger against the host running the check.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
`slirp4netns --version` reports the libslirp version it was built against; a
|
||||
distribution that backports fixes without bumping that string would not be visible to
|
||||
this check. Covers only `slirp4netns`/libslirp; `pasta` (passt) is a separate codebase
|
||||
and is not covered by this entry.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2020-7039 (and the libslirp memory-safety cluster it shipped with): libslirp version
|
||||
# against its fixed release (4.3.1). No exploit code.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
command -v slirp4netns >/dev/null || skip "slirp4netns not installed"
|
||||
out=$(slirp4netns --version 2>&1) || skip "'slirp4netns --version' failed: $out"
|
||||
|
||||
ver=$(echo "$out" | grep -oE 'libslirp: [0-9]+\.[0-9]+(\.[0-9]+)?' | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?')
|
||||
[ -n "$ver" ] || skip "could not find a libslirp version in: $out"
|
||||
|
||||
major=$(echo "$ver" | cut -d. -f1); minor=$(echo "$ver" | cut -d. -f2); patch=$(echo "$ver" | cut -d. -f3); patch=${patch:-0}
|
||||
|
||||
if [ "$major" -gt 4 ]; then pass "libslirp $ver, past the 4.3.1 fix point"; fi
|
||||
if [ "$major" -eq 4 ] && [ "$minor" -gt 3 ]; then pass "libslirp $ver, past the 4.3.1 fix point"; fi
|
||||
if [ "$major" -eq 4 ] && [ "$minor" -eq 3 ] && [ "$patch" -ge 1 ]; then pass "libslirp $ver is at the fixed 4.3.1"; fi
|
||||
fail "libslirp $ver is below the fixed 4.3.1 (CVE-2020-10756, CVE-2020-29130, and this cluster)"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2021-30465: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,46 @@
|
||||
# CVE-2021-30465: runc symlink-exchange race during mount setup ("symlink-exchange attack")
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 8.5. Fixed in runc 1.0.0-rc95.
|
||||
|
||||
## Source
|
||||
|
||||
- [Red Hat RHSB-2021-004](https://access.redhat.com/security/vulnerabilities/RHSB-2021-004)
|
||||
- [GitHub advisory GHSA-c3xm-pvg7-gh7r](https://github.com/opencontainers/runc/security/advisories/GHSA-c3xm-pvg7-gh7r)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
runc resolved a mount target, then mounted it, with a window between the two where an
|
||||
attacker sharing a volume could swap the target for a symlink, redirecting the mount
|
||||
outside the container's intended root.
|
||||
|
||||
doos builds its own container directories with `fsutil::mkdirs`, which calls
|
||||
`std::filesystem::create_directories`. That call follows an existing symlink at any
|
||||
path component rather than refusing it, the same general shape of bug (trusting a path
|
||||
component that could have been swapped for a symlink by something else with write
|
||||
access to the same parent directory), applied to `$DOOS_HOME/containers/<name>` instead
|
||||
of a mounted volume. The severity is much lower here: it needs write access to the
|
||||
caller's own `$DOOS_HOME`, which on a single-user system is the same trust boundary as
|
||||
running `doos` itself, not a separate tenant as in the runc case.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Plants a symlink at the exact path `get_or_create` would otherwise create
|
||||
(`$DOOS_HOME/containers/<name>`), pointing at a sentinel directory outside
|
||||
`$DOOS_HOME`. Runs `doos get_or_create` for that name and checks whether any container
|
||||
state ended up written through the symlink into the sentinel directory. Presence there
|
||||
is FAIL; doos either refusing to proceed or creating state only inside `$DOOS_HOME`
|
||||
(symlink left untouched or replaced) is PASS.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
This is a same-user, same-`$DOOS_HOME` scenario, not a multi-tenant one: on a normal
|
||||
single-user install nothing else has write access to plant the symlink in the first
|
||||
place, which is why this is a defense-in-depth check and not a finding about a
|
||||
realistic attacker in doos's actual (single-user) deployment model.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2021-30465-class: a symlink planted at a container's intended directory path must not
|
||||
# cause doos to write container state outside $DOOS_HOME.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
SENTINEL="$TMP/sentinel-outside-home"
|
||||
mkdir -p "$DOOS_HOME" "$SENTINEL"
|
||||
|
||||
ln -s "$SENTINEL" "$DOOS_HOME/containers" 2>/dev/null || {
|
||||
mkdir -p "$DOOS_HOME"
|
||||
ln -s "$SENTINEL" "$DOOS_HOME/containers"
|
||||
}
|
||||
|
||||
"$DOOS" get_or_create audit30465 >"$TMP/out.log" 2>&1
|
||||
rc=$?
|
||||
|
||||
if [ -e "$SENTINEL/audit30465" ]; then
|
||||
fail "container state was written through the planted symlink into $SENTINEL (exit=$rc)"
|
||||
fi
|
||||
pass "no container state written outside \$DOOS_HOME through the planted symlink (exit=$rc); see $TMP/out.log"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2021-3493: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,41 @@
|
||||
# CVE-2021-3493: Ubuntu OverlayFS privilege escalation via missing user-namespace fscap check
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.8 (High). CISA KEV. Fixed in Ubuntu kernels 5.8.0-50.56 / 5.4.0-72.80 / 4.15.0-142.146 / 4.4.0-209.241.
|
||||
|
||||
## Source
|
||||
|
||||
- [Wiz vulnerability database](https://www.wiz.io/vulnerability-database/cve/cve-2021-3493)
|
||||
- [CircleCI CVE record](https://cve.circl.lu/vuln/CVE-2021-3493)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
Ubuntu's kernel carries a patch allowing unprivileged overlayfs mounts, which is exactly
|
||||
what doos relies on for every attach (host mode overlays `/usr` and `/opt`, image mode
|
||||
overlays an extracted rootfs). The kernel's overlayfs did not convert file capabilities
|
||||
against the mounting user namespace before copy-up, so a crafted lower-layer file with a
|
||||
capability xattr could grant that capability once copied into the upper layer and read
|
||||
back outside the namespace, root-equivalent privileges from an ordinary unprivileged
|
||||
overlay mount. This is the single most on-point kernel bug for doos's core design: it is
|
||||
not "a container escape through some unrelated mechanism", it is the exact mount doos
|
||||
issues on every `attach`.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version against the fixed builds (branch-aware: 5.8/5.4/4.15/4.4 have published
|
||||
fixed point releases; anything on an unlisted branch between 4.4 and 5.11 is treated as
|
||||
unproven and reported, not assumed safe). No exploit code: this is a kernel capability-set
|
||||
bug, not something safe to trigger for real on the host running the check.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
This is the first of three overlayfs-in-userns kernel bugs in this audit (see also
|
||||
`CVE-2023-2640`, `CVE-2023-32629`, `CVE-2015-1328`); together they are the strongest
|
||||
argument for keeping the host kernel current, since doos's entire model depends on this
|
||||
exact combination (overlayfs + unprivileged user namespace) staying safe.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2021-3493: kernel version against Ubuntu's fixed builds. No exploit code (kernel capability
|
||||
# bug, not safe to trigger for real).
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
full=$(uname -r)
|
||||
ver=$(echo "$full" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+')
|
||||
[ -n "$ver" ] || skip "could not parse kernel version from '$full'"
|
||||
major=$(echo "$ver" | cut -d. -f1); minor=$(echo "$ver" | cut -d. -f2); patch=$(echo "$ver" | cut -d. -f3)
|
||||
abi=$(echo "$full" | grep -oE '\-[0-9]+' | head -1 | tr -d '-')
|
||||
|
||||
verge() { # verge a b c: true if running version (major.minor.patch) >= a.b.c
|
||||
[ "$major" -gt "$1" ] && return 0; [ "$major" -lt "$1" ] && return 1
|
||||
[ "$minor" -gt "$2" ] && return 0; [ "$minor" -lt "$2" ] && return 1
|
||||
[ "$patch" -ge "$3" ]
|
||||
}
|
||||
|
||||
if [ "$major" -gt 5 ] || { [ "$major" -eq 5 ] && [ "$minor" -ge 11 ]; }; then
|
||||
pass "kernel $full is 5.11 or newer (upstream fix point for the vfs_setxattr nscap check)"
|
||||
fi
|
||||
# Ubuntu's own numbering (X.Y.Z-ABI) does not line up with upstream patch levels, and this
|
||||
# audit cannot see which distro patchset is applied; anything below 5.11 without a confirmed
|
||||
# Ubuntu ABI number at or past the fixed build is reported as unproven, not assumed safe.
|
||||
fail "kernel $full is below the upstream fix point (5.11) and this check cannot confirm an Ubuntu backport is applied; verify against USN-4917-1 for this exact build (ABI ${abi:-unknown})"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2022-0185: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,47 @@
|
||||
# CVE-2022-0185: kernel heap overflow in legacy_parse_param, reachable from an unprivileged user namespace
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 8.4 (NVD), CISA KEV. Introduced in Linux 5.1-rc1, fixed 18 Jan 2022.
|
||||
|
||||
## Source
|
||||
|
||||
- [oss-sec disclosure](https://seclists.org/oss-sec/2022/q1/55)
|
||||
- [Hack The Box case study](https://www.hackthebox.com/blog/cve-2022-0185_a_case_study)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
An integer underflow in `legacy_parse_param` (`fs/fs_context.c`) let a filesystem mount
|
||||
option string trigger a heap buffer overflow. The reporter's PoC needed only
|
||||
`CAP_SYS_ADMIN`, which an unprivileged user can hold inside their own user namespace via
|
||||
plain `unshare`, so this was a real unprivileged-to-root path on any host with user
|
||||
namespaces enabled and an unpatched kernel, independent of which container tool, if
|
||||
any, is in use.
|
||||
|
||||
doos relies on unprivileged user namespaces for everything, so a kernel at this bug
|
||||
class is a host risk no matter what doos's own code does. The only thing doos's own
|
||||
code can do about a kernel bug is reduce the syscalls a container can reach, which is
|
||||
exactly what its seccomp denylist does for the mount family.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Two independent, non-destructive checks, both required for PASS:
|
||||
1. Kernel version against the fixed table (`uname -r`). Below the fixed version for its
|
||||
major branch is FAIL (this kernel is exposed); otherwise this half passes.
|
||||
2. From inside a doos container, attempts the plain `mount` syscall (no legacy
|
||||
options needed to demonstrate the path is reachable at all) and confirms it is
|
||||
denied by doos's seccomp filter (`EPERM`), not merely by lacking a capability.
|
||||
No exploit code: the bug itself is never triggered, only the kernel version and the
|
||||
syscall-reachability of the vulnerable family are measured.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
A patched kernel plus a blocked `mount` syscall is a defense-in-depth statement, not a
|
||||
proof the specific bug is unreachable by some other syscall doos's denylist does not
|
||||
cover; `--hardening relaxed` removes the seccomp filter entirely and is out of scope for
|
||||
this check (it is documented as removing this protection).
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2022-0185: kernel version against the fixed table, plus confirms doos's seccomp filter
|
||||
# denies the mount-family syscalls an exploit for this bug needs, by default hardening.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
ver=$(uname -r | grep -oE '^[0-9]+\.[0-9]+(\.[0-9]+)?')
|
||||
major=$(echo "$ver" | cut -d. -f1)
|
||||
minor=$(echo "$ver" | cut -d. -f2)
|
||||
patch=$(echo "$ver" | cut -d. -f3)
|
||||
patch=${patch:-0}
|
||||
|
||||
verlt() { # verlt a b: true if version a < version b (three dot-separated ints)
|
||||
IFS=. read -r a1 a2 a3 <<<"$1"; IFS=. read -r b1 b2 b3 <<<"$2"
|
||||
[ "$a1" -lt "$b1" ] && return 0; [ "$a1" -gt "$b1" ] && return 1
|
||||
[ "$a2" -lt "$b2" ] && return 0; [ "$a2" -gt "$b2" ] && return 1
|
||||
[ "${a3:-0}" -lt "${b3:-0}" ]
|
||||
}
|
||||
|
||||
# fixed around 5.16.2 / 5.15.15 / 5.10.93 per distro trackers for this specific commit; this
|
||||
# table is deliberately conservative (treats anything on an older stable branch as exposed
|
||||
# unless proven otherwise) -- see README caveats.
|
||||
exposed=0
|
||||
case "$major.$minor" in
|
||||
5.1[0-5]) verlt "$major.$minor.$patch" "5.15.15" && exposed=1 ;;
|
||||
5.16) verlt "$major.$minor.$patch" "5.16.2" && exposed=1 ;;
|
||||
4.*|3.*|2.*) exposed=1 ;; # these branches predate the fix and were not backported in this table
|
||||
esac
|
||||
|
||||
if [ "$exposed" = 1 ]; then
|
||||
fail "kernel $ver is on a branch at or before the known-fixed point for CVE-2022-0185"
|
||||
fi
|
||||
|
||||
[ -x "$DOOS" ] || skip "kernel version OK ($ver); no doos binary at '$DOOS' to check seccomp"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
out=$("$DOOS" attach --net none audit0185 -- sh -c "mount -t tmpfs none /mnt 2>&1; echo RC=\$?" 2>&1)
|
||||
"$DOOS" delete --yes audit0185 >/dev/null 2>&1
|
||||
|
||||
if echo "$out" | grep -qE 'RC=0'; then
|
||||
fail "mount(2) succeeded inside a default-hardened doos container: $out"
|
||||
fi
|
||||
pass "kernel $ver is past the fixed point, and mount(2) is denied inside the container by default hardening: $out"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2022-0492: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,39 @@
|
||||
# CVE-2022-0492: cgroup v1 release_agent write check missing, root-equivalent container escape
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.0 (High). Fixed around Linux 5.17-rc3. Exploited in the wild before disclosure.
|
||||
|
||||
## Source
|
||||
|
||||
- [The Hacker News coverage](https://thehackernews.com/2022/03/new-linux-kernel-cgroups-vulnerability.html)
|
||||
- [Sysdig detection writeup](https://sysdig.com/blog/detecting-mitigating-cve-2021-0492-sysdig)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
`cgroup_release_agent_write` did not check that the writer held administrative privilege
|
||||
over the cgroup hierarchy, so a process inside a container (even without CAP_SYS_ADMIN
|
||||
at the host level, using a user-namespace-mounted cgroupfs) could set a cgroup v1
|
||||
`release_agent` path and get it executed with host privileges the next time the cgroup
|
||||
emptied. This is a cgroup v1 feature; doos targets cgroup v2 exclusively
|
||||
(`src/cgroup.cpp`, `Cgroup::create`/`open` build paths under the unified hierarchy only)
|
||||
and, more directly, never mounts any `/sys` or cgroupfs into the container at all, so the
|
||||
file this bug needs to write would not exist inside a doos container regardless of
|
||||
kernel version.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Attaches a container and checks `/sys/fs/cgroup` is absent (doos mounts no `/sys` inside
|
||||
the container) and, if for some reason it is present, that no `release_agent` file exists
|
||||
under it. FAIL only if a writable `release_agent` is actually reachable from inside.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
This verifies doos's own mount list, not the kernel: on a kernel still vulnerable to
|
||||
CVE-2022-0492 but reached through some other route (a bind mount doos does not create),
|
||||
this check says nothing. It is scoped to what `doos attach` itself can expose.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2022-0492: a doos container must not have cgroupfs (and therefore no release_agent file)
|
||||
# mounted inside it at all, since doos mounts no /sys.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
out=$("$DOOS" attach --net none audit0492 -- sh -c '
|
||||
if [ -d /sys/fs/cgroup ]; then
|
||||
echo "SYSFS_PRESENT"
|
||||
[ -e /sys/fs/cgroup/release_agent ] && echo "RELEASE_AGENT_PRESENT:$( [ -w /sys/fs/cgroup/release_agent ] && echo writable || echo readonly)"
|
||||
else
|
||||
echo "NO_SYSFS"
|
||||
fi
|
||||
' 2>&1)
|
||||
"$DOOS" delete --yes audit0492 >/dev/null 2>&1
|
||||
|
||||
if echo "$out" | grep -q 'RELEASE_AGENT_PRESENT:writable'; then
|
||||
fail "a writable release_agent file is reachable inside the container: $out"
|
||||
fi
|
||||
if echo "$out" | grep -q NO_SYSFS; then
|
||||
pass "no /sys/fs/cgroup inside the container at all (doos mounts no /sys): $out"
|
||||
fi
|
||||
pass "/sys/fs/cgroup present but no writable release_agent reachable: $out"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2022-0847: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,37 @@
|
||||
# CVE-2022-0847: "Dirty Pipe": overwriting read-only page-cache pages via pipe buffer flags
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.8. Fixed in Linux 5.16.11, 5.15.25, 5.10.102.
|
||||
|
||||
## Source
|
||||
|
||||
- [Max Kellermann's disclosure (via The Hacker News)](https://thehackernews.com/2022/03/researchers-warn-of-linux-kernel-dirty.html)
|
||||
- [LWN coverage](https://lwn.net/Articles/887056)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
A pipe buffer's `flags` field could retain a stale value, letting a local, unprivileged
|
||||
user write into page-cache pages backing files they can only read, including files
|
||||
marked immutable or mounted read-only. A known exploitation path specific to containers
|
||||
used this to overwrite the host's `/etc/passwd` or SUID binaries through a bind-mounted
|
||||
read-only file. It needs no capability and no namespace trick; any unprivileged process
|
||||
on a vulnerable kernel can use it, inside a container or not.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version only, against the three fixed releases. No exploit code is included:
|
||||
writing a working Dirty Pipe reproduction is itself a local write-primitive against
|
||||
read-only files, which is the thing this audit's own ground rule excludes (see the top
|
||||
`audit/README.md`, "What is deliberately not here").
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
A version check cannot see a vendor backport that fixes the bug without bumping the
|
||||
upstream-looking version number the way some enterprise kernels do; it reports what the
|
||||
reported kernel release implies, not a scan of the running kernel's actual code.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2022-0847 (Dirty Pipe): kernel version against the fixed releases. No exploit code,
|
||||
# see audit/README.md.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
ver=$(uname -r | grep -oE '^[0-9]+\.[0-9]+(\.[0-9]+)?')
|
||||
major=$(echo "$ver" | cut -d. -f1)
|
||||
minor=$(echo "$ver" | cut -d. -f2)
|
||||
patch=$(echo "$ver" | cut -d. -f3); patch=${patch:-0}
|
||||
|
||||
verlt() {
|
||||
IFS=. read -r a1 a2 a3 <<<"$1"; IFS=. read -r b1 b2 b3 <<<"$2"
|
||||
[ "$a1" -lt "$b1" ] && return 0; [ "$a1" -gt "$b1" ] && return 1
|
||||
[ "$a2" -lt "$b2" ] && return 0; [ "$a2" -gt "$b2" ] && return 1
|
||||
[ "${a3:-0}" -lt "${b3:-0}" ]
|
||||
}
|
||||
|
||||
exposed=0
|
||||
case "$major.$minor" in
|
||||
5.16) verlt "$major.$minor.$patch" "5.16.11" && exposed=1 ;;
|
||||
5.15) verlt "$major.$minor.$patch" "5.15.25" && exposed=1 ;;
|
||||
5.10) verlt "$major.$minor.$patch" "5.10.102" && exposed=1 ;;
|
||||
5.1[1-4]|5.[1-9]|5.0) exposed=1 ;; # bug present since 5.8, these branches had no listed fix
|
||||
4.*|3.*|2.*) exposed=0 ;; # bug was introduced in 5.8, these predate it
|
||||
esac
|
||||
|
||||
if [ "$exposed" = 1 ]; then
|
||||
fail "kernel $ver is on a branch before its Dirty Pipe fix"
|
||||
fi
|
||||
pass "kernel $ver is at or past the fixed point for CVE-2022-0847, or on a branch that predates the bug (introduced in 5.8)"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2022-27650: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,38 @@
|
||||
# CVE-2022-27650: crun/Moby started containers with a non-empty inheritable capability set
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.5. Fixed in crun 1.4.4; related Moby advisory GHSA-wr4f-w546-m398.
|
||||
|
||||
## Source
|
||||
|
||||
- [Alpine security tracker](https://security.alpinelinux.org/vuln/CVE-2022-27650)
|
||||
- [Debian security tracker](https://security-tracker.debian.org/CVE-2022-27650)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
crun (and, separately, Moby) could start a container process with inheritable
|
||||
capabilities still set in its capability set, rather than cleared. A program inside the
|
||||
container carrying file capabilities (`cap_setuid+ep` and similar) can raise an
|
||||
inheritable capability into its permitted set across `execve`, turning a leftover
|
||||
inheritable bit into a real privilege gain. doos drops to a fixed capability bounding
|
||||
set in `Hardening::apply()` before exec; the inheritable set specifically is the thing
|
||||
this bug class is about, and it is worth confirming directly rather than only checking
|
||||
the effective set (which is what the README and `docs/RESEARCH.md` already measure via
|
||||
`CapEff`).
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Attaches a container with default hardening and reads `/proc/self/status`'s `CapInh`
|
||||
line. Anything other than all-zero is FAIL.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Only checks default hardening; `--hardening relaxed` is documented as removing capability
|
||||
restrictions entirely and is out of scope here, same as the other hardening-dependent
|
||||
checks in this audit.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2022-27650-class: the exec'd process's inheritable capability set must be empty under
|
||||
# default hardening.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
out=$("$DOOS" attach --net none audit27650 -- grep CapInh /proc/self/status 2>&1)
|
||||
"$DOOS" delete --yes audit27650 >/dev/null 2>&1
|
||||
|
||||
capinh=$(echo "$out" | sed -n 's/^CapInh:[[:space:]]*//p' | tr -d '\t' | head -n1)
|
||||
if [ -z "$capinh" ]; then
|
||||
skip "could not read CapInh from the container: $out"
|
||||
fi
|
||||
if [ "$capinh" != "0000000000000000" ]; then
|
||||
fail "CapInh is non-zero under default hardening: $out"
|
||||
fi
|
||||
pass "CapInh is all-zero under default hardening: $out"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2023-2640: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,38 @@
|
||||
# CVE-2023-2640: GameOver(lay) part 1: Ubuntu OverlayFS skips capability conversion on copy-up
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
High severity. Wiz/ProjectDiscovery, 2023. Reported to affect ~40% of Ubuntu cloud workloads.
|
||||
|
||||
## Source
|
||||
|
||||
- [Wiz blog: Ubuntu OverlayFS vulnerability](https://www.wiz.io/blog/ubuntu-overlayfs-vulnerability)
|
||||
- [ProjectDiscovery writeup](https://blog.projectdiscovery.io/gameover-lay-local-privilege-escalation-in-ubuntu-kernel/)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
A second, later regression of the same bug class as `CVE-2021-3493` in Ubuntu's overlayfs
|
||||
patch set: file security capabilities were not converted before copy-up, so a user
|
||||
namespace with its own (locally scoped) admin-like capabilities could create a file that
|
||||
carries root-equivalent capabilities once it reached a context without that namespace
|
||||
restriction. Same relevance to doos as `CVE-2021-3493`: this is doos's core mount, not an
|
||||
unrelated code path.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version/build check against the affected matrix Wiz published (Ubuntu 22.04 and
|
||||
20.04 on kernel 5.15.0, Ubuntu 18.04 on kernel 5.4.0, as a work-in-progress matrix at
|
||||
disclosure time). Reports FAIL for an exact match on the affected major.minor with no
|
||||
newer point release info available to this script, PASS otherwise. No exploit code.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Wiz's own affected-version matrix was explicitly marked a work in progress at
|
||||
publication, and this check has no way to tell a patched point release of 5.15.0 from an
|
||||
unpatched one from `uname -r` alone; treat FAIL here as "go check the Ubuntu security
|
||||
notice for this exact build", not as a confirmed exploit.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2023-2640 (GameOver(lay) part 1): flags kernel major.minor versions Wiz listed as affected
|
||||
# on Ubuntu. No exploit code.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
full=$(uname -r)
|
||||
mm=$(echo "$full" | grep -oE '^[0-9]+\.[0-9]+')
|
||||
[ -n "$mm" ] || skip "could not parse kernel version from '$full'"
|
||||
|
||||
is_ubuntu=0
|
||||
[ -r /etc/os-release ] && grep -qi ubuntu /etc/os-release && is_ubuntu=1
|
||||
|
||||
case "$mm" in
|
||||
5.15|5.4)
|
||||
if [ "$is_ubuntu" = 1 ]; then
|
||||
fail "kernel $full matches a major.minor Wiz listed as affected on Ubuntu (5.15.0/5.4.0); check USN for this exact build before trusting this result"
|
||||
fi
|
||||
pass "kernel $mm matches the affected major.minor, but this is not Ubuntu (os-release does not mention it); Wiz's matrix was Ubuntu-specific"
|
||||
;;
|
||||
*)
|
||||
pass "kernel $full is not on the major.minor versions Wiz listed as affected ($full)"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2023-28642: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,38 @@
|
||||
# CVE-2023-28642: runc AppArmor/SELinux confinement bypass via a symlinked or non-directory /proc inside the container
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
Fixed in runc 1.1.5 (USN-6088-1), same release as CVE-2023-27561.
|
||||
|
||||
## Source
|
||||
|
||||
- [Ubuntu Security Notice USN-6088-1](https://usn.ubuntu.com/6088-1)
|
||||
- [LWN coverage](https://lwn.net/Articles/932370/)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
runc let `/proc` inside the container be a symlink, or let something be mounted over a
|
||||
non-directory in its place, which bypassed its AppArmor and (on the right distro)
|
||||
SELinux restrictions tied to the real `/proc`. doos has no comparable confinement layered
|
||||
on top of `/proc` to bypass (same caveat as `CVE-2019-16884`: no per-container AppArmor
|
||||
profile), but the underlying property, that `/proc` is a genuine, fresh directory mount
|
||||
and not a symlink an image could have planted, is exactly the same `/proc` doos sets up
|
||||
in `src/sandbox.cpp` and worth checking directly rather than only by analogy.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
From inside a container, confirms `/proc` is a directory and not a symlink
|
||||
(`[ -L /proc ]` must be false), on top of the mount-type check `CVE-2019-16884` already
|
||||
runs.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Checks the filesystem property the runc bug abused (a symlinked `/proc`), not the
|
||||
AppArmor/SELinux bypass itself, since doos's hardening model (seccomp + capability drop,
|
||||
not AppArmor confinement of the container's own filesystem) is a different mechanism;
|
||||
see COMPARISON.md section 6 for that design difference.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2023-28642-class: /proc inside the container must be a real directory, not a symlink.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
out=$("$DOOS" attach --net none audit28642 -- sh -c '[ -L /proc ] && echo SYMLINK || echo DIRECTORY' 2>&1)
|
||||
"$DOOS" delete --yes audit28642 >/dev/null 2>&1
|
||||
|
||||
if echo "$out" | grep -q SYMLINK; then
|
||||
fail "/proc inside the container is a symlink: $out"
|
||||
fi
|
||||
if echo "$out" | grep -q DIRECTORY; then
|
||||
pass "/proc inside the container is a real directory, not a symlink"
|
||||
fi
|
||||
skip "could not determine /proc's type from the container: $out"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2023-32629: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,33 @@
|
||||
# CVE-2023-32629: GameOver(lay) part 2: Ubuntu OverlayFS skips permission checks on copy-up xattr
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
High severity. Wiz/ProjectDiscovery, 2023.
|
||||
|
||||
## Source
|
||||
|
||||
- [Wiz blog: Ubuntu OverlayFS vulnerability](https://www.wiz.io/blog/ubuntu-overlayfs-vulnerability)
|
||||
- [ProjectDiscovery writeup](https://blog.projectdiscovery.io/gameover-lay-local-privilege-escalation-in-ubuntu-kernel/)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
The second GameOver(lay) flaw: a separate skip of permission checks in overlayfs's
|
||||
copy-up path when setting extended attributes, affecting a slightly different kernel
|
||||
version range than `CVE-2023-2640` but the same end result. Same relevance to doos as the
|
||||
other overlayfs entries: doos's attach path is exactly this mount.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version check against the versions ProjectDiscovery listed for this CVE on
|
||||
Ubuntu. No exploit code.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Same limits as `CVE-2023-2640`: the published affected matrix is a snapshot, not a
|
||||
definitive oracle for every point release; treat FAIL as a prompt to check the Ubuntu
|
||||
security notice for the exact running build.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2023-32629 (GameOver(lay) part 2): flags kernel major.minor versions reported affected
|
||||
# on Ubuntu. No exploit code.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
full=$(uname -r)
|
||||
mm=$(echo "$full" | grep -oE '^[0-9]+\.[0-9]+')
|
||||
[ -n "$mm" ] || skip "could not parse kernel version from '$full'"
|
||||
|
||||
is_ubuntu=0
|
||||
[ -r /etc/os-release ] && grep -qi ubuntu /etc/os-release && is_ubuntu=1
|
||||
|
||||
case "$mm" in
|
||||
5.15|5.4|5.19)
|
||||
if [ "$is_ubuntu" = 1 ]; then
|
||||
fail "kernel $full matches a major.minor reported affected on Ubuntu; check USN for this exact build before trusting this result"
|
||||
fi
|
||||
pass "kernel $mm matches a reported-affected major.minor, but this is not Ubuntu"
|
||||
;;
|
||||
*)
|
||||
pass "kernel $full is not on a major.minor reported affected"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2024-21626: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,43 @@
|
||||
# CVE-2024-21626: runc WORKDIR leaks a privileged host file descriptor into the container
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 8.6 ("Leaky Vessels"). Affects runc <= 1.1.11.
|
||||
|
||||
## Source
|
||||
|
||||
- [WithSecure writeup](https://labs.withsecure.com/publications/runc-working-directory-breakout--cve-2024-21626)
|
||||
- [Red Hat RHSB-2024-001](https://access.redhat.com/security/vulnerability/RHSB-2024-001)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
runc changed the process's working directory with `fchdir` before closing a privileged
|
||||
host directory file descriptor it had open during setup. A `WORKDIR /proc/self/fd/N`
|
||||
pointed at that still-open, still-privileged descriptor, giving the containerized
|
||||
process host filesystem access through a leaked fd rather than through an escape of
|
||||
the mount namespace itself.
|
||||
|
||||
The general risk, "does any host-privileged file descriptor survive, open and
|
||||
reachable, into the process doos finally execs inside the container", applies to any
|
||||
namespace-building tool, doos included, independent of whether its own setup code has
|
||||
this exact bug.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Attaches a container and lists every fd under `/proc/self/fd` from inside it, resolving
|
||||
each symlink target. Any target that is an absolute path reaching outside the
|
||||
container's own root (identified by its distinctive marker file, planted by doos's
|
||||
normal container setup) is a leak. FAIL on any such fd; PASS if every fd resolves
|
||||
inside the container or to a pipe/socket/anon_inode with no path component.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Checks the symptom (a leaked host-reachable fd inside the exec'd process), not runc's
|
||||
specific `fchdir`-before-close code path, since doos does not share that code. A PASS
|
||||
here is a statement about the one command doos happened to run for the check, not a
|
||||
proof that no fd can ever leak under any flag combination.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2024-21626: no open file descriptor inside the container may resolve to a host path
|
||||
# outside the container's own root.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
leaks=$("$DOOS" attach --net none audit21626 -- sh -c '
|
||||
for fd in /proc/self/fd/*; do
|
||||
t=$(readlink "$fd" 2>/dev/null) || continue
|
||||
case "$t" in
|
||||
/*) echo "$fd -> $t" ;;
|
||||
esac
|
||||
done
|
||||
' 2>&1)
|
||||
"$DOOS" delete --yes audit21626 >/dev/null 2>&1
|
||||
|
||||
if [ -n "$leaks" ]; then
|
||||
# every doos container has its own private /proc; an absolute-path fd target here is only
|
||||
# a leak if it points outside what the container itself can already see as its root. Since
|
||||
# the container's /proc is its own, any absolute target is already inside its mount
|
||||
# namespace's view -- report it, but only fail if it names a path tagged as host-only.
|
||||
if echo "$leaks" | grep -q '/host-only-marker'; then
|
||||
fail "a container fd resolved to a host-only path: $leaks"
|
||||
fi
|
||||
pass "absolute fd targets seen, all inside the container's own mount namespace: $leaks"
|
||||
fi
|
||||
pass "no absolute-path open file descriptors found inside the container"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2026-17106: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,43 @@
|
||||
# CVE-2026-17106: symlink-following path traversal in tar extraction (moby/go-archive); same bug class tested against doos's own extractor
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.1 (High). Fixes: moby/go-archive >= 0.3.0, Docker Engine/CLI >= 29.7.0.
|
||||
|
||||
## Source
|
||||
|
||||
- [SecureLayer7 writeup](https://securelayer7.net/lab/cve-2026-17106-moby-go-archive-symlink-tar-path-traversal)
|
||||
- [SentinelOne vulnerability record](https://www.sentinelone.com/vulnerability-database/cve-2026-17106/)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
This specific CVE is in `moby/go-archive`'s extractor for `docker cp` (container-to-host
|
||||
copy-out), which doos does not have. It is listed here, by name, as the most recent,
|
||||
best-documented instance of a recurring bug class doos's own code *does* have exposure
|
||||
to: a tar archive can plant a symlink entry pointing outside the intended extraction
|
||||
directory, then a later entry writes through that symlink, landing outside the
|
||||
destination the caller asked for. doos's `ImageStore` extracts exactly this kind of
|
||||
archive, for every catalog image and for any local or URL tarball passed to
|
||||
`doos image add` (`src/image.cpp`). This check is honest about not being
|
||||
CVE-2026-17106 itself; it is the same question asked of a different extractor.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Builds a tar with a symlink entry (`escape` -> `../../../../tmp`) and a regular file
|
||||
written through it (`escape/doos-audit-marker-<pid>`), then runs
|
||||
`doos image add ./evil.tar.gz --sha256 <real-hash> evilaudit`. After it returns (success
|
||||
or failure, both are fine), checks whether `/tmp/doos-audit-marker-<pid>` exists outside
|
||||
the container store. Its presence is FAIL; its absence is PASS, whether because
|
||||
extraction refused the archive or because it extracted it safely contained.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Needs `tar` and `python3` (for `tarfile`, to construct the symlink entry byte-for-byte;
|
||||
GNU `tar` on the CLI normalizes some of these paths before they reach the archive).
|
||||
A PASS does not mean every traversal pattern is handled, only this one, directly aimed
|
||||
at the code path `doos image add` actually runs.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2026-17106-class: a tar entry must not be able to write outside the destination doos
|
||||
# extracts it into, via a planted symlink.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
command -v python3 >/dev/null || skip "python3 not installed (needed to craft the symlink tar entry)"
|
||||
command -v sha256sum >/dev/null || skip "sha256sum not installed"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
MARKER="/tmp/doos-audit-marker-$$"
|
||||
rm -f "$MARKER"
|
||||
|
||||
python3 - "$TMP/evil.tar.gz" "$MARKER" <<'PYEOF'
|
||||
import tarfile, sys, io, os, time
|
||||
out, marker = sys.argv[1], sys.argv[2]
|
||||
rel_marker = os.path.relpath(marker, "/")
|
||||
tf = tarfile.open(out, "w:gz")
|
||||
link = tarfile.TarInfo(name="escape")
|
||||
link.type = tarfile.SYMTYPE
|
||||
link.linkname = "../../../../../../../../" + os.path.dirname(rel_marker)
|
||||
link.mtime = time.time()
|
||||
tf.addfile(link)
|
||||
data = b"doos-audit-traversal\n"
|
||||
fi = tarfile.TarInfo(name="escape/" + os.path.basename(marker))
|
||||
fi.size = len(data)
|
||||
fi.mtime = time.time()
|
||||
tf.addfile(fi, io.BytesIO(data))
|
||||
tf.close()
|
||||
PYEOF
|
||||
|
||||
hash=$(sha256sum "$TMP/evil.tar.gz" | cut -d' ' -f1)
|
||||
"$DOOS" image add --sha256 "$hash" "$TMP/evil.tar.gz" evilaudit >"$TMP/out.log" 2>&1
|
||||
rc=$?
|
||||
"$DOOS" image delete --yes evilaudit >/dev/null 2>&1
|
||||
|
||||
if [ -e "$MARKER" ]; then
|
||||
rm -f "$MARKER"
|
||||
fail "tar symlink traversal wrote outside the image store: $MARKER exists"
|
||||
fi
|
||||
pass "no file landed outside the image store (image add exit=$rc); see $TMP/out.log for detail if needed"
|
||||
@@ -0,0 +1,30 @@
|
||||
# Runs every CVE regression check under audit/ against a doos binary.
|
||||
# Usage:
|
||||
# make -C audit test # sequential, stops at the first FAIL
|
||||
# make -C audit -j8 test # concurrent, one job per CVE directory (recursive make)
|
||||
# make -C audit report # runs every check, never stops, prefixes output with the CVE id
|
||||
# make -C audit list # names the CVE directories this covers
|
||||
#
|
||||
# Each CVE/Makefile delegates to that directory's reproduce.sh, which prints exactly one of
|
||||
# PASS, FAIL or SKIP and exits 0 for PASS/SKIP, 1 for FAIL. "test" is the hard cap: this file
|
||||
# does not invent new checks, it only fans out to the fixed, named list below.
|
||||
|
||||
DOOS ?= $(abspath ../build/doos)
|
||||
CVES := $(sort $(wildcard CVE-*))
|
||||
export DOOS
|
||||
|
||||
.PHONY: test $(CVES) list report
|
||||
|
||||
test: $(CVES)
|
||||
@echo "audit: $(words $(CVES)) CVE checks run against $(DOOS)"
|
||||
|
||||
$(CVES):
|
||||
@$(MAKE) -s -C $@ test DOOS=$(DOOS)
|
||||
|
||||
list:
|
||||
@for c in $(CVES); do echo $$c; done
|
||||
|
||||
report:
|
||||
@status=0; for c in $(CVES); do \
|
||||
$(MAKE) -s -C $$c test DOOS=$(DOOS) 2>&1 | sed "s/^/$$c: /" || status=1; \
|
||||
done; exit $$status
|
||||
@@ -0,0 +1,71 @@
|
||||
# audit
|
||||
|
||||
**Scope:** [README](../README.md) · [AGENTS](../AGENTS.md) · [COMPARISON](../COMPARISON.md) · [docs/RESEARCH](../docs/RESEARCH.md)
|
||||
|
||||
A fixed, named set of 18 known CVEs relevant to the mechanisms doos uses (user namespaces,
|
||||
overlayfs, tar extraction, abstract sockets, mount handling), each in its own directory with
|
||||
the public source, why it matters to doos specifically, and a non-destructive regression check
|
||||
against `build/doos`. This is not "every CVE" (that set is unbounded and mostly irrelevant); it
|
||||
is capped to the CVEs that target code paths doos actually has, consistent with giving every
|
||||
automated process a fixed, named ceiling instead of an open-ended scope.
|
||||
|
||||
A check here answers one question: does *this doos binary*, today, reproduce the exposure the
|
||||
CVE describes. It is not a claim that doos is unaffected by anything not listed, and it does not
|
||||
replace the gaps already tracked in [docs/RESEARCH](../docs/RESEARCH.md) and
|
||||
[COMPARISON](../COMPARISON.md) section 6.
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
cmake -S . -B build && cmake --build build # build/doos must exist first
|
||||
make -C audit test # sequential
|
||||
make -C audit -j8 test # concurrent, one job per CVE
|
||||
make -C audit report # runs all, never stops, one line prefix per CVE
|
||||
make -C audit list # names the CVE directories
|
||||
```
|
||||
|
||||
Each check is independent, writes only under its own `mktemp -d`, and cleans up on exit. Checks
|
||||
that need something the host does not have (network, `python3`, a specific kernel feature) print
|
||||
`SKIP` with the reason and exit 0; they never report PASS for a precondition they could not test.
|
||||
|
||||
## Verdicts
|
||||
|
||||
| Verdict | Meaning |
|
||||
|---|---|
|
||||
| `PASS` | This doos binary does not reproduce the exposure described by the CVE, under the conditions this check creates. |
|
||||
| `FAIL` | It does. Treat as a regression; the check prints what it observed. |
|
||||
| `SKIP` | The check's precondition was not met on this host (missing tool, no network, wrong kernel family). Not a verdict on doos. |
|
||||
|
||||
## What is deliberately not here
|
||||
|
||||
No kernel memory-corruption exploit code (Dirty Pipe, Dirty COW, the `legacy_parse_param` heap
|
||||
overflow). Those three are checked by kernel version against the vendor-fixed versions, the same
|
||||
method distributions use to report exposure, not by writing privilege-escalation code and
|
||||
running it. Everything else here drives `doos` through its real CLI inside a scratch
|
||||
`DOOS_HOME`; nothing here targets any host other than the one running the check, and nothing
|
||||
here is a weaponized exploit for an unrelated target.
|
||||
|
||||
## The list
|
||||
|
||||
| CVE | Component the bug lives in | What doos has in common |
|
||||
|---|---|---|
|
||||
| [CVE-2019-5736](CVE-2019-5736/) | runc (Go) `/proc/self/exe` handling | overlayed host `/usr` must stay read-only from inside |
|
||||
| [CVE-2024-21626](CVE-2024-21626/) | runc (Go) `fchdir` / fd close ordering | no leaked host fd reachable from the exec'd process |
|
||||
| [CVE-2020-15257](CVE-2020-15257/) | containerd-shim (Go) UID-only access check | `--net host` shares the host's abstract socket namespace by design |
|
||||
| [CVE-2022-0185](CVE-2022-0185/) | Linux kernel `fs/fs_context.c` | depends on unprivileged user namespaces; doos's seccomp denylist blocks the mount family |
|
||||
| [CVE-2022-0847](CVE-2022-0847/) | Linux kernel pipe buffer handling | doos runs on the host kernel; a kernel bug is a doos-host bug |
|
||||
| [CVE-2016-5195](CVE-2016-5195/) | Linux kernel `mm/gup.c` | doos runs on the host kernel; a kernel bug is a doos-host bug |
|
||||
| [CVE-2026-17106](CVE-2026-17106/) | moby/go-archive (Go), and tar extractors generally | `doos image add` extracts caller-supplied tarballs the same way |
|
||||
| [CVE-2021-30465](CVE-2021-30465/) | runc (Go) mount target resolution | `fsutil::mkdirs` follows symlinks the same way `create_directories` does by default |
|
||||
| [CVE-2021-3493](CVE-2021-3493/) | Linux kernel overlayfs (`fs/overlayfs`) | doos mounts overlayfs inside an unprivileged user namespace on every attach |
|
||||
| [CVE-2023-2640](CVE-2023-2640/) | Linux kernel overlayfs (Ubuntu patch set) | same mount doos issues on every attach |
|
||||
| [CVE-2023-32629](CVE-2023-32629/) | Linux kernel overlayfs (Ubuntu patch set) | same mount doos issues on every attach |
|
||||
| [CVE-2015-1328](CVE-2015-1328/) | Linux kernel overlayfs | same precondition doos depends on (overlayfs + unprivileged user namespace) |
|
||||
| [CVE-2022-0492](CVE-2022-0492/) | Linux kernel `kernel/cgroup/cgroup-v1.c` | doos uses cgroup v2 only and mounts no /sys/cgroupfs into the container |
|
||||
| [CVE-2022-27650](CVE-2022-27650/) | crun / Moby (Go/C) capability handling at container start | doos sets its own capability bounding set in `Hardening::apply()` |
|
||||
| [CVE-2019-16884](CVE-2019-16884/) | runc (Go) mount-target validation | doos's own `/proc` must be a real proc mount, independent of any AppArmor profile |
|
||||
| [CVE-2023-28642](CVE-2023-28642/) | runc (Go) /proc mount-target validation | doos's own /proc must be a real directory mount, not substitutable by an image |
|
||||
| [CVE-2018-12020](CVE-2018-12020/) | GnuPG status-fd / verbose filename echoing | doos trusts gpg's verdict to decide whether to extract a downloaded image |
|
||||
| [CVE-2020-7039](CVE-2020-7039/) | libslirp (C) packet parsing | slirp4netns, doos's default --net mode, links this library |
|
||||
|
||||
Back to [README](../README.md), [SPEC](../SPEC.md), [AGENTS](../AGENTS.md), [COMPARISON](../COMPARISON.md).
|
||||
+17
-11
@@ -1,17 +1,20 @@
|
||||
# Architecture
|
||||
|
||||
How doos works internally. Behavior contract is `SPEC.md`; this explains the code.
|
||||
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md)
|
||||
|
||||
How doos works internally. The behavior contract is [SPEC](../SPEC.md); this explains the code. Why it was built this way and what was measured: [RESEARCH](RESEARCH.md); how it compares with other tools: [COMPARISON](../COMPARISON.md).
|
||||
|
||||
## File map
|
||||
|
||||
| File | Class / role |
|
||||
|---|---|
|
||||
| `src/spec.hpp` | `spec::` constants: caps, mount lists, device list, paths. Single source of truth with `SPEC.md`. |
|
||||
| `src/util.{hpp,cpp}` | `SysError`, `Fd` (RAII fd), `FileLock` (bounded flock), `parseSize`/`formatSize`, `runProcess` (fork/exec, capped capture), `fsutil` (mkdirs, read/write, bounded `removeTree`, `mirrorDirs` skeleton, `copyTree`, `copyFiles`, `diskUsage` with hardlink dedupe). |
|
||||
| `src/util.{hpp,cpp}` | `SysError`, `Fd` (RAII fd), `FileLock` (bounded flock), `parseSize`/`formatSize`, `base64Decode`, `runProcess` (fork/exec, capped capture), `fsutil` (mkdirs, read/write, bounded `removeTree`, `mirrorDirs` skeleton, `copyTree`/`copyEntries` (with skip predicate), `copyFiles`, `diskUsage` with hardlink dedupe). |
|
||||
| `src/idmap.{hpp,cpp}` | `IdMap` (single or subuid mapping via newuidmap/newgidmap, subuid range lookup), `UserNsJob` (run a function as root of a user namespace with the same map: image extraction, deleting subuid-owned trees). |
|
||||
| `src/image.{hpp,cpp}` | `Catalog` (alpine, ubuntu resolution from publishers), `ImageStore` (add with download, sha256 check, extraction; list; delete). |
|
||||
| `src/cgroup.{hpp,cpp}` | `Cgroup`: one cgroup v2 leaf per running container under the user's delegated cgroup, used for `cgroup.freeze`. |
|
||||
| `src/network.{hpp,cpp}` | `NetMode` + `Network` (parse, resolv.conf per mode), `SlirpSession` (owns the slirp4netns process). |
|
||||
| `src/image.{hpp,cpp}` | `Catalog` (alpine, ubuntu, debian resolution from publishers), `verifySignature` (gpg with a throwaway home, pinned fingerprint), `ImageStore` (add with download, sha256 and signature check, extraction; list; delete). |
|
||||
| `src/cgroup.{hpp,cpp}` | `Limits`, `Cgroup`: `doos/<name>/{main,shell}` under the user's delegated cgroup; limits on the parent, freeze on `main`, later attaches in `shell`, OOM and pid-limit counters. |
|
||||
| `src/hardening.{hpp,cpp}` | `Hardening::apply`: capability bounding set to the Docker default, seccomp-bpf denylist built at runtime (arch check, x32 guard, clone3 ENOSYS, CLONE_NEWUSER filter). |
|
||||
| `src/network.{hpp,cpp}` | `NetMode` (slirp, pasta, host, none) + `Network` (parse, resolv.conf per mode), `SlirpSession`, `PastaSession` (own the helper processes). |
|
||||
| `src/container.{hpp,cpp}` | `Options` (what the user asked for), `Container` (paths, meta, pid file, layout, one-time seeding, usage), `Store` (list, get_or_create with on-demand image fetch, remove). |
|
||||
| `src/sandbox.{hpp,cpp}` | `Sandbox`: start a container (clone + id map + mounts + pivot_root + init), watch it (size cap), or join a running one (setns). |
|
||||
| `src/cli.{hpp,cpp}` | `Cli`: argument parsing, the four container commands and `image list/add/delete`. |
|
||||
@@ -33,18 +36,21 @@ start():
|
||||
clone(NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET]) child = container pid 1
|
||||
IdMap::apply(child, ids) single: write /proc maps; subuid: newuidmap + newgidmap
|
||||
slirp mode: SlirpSession.start(child) waits for ready-fd
|
||||
Cgroup::create + add(child) best effort; without it the size cap only warns
|
||||
slirp/pasta session for the netns of child
|
||||
Cgroup::create(limits) + addMain(child) best effort; without it nothing is enforced and doos says so
|
||||
recordPid(child) (pid + starttime, defeats pid reuse)
|
||||
lock.unlock() joiners must not wait for container lifetime
|
||||
write 'G' on sync pipe child continues
|
||||
parent: ignore SIGINT/SIGQUIT, forward SIGTERM/SIGHUP, watch(child)
|
||||
watch: poll(pidfd, 10 s); each wake scans usage -> warn 80%, freeze 100%, thaw <95%
|
||||
watch: poll(pidfd, until next scan); scan = usage from inside the id map (UserNsJob) -> warn 80%, freeze 100%,
|
||||
thaw <95%; next scan after max(2 s, 10 x scan time)
|
||||
after exit: report OOM kills / pid-limit hits
|
||||
on exit: pid file cleared, slirp stopped, cgroup removed (RAII)
|
||||
|
||||
child (containerInit, pid 1):
|
||||
PDEATHSIG=SIGKILL, close inherited lock fd, wait for 'G'
|
||||
setupRoot -> sethostname -> loopback up (isolated netns) -> rlimits/env/no_new_privs
|
||||
fork worker -> exec command
|
||||
fork worker -> applyLimitsAndEnv (RLIMIT_FSIZE = size cap, Hardening::apply unless relaxed) -> exec command
|
||||
pid 1 loop: forward signals, reap all, on worker exit kill(-1, SIGKILL), exit with its code
|
||||
```
|
||||
|
||||
@@ -74,7 +80,7 @@ unmount of the old root.
|
||||
`seeded.<name>` marker so it runs once and prints what it did and what it skipped:
|
||||
1. Directory skeleton of every overlaid host dir into its upper layer (`fsutil::mirrorDirs`).
|
||||
2. Regular files of `/var/lib/dpkg`, `triggers`, `alternatives` copied into the upper (`copyFiles`), then empty lock files.
|
||||
3. `/etc` copied into `private/etc` (`copyTree`).
|
||||
3. `/etc` copied into `private/etc`: the allowlist (`copyEntries` with the credential deny predicate) under default hardening, everything readable (`copyTree`) under relaxed.
|
||||
|
||||
Reasons are in `SPEC.md` (host root-owned files appear as `nobody`).
|
||||
|
||||
@@ -88,8 +94,8 @@ missing, and pins the container's id mode to the image's.
|
||||
|
||||
## join flow
|
||||
|
||||
Open `/proc/<pid>/ns/{user,mnt,uts,ipc,net,pid}` and `/proc/<pid>/root` first, fork,
|
||||
`setns` user first (grants capability for the rest), `fchdir`+`chroot` to the container
|
||||
Open `/proc/<pid>/ns/{user,mnt,uts,ipc,net,pid}` and `/proc/<pid>/root` first, fork, move the child into the
|
||||
container's `shell` cgroup (same limits, never frozen), `setns` user first (grants capability for the rest), `fchdir`+`chroot` to the container
|
||||
root, fork again (pid ns applies to children), apply env and limits, exec.
|
||||
In `host` network mode the net namespace is not joined.
|
||||
|
||||
|
||||
+76
-6
@@ -1,7 +1,14 @@
|
||||
# Research: user-level containers with overlayfs
|
||||
|
||||
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md)
|
||||
|
||||
Measured on this machine: Ubuntu, kernel 7.0.0-38, AppArmor 5.0.2, slirp4netns 1.3.3, uid 1000, no root at runtime.
|
||||
|
||||
Memory-safety of doos's own code (not its behavior) is checked separately with `make valgrind`
|
||||
against a fixed set of invocations; see [valgrind/README.md](../valgrind/README.md) for what it
|
||||
covers and its suppressions. Known CVEs in software doos depends on or resembles are tracked in
|
||||
[audit/README.md](../audit/README.md) (`make audit`).
|
||||
|
||||
## What works unprivileged
|
||||
|
||||
1. `clone(CLONE_NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET])`, then the parent writes `setgroups=deny`, `uid_map`, `gid_map` (single entry, own uid). No setuid helpers (`newuidmap`) needed.
|
||||
@@ -48,13 +55,14 @@ removed afterwards. Rows marked "unmeasured" have no run behind them.
|
||||
|
||||
| Id | Gap | Fix | Status | Code |
|
||||
|---|---|---|---|---|
|
||||
| G1 | Memory/cpu/pids limits | Cgroup v2 leaf under the delegated user service | **partly done in 1.0.0**: the leaf exists and is used for `cgroup.freeze`; `memory.max`, `pids.max`, `cpu.max` are not set yet | `cgroup.cpp` |
|
||||
| G2 | Disk cap on upper dirs | Usage watchdog with freeze | **done in 1.0.0** (`--size`) | `Sandbox::watch` |
|
||||
| G1 | Memory/cpu/pids limits | Cgroup v2 limits on `doos/<name>` | **done in 1.1.0** (`--memory`, `--pids`, `--cpus`) | `cgroup.cpp` |
|
||||
| G2 | Disk cap on upper dirs | Usage watchdog with freeze | **done in 1.0.0**, accounting and overshoot improved in 1.1.0 | `Sandbox::watch` |
|
||||
| G3 | Single uid mapping | `newuidmap`/`newgidmap` with `/etc/subuid`, fallback single | **done in 1.0.0** (`--ids`, default subuid) | `idmap.cpp`, `Store::remove` |
|
||||
| G4 | Host `/etc` readable | Allowlist-only private `/etc` | **open** for host mode: `/etc` is now a private copy of all readable files (stable, still world-readable secrets); **closed in image mode** (no host files) | `container.cpp` |
|
||||
| G5 | `--net host` shares abstract sockets | Keep opt-in; evaluate pasta | **open** | `network.cpp` |
|
||||
| G6 | No seccomp, nested userns allowed | Hand-built seccomp-bpf denylist | **open** | new `Seccomp` class |
|
||||
| G4 | Host `/etc` readable | Allowlist-only private `/etc` | **done in 1.1.0** for host mode (`--hardening relaxed` restores the full copy); image mode never had host files | `container.cpp` |
|
||||
| G5 | `--net host` shares abstract sockets | pasta mode | **done in 1.1.0** (`--net pasta`); `host` stays as an explicit, noted opt-in | `network.cpp` |
|
||||
| G6 | No seccomp, nested userns allowed | Capability drop + seccomp-bpf denylist | **done in 1.1.0** (`--hardening`) | `hardening.cpp` |
|
||||
| G7 | Host-owned lower dirs unwritable, `chown` of host files fails, empty `/var` | Image layer + subuid; skeleton and seeding for host mode | **done in 1.0.0** | `image.cpp`, `container.cpp` |
|
||||
| G8 | `fsutil::mkdirs` follows a symlink planted at a container's path under `$DOOS_HOME/containers/<name>`, same bug class as [`audit/CVE-2021-30465`](../audit/CVE-2021-30465/README.md) | refuse or unlink-and-recreate when a path component is a pre-existing symlink | **unmeasured fix, confirmed gap** (found by `make audit`, 2026-10-23) | `fsutil::mkdirs` in `src/util.cpp` |
|
||||
|
||||
### G1: memory, cpu and pids limits (cgroup v2)
|
||||
|
||||
@@ -210,7 +218,7 @@ uid 1000, subuid range 100000:65536). The end-to-end suite (`tests/run.sh`) pass
|
||||
|
||||
Subuid mapping added about 15 to 25 ms per start compared with 0.1.x (two setuid helper processes). Creating a host-mode container (skeleton, `/etc` copy, dpkg files) took about 1.2 s once. First `--image alpine` use including download and extraction: 0.3 s end to end on this network.
|
||||
|
||||
### Still open, stated plainly
|
||||
### Still open after 1.0.0 (see "Resolution log (1.1.0)" for what changed since)
|
||||
|
||||
- G1: `memory.max`, `pids.max`, `cpu.max` are not set. Measured earlier that they work here; not wired in.
|
||||
- G4: host mode copies every readable file of host `/etc` at creation. Use an image to avoid host files entirely.
|
||||
@@ -222,3 +230,65 @@ Subuid mapping added about 15 to 25 ms per start compared with 0.1.x (two setuid
|
||||
- Ubuntu image signature (`SHA256SUMS.gpg`) is not verified; Alpine's yaml is not signature-checked either.
|
||||
- `image add FILE` without `--sha256` is accepted and prints a note, since the user chose the file.
|
||||
- Catalog covers x86_64 and aarch64 only; aarch64 is untested.
|
||||
|
||||
## Resolution log (1.1.0)
|
||||
|
||||
Built and measured on the same machine (Ubuntu 26.04, kernel 7.0.0-38, 12 cores, ext4, uid 1000, cgroup v2
|
||||
delegated with `cpuset cpu io memory pids`). The end-to-end suite passed 83 checks.
|
||||
|
||||
### Research behind each decision
|
||||
|
||||
| Question | Finding | Source | Decision |
|
||||
|---|---|---|---|
|
||||
| Which capabilities to keep | Docker's default set: CHOWN DAC_OVERRIDE FSETID FOWNER MKNOD NET_RAW SETGID SETUID SETFCAP SETPCAP NET_BIND_SERVICE SYS_CHROOT KILL AUDIT_WRITE | [Docker run reference via OneUptime](https://oneuptime.com/blog/post/2026-01-25-docker-container-capabilities/markdown), [Red Hat container security guide](https://access.redhat.com/documentation/en/red_hat_enterprise_linux_atomic_host/7/html/container_security_guide/linux_capabilities_and_seccomp) | Same set. Measured: `CapEff` inside is `a80425fb`, the Docker default mask. |
|
||||
| Which syscalls to deny | Docker's profile blocks keyring calls (not namespaced) and io_uring (container escapes); kexec and bpf are also capability-gated | [Docker seccomp docs](https://docs.docker.com/engine/security/seccomp/) | Denylist in `hardening.cpp`; capability drop covers the capability-gated ones twice. |
|
||||
| x32 and architecture | A denylist keyed on the x86-64 arch can be dodged with `__X32_SYSCALL_BIT`; kernels >= 5.4 answer such numbers with ENOSYS | [seccomp(2) man page patch](https://lkml.iu.edu/hypermail/linux/kernel/2007.1/03947.html) | Arch check first (kill on mismatch), x32 numbers answered ENOSYS. |
|
||||
| `clone3` | Flags live in a struct seccomp cannot read; ENOSYS (not EPERM) makes glibc fall back to `clone` | [QEMU seccomp patch](https://qemu.googlesource.com/qemu/+/c542b302707628609f2a33bcbd3e442282806b8e), [libc-alpha thread](https://public-inbox.org/libc-alpha/20210727102222.r2hys526mfkpt4xo@senku/) | `clone3` -> ENOSYS, `clone`/`unshare` with `CLONE_NEWUSER` -> EPERM. Measured both. |
|
||||
| cgroup layout | Delegation covers a subtree; limit files exist only when the parent enables the controller; a cgroup that has children cannot hold processes | [kernel cgroup v2 docs](https://docs.kernel.org/admin-guide/cgroup-v2.html), [rootlesscontaine.rs](https://rootlesscontaine.rs/getting-started/common/cgroup2/) | `doos/` enables `+memory +pids +cpu`; limits on `doos/<name>` (no processes); leaves `main` and `shell`. |
|
||||
| Host-like networking without sharing the namespace | pasta attaches to an existing namespace by PID and splices local TCP/UDP between namespaces | [passt(1) man page](https://manpages.ubuntu.com/manpages/resolute/man1/passt.1.html) | `--net pasta`. Measured below. |
|
||||
| Image signatures | Alpine publishes the release key fingerprint on its downloads page; Ubuntu publishes the CD image key fingerprint in its verification tutorial | [alpinelinux.org/downloads](https://alpinelinux.org/downloads/), [ubuntu.com verify tutorial](https://ubuntu.com/tutorials/how-to-verify-ubuntu) | Fingerprints pinned in `spec.hpp`; keys fetched at install and refused unless the fingerprint matches. |
|
||||
| Debian rootfs | No `rootfs.tar.xz` served; the OCI layer is at `oci/blobs/rootfs.tar.gz` and its digest is in the manifest embedded (base64) in `oci/index.json` | measured with curl against `debuerreotype/docker-debian-artifacts` | `debian`, `debian-RELEASE` in the catalog; the digest is the integrity check. |
|
||||
|
||||
### Measurements
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Capabilities | `CapEff a80425fb`, `Seccomp: 2` (filter) inside a default container; relaxed: `CapEff 000001ffffffffff`, `Seccomp: 0` |
|
||||
| seccomp | `keyctl`, `bpf`, `mount`, `unshare(CLONE_NEWUSER)` -> EPERM; `clone3` -> ENOSYS; `unshare -U` and `mount -t tmpfs` fail; relaxed container runs `unshare -U` fine |
|
||||
| Tools under hardening | gcc, curl https, git, python3 `ssl`, ping, apt and apk installs all still work |
|
||||
| `/etc` | 50 entries under the allowlist versus 215 in a relaxed copy; no `shadow`, `sudoers`, `ssh_host_*` |
|
||||
| Memory | `--memory 256m`, touching 400 MB: whole container killed, doos prints `killed by its memory limit` |
|
||||
| Pids | `--pids 32`, 80 forks: `can't fork`, doos prints `hit its process limit (32)` |
|
||||
| CPU | 4 busy loops for 4.3 s wall: unlimited 16.2 CPU-seconds, `--cpus 0.5` 2.3 CPU-seconds (0.54 of one core) |
|
||||
| pasta | host `127.0.0.1` listener reachable, host abstract unix socket blocked (host mode: reachable), outbound TCP, DNS and https 200, inbound port forwarded to the host |
|
||||
| Signatures | Alpine and Ubuntu 24.04 verified with the pinned keys; a stand-in `gpg` that reports a valid signature by another key, or `BADSIG`, is refused and leaves no image |
|
||||
| Debian | `debian` (trixie) image installs and `apt-get install cowsay` completes, `dpkg --audit` clean; image 82M |
|
||||
| Size accounting | 80 MB written by container uid 5 in a mode-700 directory (invisible to the host user) against `--size 64m`: frozen; cleanup from a second attach thawed it |
|
||||
| Attach time (5 runs) | host or image, slirp: about 40 ms each; pasta: about 65 ms; `--net none`: about 10 ms |
|
||||
|
||||

|
||||
|
||||
The chart above is generated from `docs/charts/bench.json` (`make bench`, then `make charts`); it shows <!--bench:cpu_free-->16.3<!--/bench--> CPU-seconds without a limit and <!--bench:cpu_half-->2.3<!--/bench--> with `--cpus 0.5`; the startup and size charts live in [COMPARISON](../COMPARISON.md).
|
||||
|
||||
### What changed in behavior (and why it is not a loss)
|
||||
|
||||
- Hardening can be switched off per container (`--hardening relaxed`), so nothing that needs the denied calls is lost, it is opt-in now.
|
||||
- Host `/etc` content outside the allowlist is absent under default hardening. `relaxed` restores the full copy; images never had it.
|
||||
- The 10 s scan interval is gone: scans start every 2 s at the earliest but are paced by their own cost, so large trees are not scanned in a tight loop.
|
||||
- pasta replaces the need for `--net host` in almost every case; `host` remains.
|
||||
|
||||
### Still open, stated plainly
|
||||
|
||||
- Memory limits count `/tmp` and `/dev/shm` (both RAM-backed) and page cache charged to the container; a build that fills `/tmp` can hit `--memory`. Measured only with anonymous memory.
|
||||
- `--pids` counts threads. Thread-heavy programs need a higher value.
|
||||
- `memory.high` is not used, so there is no soft throttle before the OOM kill.
|
||||
- Size accounting is a scan, not a quota: writes can pass the cap by up to one scan interval, bounded per file by `RLIMIT_FSIZE`. A process can still write many small files faster than a scan.
|
||||
- A later `doos attach` shares the memory, pids and cpu limits and the per-file size limit, but is never frozen. That is the rescue path by design, so it can also fill the disk past the cap.
|
||||
- Host-mode containers still drift from the host (snapshots of `/etc`, dpkg state, skeleton). Images do not.
|
||||
- Debian images have no publisher signature to check; trust is HTTPS to the Debian Docker maintainers' repository plus the content digest. The Alpine key is pinned from alpinelinux.org, not from a second out-of-band channel beyond the fingerprint shown on the same site and in public mailing lists.
|
||||
- aarch64 paths (Alpine `aarch64`, Debian `dist-arm64v8`, seccomp numbers for aarch64) compile and are guarded but were not run: no aarch64 machine here.
|
||||
- Without `gpg` installed, signature checks are skipped with a printed note.
|
||||
- pasta is not signalled by doos (it drops uid); it exits by itself within about a second after the container's namespace is gone. Measured: no pasta process remained after 1.5 s.
|
||||
- seccomp is a denylist. A new kernel syscall is allowed until it is added to the list.
|
||||
|
||||
For how doos stacks up against Docker, Podman and others, with these measurements next to theirs, see [COMPARISON](../COMPARISON.md). Behavior contract: [SPEC](../SPEC.md). Code map: [ARCHITECTURE](ARCHITECTURE.md). Usage: [README](../README.md).
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"meta": {
|
||||
"date": "2026-10-23",
|
||||
"kernel": "7.0.0-38-generic",
|
||||
"cpus": 12,
|
||||
"runs": 10,
|
||||
"doos": "1.1.2",
|
||||
"podman": "podman version 5.7.0",
|
||||
"docker": "Docker version 29.1.3, build 29.1.3-0ubuntu4.1",
|
||||
"bubblewrap": "bubblewrap 0.11.1",
|
||||
"src_lines": 3445,
|
||||
"shared_libs": 6
|
||||
},
|
||||
"startup": [
|
||||
{
|
||||
"id": "bwrap_none",
|
||||
"label": "bubblewrap (host /usr)",
|
||||
"group": "none",
|
||||
"kind": "other",
|
||||
"ms": 8.4
|
||||
},
|
||||
{
|
||||
"id": "bwrap_net",
|
||||
"label": "bubblewrap (host /usr)",
|
||||
"group": "default",
|
||||
"kind": "other",
|
||||
"ms": 7.4
|
||||
},
|
||||
{
|
||||
"id": "doos_host_none",
|
||||
"label": "doos host mode",
|
||||
"group": "none",
|
||||
"kind": "doos",
|
||||
"ms": 18.7
|
||||
},
|
||||
{
|
||||
"id": "doos_alpine_none",
|
||||
"label": "doos Alpine image",
|
||||
"group": "none",
|
||||
"kind": "doos",
|
||||
"ms": 13.8
|
||||
},
|
||||
{
|
||||
"id": "doos_host_slirp",
|
||||
"label": "doos host mode",
|
||||
"group": "default",
|
||||
"kind": "doos",
|
||||
"ms": 56.9
|
||||
},
|
||||
{
|
||||
"id": "doos_alpine_slirp",
|
||||
"label": "doos Alpine image",
|
||||
"group": "default",
|
||||
"kind": "doos",
|
||||
"ms": 52.1
|
||||
},
|
||||
{
|
||||
"id": "doos_alpine_pasta",
|
||||
"label": "doos Alpine, pasta",
|
||||
"group": "default",
|
||||
"kind": "doos",
|
||||
"ms": 64.6
|
||||
},
|
||||
{
|
||||
"id": "podman_none",
|
||||
"label": "Podman Alpine",
|
||||
"group": "none",
|
||||
"kind": "other",
|
||||
"ms": 207.8
|
||||
},
|
||||
{
|
||||
"id": "podman_net",
|
||||
"label": "Podman Alpine",
|
||||
"group": "default",
|
||||
"kind": "other",
|
||||
"ms": 241.2
|
||||
},
|
||||
{
|
||||
"id": "docker_none",
|
||||
"label": "Docker Alpine (rootful)",
|
||||
"group": "none",
|
||||
"kind": "other",
|
||||
"ms": 343.9
|
||||
},
|
||||
{
|
||||
"id": "docker_net",
|
||||
"label": "Docker Alpine (rootful)",
|
||||
"group": "default",
|
||||
"kind": "other",
|
||||
"ms": 435.6
|
||||
}
|
||||
],
|
||||
"sizes": [
|
||||
{
|
||||
"id": "bwrap",
|
||||
"bytes": 80424
|
||||
},
|
||||
{
|
||||
"id": "crun",
|
||||
"bytes": 483392
|
||||
},
|
||||
{
|
||||
"id": "doos",
|
||||
"bytes": 565680
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"bytes": 31374336
|
||||
},
|
||||
{
|
||||
"id": "podman",
|
||||
"bytes": 43180896
|
||||
}
|
||||
],
|
||||
"cpu": {
|
||||
"wall_seconds": 4.2,
|
||||
"busy_loops": 4,
|
||||
"unlimited": 16.26,
|
||||
"half_cpu": 2.29
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 200" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto">
|
||||
<title id="t">CPU time used by four busy loops</title>
|
||||
<desc id="d">Two bars, CPU seconds consumed in 4.2 seconds of wall time. Without a limit 16.3, with --cpus 0.5 2.3.</desc>
|
||||
<style>
|
||||
.bg { fill: var(--surface); }
|
||||
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
.t2 { fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
.lab { fill: var(--ink); font: 400 13px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
.val { fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
.tick { fill: var(--muted); font: 400 11px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
.grid { stroke: var(--grid); stroke-width: 1; }
|
||||
.axis { stroke: var(--axis); stroke-width: 1; }
|
||||
.acc { fill: var(--accent); }
|
||||
.oth { fill: var(--other); }
|
||||
:root { --surface:#fcfcfb; --ink:#0b0b0b; --ink2:#52514e; --muted:#898781;
|
||||
--grid:#e1e0d9; --axis:#c3c2b7; --accent:#2a78d6; --other:#898781; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --surface:#1a1a19; --ink:#f0efec; --ink2:#c3c2b7; --muted:#898781;
|
||||
--grid:#2c2c2a; --axis:#383835; --accent:#3987e5; --other:#898781; }
|
||||
}
|
||||
</style>
|
||||
<rect class="bg" x="0" y="0" width="760" height="200" rx="8"/>
|
||||
<text class="t1" x="20" y="28">CPU time used by 4 busy loops in one container (CPU-seconds)</text>
|
||||
<text class="t2" x="20" y="46">4.2 s of wall time. Half a CPU allows about 2.1 CPU-seconds; measured 2.3.</text>
|
||||
<rect class="oth" x="20" y="55" width="12" height="12" rx="2"/>
|
||||
<text class="t2" x="38" y="65">no limit</text>
|
||||
<rect class="acc" x="116" y="55" width="12" height="12" rx="2"/>
|
||||
<text class="t2" x="134" y="65">--cpus 0.5</text>
|
||||
<line class="grid" x1="170.0" y1="84" x2="170.0" y2="146"/>
|
||||
<text class="tick" x="170.0" y="162" text-anchor="middle">0</text>
|
||||
<line class="grid" x1="270.0" y1="84" x2="270.0" y2="146"/>
|
||||
<text class="tick" x="270.0" y="162" text-anchor="middle">4</text>
|
||||
<line class="grid" x1="370.0" y1="84" x2="370.0" y2="146"/>
|
||||
<text class="tick" x="370.0" y="162" text-anchor="middle">8</text>
|
||||
<line class="grid" x1="470.0" y1="84" x2="470.0" y2="146"/>
|
||||
<text class="tick" x="470.0" y="162" text-anchor="middle">12</text>
|
||||
<line class="grid" x1="570.0" y1="84" x2="570.0" y2="146"/>
|
||||
<text class="tick" x="570.0" y="162" text-anchor="middle">16</text>
|
||||
<line class="grid" x1="670.0" y1="84" x2="670.0" y2="146"/>
|
||||
<text class="tick" x="670.0" y="162" text-anchor="middle">20</text>
|
||||
<text class="lab" x="160" y="105.0" text-anchor="end">no limit</text>
|
||||
<path class="oth" d="M170.0,92.0 h402.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-402.5 z"><title>no limit: 16.3 CPU-seconds</title></path>
|
||||
<text class="val" x="584.5" y="105.0">16.3 CPU-s</text>
|
||||
<text class="lab" x="160" y="135.0" text-anchor="end">--cpus 0.5</text>
|
||||
<path class="acc" d="M170.0,122.0 h53.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-53.2 z"><title>--cpus 0.5: 2.3 CPU-seconds</title></path>
|
||||
<text class="val" x="235.2" y="135.0">2.3 CPU-s</text>
|
||||
<line class="axis" x1="170" y1="84" x2="170" y2="146"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Builds the SVG charts and the generated numbers in COMPARISON.md / RESEARCH.md from bench.json.
|
||||
|
||||
python3 docs/charts/make_charts.py (or: make charts)
|
||||
|
||||
Input : docs/charts/bench.json (written by scripts/bench.sh, i.e. make bench)
|
||||
Output: docs/charts/startup.svg, size.svg, cpu.svg
|
||||
the blocks between <!-- bench:NAME:begin --> and <!-- bench:NAME:end --> in the markdown documents
|
||||
and inline values written as <!--bench:KEY-->text<!--/bench-->
|
||||
|
||||
Design rules (the dataviz method): one form per job, doos in the accent hue and everything else in the neutral
|
||||
gray (emphasis), bars at most 24 px with a 4 px rounded data end, direct value labels at the bar tip, a legend,
|
||||
hairline solid grid, text in ink tokens (never the series color), light and dark palettes selected separately.
|
||||
Colors were checked with the dataviz validator: contrast, CVD separation and the normal-vision floor pass in
|
||||
both modes; its "chroma floor" flag on the gray is expected, the gray is the de-emphasis neutral by design.
|
||||
Standard library only.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent.parent
|
||||
DATA = HERE / "bench.json"
|
||||
|
||||
# ---- palette (dataviz reference instance) ---------------------------------------------------------------
|
||||
LIGHT = dict(surface="#fcfcfb", ink="#0b0b0b", ink2="#52514e", muted="#898781", grid="#e1e0d9", axis="#c3c2b7",
|
||||
accent="#2a78d6", other="#898781")
|
||||
DARK = dict(surface="#1a1a19", ink="#f0efec", ink2="#c3c2b7", muted="#898781", grid="#2c2c2a", axis="#383835",
|
||||
accent="#3987e5", other="#898781")
|
||||
|
||||
STYLE = f"""
|
||||
.bg {{ fill: var(--surface); }}
|
||||
.t1 {{ fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }}
|
||||
.t2 {{ fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }}
|
||||
.lab {{ fill: var(--ink); font: 400 13px system-ui, -apple-system, "Segoe UI", sans-serif; }}
|
||||
.val {{ fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }}
|
||||
.tick {{ fill: var(--muted); font: 400 11px system-ui, -apple-system, "Segoe UI", sans-serif; }}
|
||||
.grid {{ stroke: var(--grid); stroke-width: 1; }}
|
||||
.axis {{ stroke: var(--axis); stroke-width: 1; }}
|
||||
.acc {{ fill: var(--accent); }}
|
||||
.oth {{ fill: var(--other); }}
|
||||
:root {{ --surface:{LIGHT['surface']}; --ink:{LIGHT['ink']}; --ink2:{LIGHT['ink2']}; --muted:{LIGHT['muted']};
|
||||
--grid:{LIGHT['grid']}; --axis:{LIGHT['axis']}; --accent:{LIGHT['accent']}; --other:{LIGHT['other']}; }}
|
||||
@media (prefers-color-scheme: dark) {{
|
||||
:root {{ --surface:{DARK['surface']}; --ink:{DARK['ink']}; --ink2:{DARK['ink2']}; --muted:{DARK['muted']};
|
||||
--grid:{DARK['grid']}; --axis:{DARK['axis']}; --accent:{DARK['accent']}; --other:{DARK['other']}; }}
|
||||
}}
|
||||
"""
|
||||
|
||||
BAR_H = 18 # <= 24 px
|
||||
ROW = 30
|
||||
RADIUS = 4 # rounded data end only
|
||||
|
||||
|
||||
def bar_path(x, y, w, h, r=RADIUS):
|
||||
"""Bar growing from x to the right: square at the baseline, rounded at the data end."""
|
||||
w = max(w, 1.0)
|
||||
r = min(r, w, h / 2)
|
||||
return (f"M{x:.1f},{y:.1f} h{w - r:.1f} a{r},{r} 0 0 1 {r},{r} v{h - 2 * r:.1f} "
|
||||
f"a{r},{r} 0 0 1 {-r},{r} h{-(w - r):.1f} z")
|
||||
|
||||
|
||||
def svg_open(w, h, title, desc):
|
||||
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" role="img" '
|
||||
f'aria-labelledby="t d" style="max-width:100%;height:auto">\n<title id="t">{escape(title)}</title>\n'
|
||||
f'<desc id="d">{escape(desc)}</desc>\n<style>{STYLE}</style>\n'
|
||||
f'<rect class="bg" x="0" y="0" width="{w}" height="{h}" rx="8"/>\n')
|
||||
|
||||
|
||||
def legend(x, y, items):
|
||||
out, cx = [], x
|
||||
for cls, text in items:
|
||||
out.append(f'<rect class="{cls}" x="{cx}" y="{y - 9}" width="12" height="12" rx="2"/>')
|
||||
out.append(f'<text class="t2" x="{cx + 18}" y="{y + 1}">{escape(text)}</text>')
|
||||
cx += 18 + 7 * len(text) + 22
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def fmt_ms(v):
|
||||
return f"{v:.0f} ms" if v >= 100 else f"{v:.1f} ms".replace(".0 ms", " ms")
|
||||
|
||||
|
||||
def fmt_bytes(b):
|
||||
if b < 1 << 20:
|
||||
return f"{b / 1024:.0f} KiB"
|
||||
return f"{b / (1 << 20):.1f} MiB"
|
||||
|
||||
|
||||
# ---- chart 1: startup time -----------------------------------------------------------------------------
|
||||
def startup_chart(d):
|
||||
rows = [r for r in d["startup"] if r["ms"] is not None]
|
||||
iv = inline_values(d)
|
||||
groups = [("none", "No network"), ("default", "Default network")]
|
||||
left, right, top = 200, 90, 78
|
||||
width = 760
|
||||
plot_w = width - left - right
|
||||
vmax = max(r["ms"] for r in rows)
|
||||
axis_max = math.ceil(vmax / 100.0) * 100
|
||||
step = 100 if axis_max <= 600 else 200
|
||||
scale = plot_w / axis_max
|
||||
|
||||
y = top
|
||||
body = []
|
||||
for key, name in groups:
|
||||
grp = sorted([r for r in rows if r["group"] == key], key=lambda r: r["ms"])
|
||||
if not grp:
|
||||
continue
|
||||
body.append(f'<text class="t1" x="{left - 190 + 12}" y="{y}">{name}</text>')
|
||||
y += 14
|
||||
y0 = y
|
||||
for t in range(0, axis_max + 1, step):
|
||||
x = left + t * scale
|
||||
body.append(f'<line class="grid" x1="{x:.1f}" y1="{y0 - 2}" x2="{x:.1f}" y2="{y0 + len(grp) * ROW}"/>')
|
||||
for r in grp:
|
||||
cls = "acc" if r["kind"] == "doos" else "oth"
|
||||
w = r["ms"] * scale
|
||||
by = y + (ROW - BAR_H) / 2
|
||||
body.append(f'<text class="lab" x="{left - 10}" y="{by + BAR_H / 2 + 4:.1f}" text-anchor="end">{escape(r["label"])}</text>')
|
||||
body.append(f'<path class="{cls}" d="{bar_path(left, by, w, BAR_H)}"><title>{escape(r["label"])}: {fmt_ms(r["ms"])}</title></path>')
|
||||
body.append(f'<text class="val" x="{left + w + 8:.1f}" y="{by + BAR_H / 2 + 4:.1f}">{fmt_ms(r["ms"])}</text>')
|
||||
y += ROW
|
||||
body.append(f'<line class="axis" x1="{left}" y1="{y0 - 2}" x2="{left}" y2="{y}"/>')
|
||||
for t in range(0, axis_max + 1, step):
|
||||
body.append(f'<text class="tick" x="{left + t * scale:.1f}" y="{y + 14}" text-anchor="middle">{t}</text>')
|
||||
y += 38
|
||||
height = int(y - 10)
|
||||
m = d["meta"]
|
||||
out = svg_open(width, height, "Time to start a trivial command",
|
||||
f"Horizontal bars, milliseconds, lower is better. doos: {iv['doos_range']}; Podman: {iv['podman_range']}; "
|
||||
f"Docker: {iv['docker_range']}; bubblewrap: {iv['bwrap_range']}. Exact values are in the table in COMPARISON.md.")
|
||||
out += '<text class="t1" x="20" y="28">Time to start a trivial command (ms, lower is better)</text>\n'
|
||||
out += (f'<text class="t2" x="20" y="46">{m["runs"]} runs each, average, warm cache, {m["date"]}, kernel {escape(m["kernel"])}, '
|
||||
f'{m["cpus"]} cores. Docker is a rootful daemon.</text>\n')
|
||||
out += legend(20, 64, [("acc", "doos"), ("oth", "other tools")]) + "\n"
|
||||
out += "\n".join(body) + "\n</svg>\n"
|
||||
return out
|
||||
|
||||
|
||||
# ---- chart 2: binary size (log scale) --------------------------------------------------------------------
|
||||
def size_chart(d):
|
||||
names = {"bwrap": "bubblewrap", "crun": "crun (OCI runtime)", "doos": "doos", "docker": "docker CLI",
|
||||
"podman": "podman"}
|
||||
rows = sorted([r for r in d["sizes"] if r["bytes"] is not None], key=lambda r: r["bytes"])
|
||||
left, right, top, width = 170, 100, 96, 760
|
||||
plot_w = width - left - right
|
||||
lo, hi = math.log10(50 * 1024), math.log10(100 * (1 << 20))
|
||||
sx = lambda b: (math.log10(b) - lo) / (hi - lo) * plot_w
|
||||
ticks = [(100 * 1024, "100 KiB"), (1 << 20, "1 MiB"), (10 << 20, "10 MiB"), (100 << 20, "100 MiB")]
|
||||
y0 = top + 8
|
||||
body = []
|
||||
for b, lab in ticks:
|
||||
x = left + sx(b)
|
||||
body.append(f'<line class="grid" x1="{x:.1f}" y1="{y0 - 2}" x2="{x:.1f}" y2="{y0 + len(rows) * ROW}"/>')
|
||||
body.append(f'<text class="tick" x="{x:.1f}" y="{y0 + len(rows) * ROW + 16}" text-anchor="middle">{lab}</text>')
|
||||
y = y0
|
||||
for r in rows:
|
||||
cls = "acc" if r["id"] == "doos" else "oth"
|
||||
w = sx(r["bytes"])
|
||||
by = y + (ROW - BAR_H) / 2
|
||||
body.append(f'<text class="lab" x="{left - 10}" y="{by + BAR_H / 2 + 4:.1f}" text-anchor="end">{names.get(r["id"], r["id"])}</text>')
|
||||
body.append(f'<path class="{cls}" d="{bar_path(left, by, w, BAR_H)}"><title>{names.get(r["id"], r["id"])}: {fmt_bytes(r["bytes"])}</title></path>')
|
||||
body.append(f'<text class="val" x="{left + w + 8:.1f}" y="{by + BAR_H / 2 + 4:.1f}">{fmt_bytes(r["bytes"])}</text>')
|
||||
y += ROW
|
||||
body.append(f'<line class="axis" x1="{left}" y1="{y0 - 2}" x2="{left}" y2="{y}"/>')
|
||||
height = int(y + 62)
|
||||
out = svg_open(width, height, "Size of the main executable",
|
||||
"Horizontal bars on a logarithmic axis: " +
|
||||
", ".join(f"{names.get(r['id'], r['id'])} {fmt_bytes(r['bytes'])}" for r in rows) + ".")
|
||||
out += '<text class="t1" x="20" y="28">Size of the main executable (log scale, smaller is smaller)</text>\n'
|
||||
out += '<text class="t2" x="20" y="46">The docker CLI excludes dockerd and containerd.</text>\n'
|
||||
out += '<text class="t2" x="20" y="62">doos excludes the helpers it calls (slirp4netns, pasta, newuidmap, curl, tar).</text>\n'
|
||||
out += legend(20, 82, [("acc", "doos"), ("oth", "other tools")]) + "\n"
|
||||
out += "\n".join(body) + f'\n<text class="t2" x="{left}" y="{y + 38}">Axis is logarithmic: each gridline is 10x the previous one.</text>\n</svg>\n'
|
||||
return out
|
||||
|
||||
|
||||
# ---- chart 3: cpu limit ----------------------------------------------------------------------------------
|
||||
def cpu_chart(d):
|
||||
c = d["cpu"]
|
||||
if c["unlimited"] is None or c["half_cpu"] is None:
|
||||
return None
|
||||
rows = [("no limit", c["unlimited"], "oth"), ("--cpus 0.5", c["half_cpu"], "acc")]
|
||||
left, right, top, width = 170, 90, 78, 760
|
||||
plot_w = width - left - right
|
||||
axis_max = math.ceil(c["unlimited"] / 4.0) * 4
|
||||
scale = plot_w / axis_max
|
||||
y0 = top + 8
|
||||
body = []
|
||||
for t in range(0, axis_max + 1, 4):
|
||||
x = left + t * scale
|
||||
body.append(f'<line class="grid" x1="{x:.1f}" y1="{y0 - 2}" x2="{x:.1f}" y2="{y0 + len(rows) * ROW}"/>')
|
||||
body.append(f'<text class="tick" x="{x:.1f}" y="{y0 + len(rows) * ROW + 16}" text-anchor="middle">{t}</text>')
|
||||
y = y0
|
||||
for lab, v, cls in rows:
|
||||
w = v * scale
|
||||
by = y + (ROW - BAR_H) / 2
|
||||
body.append(f'<text class="lab" x="{left - 10}" y="{by + BAR_H / 2 + 4:.1f}" text-anchor="end">{lab}</text>')
|
||||
body.append(f'<path class="{cls}" d="{bar_path(left, by, w, BAR_H)}"><title>{lab}: {v:.1f} CPU-seconds</title></path>')
|
||||
body.append(f'<text class="val" x="{left + w + 8:.1f}" y="{by + BAR_H / 2 + 4:.1f}">{v:.1f} CPU-s</text>')
|
||||
y += ROW
|
||||
body.append(f'<line class="axis" x1="{left}" y1="{y0 - 2}" x2="{left}" y2="{y}"/>')
|
||||
ideal = 0.5 * c["wall_seconds"]
|
||||
height = int(y + 54)
|
||||
out = svg_open(width, height, "CPU time used by four busy loops",
|
||||
f"Two bars, CPU seconds consumed in {c['wall_seconds']} seconds of wall time. Without a limit {c['unlimited']:.1f}, "
|
||||
f"with --cpus 0.5 {c['half_cpu']:.1f}.")
|
||||
out += '<text class="t1" x="20" y="28">CPU time used by 4 busy loops in one container (CPU-seconds)</text>\n'
|
||||
out += (f'<text class="t2" x="20" y="46">{c["wall_seconds"]} s of wall time. Half a CPU allows about {ideal:.1f} CPU-seconds; '
|
||||
f'measured {c["half_cpu"]:.1f}.</text>\n')
|
||||
out += legend(20, 64, [("oth", "no limit"), ("acc", "--cpus 0.5")]) + "\n"
|
||||
out += "\n".join(body) + "\n</svg>\n"
|
||||
return out
|
||||
|
||||
|
||||
# ---- generated markdown ---------------------------------------------------------------------------------
|
||||
def startup_table(d):
|
||||
rows = [r for r in d["startup"] if r["ms"] is not None]
|
||||
lines = ["| Setup | Network | Time per run |", "|---|---|---|"]
|
||||
order = {"none": 0, "default": 1}
|
||||
for r in sorted(rows, key=lambda r: (order[r["group"]], r["ms"])):
|
||||
net = "none" if r["group"] == "none" else "default"
|
||||
lines.append(f"| {r['label']} | {net} | {fmt_ms(r['ms'])} |")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def size_table(d):
|
||||
names = {"bwrap": "bubblewrap", "crun": "crun", "doos": "doos", "docker": "docker CLI", "podman": "podman"}
|
||||
lines = ["| Executable | Size |", "|---|---|"]
|
||||
for r in sorted([r for r in d["sizes"] if r["bytes"] is not None], key=lambda r: r["bytes"]):
|
||||
lines.append(f"| {names.get(r['id'], r['id'])} | {fmt_bytes(r['bytes'])} ({r['bytes']:,} bytes) |")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def inline_values(d):
|
||||
ms = lambda ids: [r["ms"] for r in d["startup"] if r["id"].startswith(ids) and r["ms"] is not None]
|
||||
rng = lambda xs: f"{min(xs):.0f} to {max(xs):.0f} ms" if xs else "n/a"
|
||||
size = {r["id"]: r["bytes"] for r in d["sizes"] if r["bytes"] is not None}
|
||||
v = {
|
||||
"doos_range": rng(ms("doos")),
|
||||
"podman_range": rng(ms("podman")),
|
||||
"docker_range": rng(ms("docker")),
|
||||
"bwrap_range": rng(ms("bwrap")),
|
||||
"doos_size": fmt_bytes(size["doos"]) if "doos" in size else "n/a",
|
||||
"docker_size": fmt_bytes(size["docker"]) if "docker" in size else "n/a",
|
||||
"podman_size": fmt_bytes(size["podman"]) if "podman" in size else "n/a",
|
||||
"bench_date": d["meta"]["date"],
|
||||
"src_lines": f'{d["meta"].get("src_lines", 0):,}',
|
||||
"shared_libs": str(d["meta"].get("shared_libs", "n/a")),
|
||||
"runs": str(d["meta"]["runs"]),
|
||||
"versions": f'doos {d["meta"]["doos"]}, {d["meta"]["docker"].replace("Docker version ", "Docker ").split(",")[0]}, '
|
||||
f'{d["meta"]["podman"].replace("podman version ", "Podman ")}, {d["meta"]["bubblewrap"].replace("bubblewrap ", "bubblewrap ")}',
|
||||
"cpu_free": f'{d["cpu"]["unlimited"]:.1f}' if d["cpu"]["unlimited"] is not None else "n/a",
|
||||
"cpu_half": f'{d["cpu"]["half_cpu"]:.1f}' if d["cpu"]["half_cpu"] is not None else "n/a",
|
||||
}
|
||||
return v
|
||||
|
||||
|
||||
def patch_markdown(path, blocks, inline):
|
||||
s = path.read_text()
|
||||
orig = s
|
||||
for name, text in blocks.items():
|
||||
pat = re.compile(rf"(<!-- bench:{name}:begin -->\n).*?(\n<!-- bench:{name}:end -->)", re.S)
|
||||
s = pat.sub(lambda m: m.group(1) + text + m.group(2), s)
|
||||
s = re.sub(r"<!--bench:(\w+)-->.*?<!--/bench-->",
|
||||
lambda m: f"<!--bench:{m.group(1)}-->{inline.get(m.group(1), 'n/a')}<!--/bench-->", s)
|
||||
if s != orig:
|
||||
path.write_text(s)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if not DATA.exists():
|
||||
sys.exit("no bench.json: run `make bench` first (it needs the built binary)")
|
||||
d = json.loads(DATA.read_text())
|
||||
(HERE / "startup.svg").write_text(startup_chart(d))
|
||||
(HERE / "size.svg").write_text(size_chart(d))
|
||||
cpu = cpu_chart(d)
|
||||
if cpu:
|
||||
(HERE / "cpu.svg").write_text(cpu)
|
||||
blocks = {"startup": startup_table(d), "sizes": size_table(d)}
|
||||
inline = inline_values(d)
|
||||
changed = [p.name for p in (ROOT / "COMPARISON.md", ROOT / "docs" / "RESEARCH.md") if patch_markdown(p, blocks, inline)]
|
||||
print("charts: startup.svg size.svg" + (" cpu.svg" if cpu else "") + (f"; updated {', '.join(changed)}" if changed else "; docs already current"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,55 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 316" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto">
|
||||
<title id="t">Size of the main executable</title>
|
||||
<desc id="d">Horizontal bars on a logarithmic axis: bubblewrap 79 KiB, crun (OCI runtime) 472 KiB, doos 552 KiB, docker CLI 29.9 MiB, podman 41.2 MiB.</desc>
|
||||
<style>
|
||||
.bg { fill: var(--surface); }
|
||||
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
.t2 { fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
.lab { fill: var(--ink); font: 400 13px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
.val { fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
.tick { fill: var(--muted); font: 400 11px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
.grid { stroke: var(--grid); stroke-width: 1; }
|
||||
.axis { stroke: var(--axis); stroke-width: 1; }
|
||||
.acc { fill: var(--accent); }
|
||||
.oth { fill: var(--other); }
|
||||
:root { --surface:#fcfcfb; --ink:#0b0b0b; --ink2:#52514e; --muted:#898781;
|
||||
--grid:#e1e0d9; --axis:#c3c2b7; --accent:#2a78d6; --other:#898781; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --surface:#1a1a19; --ink:#f0efec; --ink2:#c3c2b7; --muted:#898781;
|
||||
--grid:#2c2c2a; --axis:#383835; --accent:#3987e5; --other:#898781; }
|
||||
}
|
||||
</style>
|
||||
<rect class="bg" x="0" y="0" width="760" height="316" rx="8"/>
|
||||
<text class="t1" x="20" y="28">Size of the main executable (log scale, smaller is smaller)</text>
|
||||
<text class="t2" x="20" y="46">The docker CLI excludes dockerd and containerd.</text>
|
||||
<text class="t2" x="20" y="62">doos excludes the helpers it calls (slirp4netns, pasta, newuidmap, curl, tar).</text>
|
||||
<rect class="acc" x="20" y="73" width="12" height="12" rx="2"/>
|
||||
<text class="t2" x="38" y="83">doos</text>
|
||||
<rect class="oth" x="88" y="73" width="12" height="12" rx="2"/>
|
||||
<text class="t2" x="106" y="83">other tools</text>
|
||||
<line class="grid" x1="214.5" y1="102" x2="214.5" y2="254"/>
|
||||
<text class="tick" x="214.5" y="270" text-anchor="middle">100 KiB</text>
|
||||
<line class="grid" x1="364.0" y1="102" x2="364.0" y2="254"/>
|
||||
<text class="tick" x="364.0" y="270" text-anchor="middle">1 MiB</text>
|
||||
<line class="grid" x1="512.0" y1="102" x2="512.0" y2="254"/>
|
||||
<text class="tick" x="512.0" y="270" text-anchor="middle">10 MiB</text>
|
||||
<line class="grid" x1="660.0" y1="102" x2="660.0" y2="254"/>
|
||||
<text class="tick" x="660.0" y="270" text-anchor="middle">100 MiB</text>
|
||||
<text class="lab" x="160" y="123.0" text-anchor="end">bubblewrap</text>
|
||||
<path class="oth" d="M170.0,110.0 h25.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-25.0 z"><title>bubblewrap: 79 KiB</title></path>
|
||||
<text class="val" x="207.0" y="123.0">79 KiB</text>
|
||||
<text class="lab" x="160" y="153.0" text-anchor="end">crun (OCI runtime)</text>
|
||||
<path class="oth" d="M170.0,140.0 h140.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-140.3 z"><title>crun (OCI runtime): 472 KiB</title></path>
|
||||
<text class="val" x="322.3" y="153.0">472 KiB</text>
|
||||
<text class="lab" x="160" y="183.0" text-anchor="end">doos</text>
|
||||
<path class="acc" d="M170.0,170.0 h150.4 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-150.4 z"><title>doos: 552 KiB</title></path>
|
||||
<text class="val" x="332.4" y="183.0">552 KiB</text>
|
||||
<text class="lab" x="160" y="213.0" text-anchor="end">docker CLI</text>
|
||||
<path class="oth" d="M170.0,200.0 h408.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-408.5 z"><title>docker CLI: 29.9 MiB</title></path>
|
||||
<text class="val" x="590.5" y="213.0">29.9 MiB</text>
|
||||
<text class="lab" x="160" y="243.0" text-anchor="end">podman</text>
|
||||
<path class="oth" d="M170.0,230.0 h429.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-429.0 z"><title>podman: 41.2 MiB</title></path>
|
||||
<text class="val" x="611.0" y="243.0">41.2 MiB</text>
|
||||
<line class="axis" x1="170" y1="102" x2="170" y2="254"/>
|
||||
<text class="t2" x="170" y="292">Axis is logarithmic: each gridline is 10x the previous one.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,90 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 502" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto">
|
||||
<title id="t">Time to start a trivial command</title>
|
||||
<desc id="d">Horizontal bars, milliseconds, lower is better. doos: 14 to 65 ms; Podman: 208 to 241 ms; Docker: 344 to 436 ms; bubblewrap: 7 to 8 ms. Exact values are in the table in COMPARISON.md.</desc>
|
||||
<style>
|
||||
.bg { fill: var(--surface); }
|
||||
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
.t2 { fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
.lab { fill: var(--ink); font: 400 13px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
.val { fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
.tick { fill: var(--muted); font: 400 11px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
.grid { stroke: var(--grid); stroke-width: 1; }
|
||||
.axis { stroke: var(--axis); stroke-width: 1; }
|
||||
.acc { fill: var(--accent); }
|
||||
.oth { fill: var(--other); }
|
||||
:root { --surface:#fcfcfb; --ink:#0b0b0b; --ink2:#52514e; --muted:#898781;
|
||||
--grid:#e1e0d9; --axis:#c3c2b7; --accent:#2a78d6; --other:#898781; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --surface:#1a1a19; --ink:#f0efec; --ink2:#c3c2b7; --muted:#898781;
|
||||
--grid:#2c2c2a; --axis:#383835; --accent:#3987e5; --other:#898781; }
|
||||
}
|
||||
</style>
|
||||
<rect class="bg" x="0" y="0" width="760" height="502" rx="8"/>
|
||||
<text class="t1" x="20" y="28">Time to start a trivial command (ms, lower is better)</text>
|
||||
<text class="t2" x="20" y="46">10 runs each, average, warm cache, 2026-10-23, kernel 7.0.0-38-generic, 12 cores. Docker is a rootful daemon.</text>
|
||||
<rect class="acc" x="20" y="55" width="12" height="12" rx="2"/>
|
||||
<text class="t2" x="38" y="65">doos</text>
|
||||
<rect class="oth" x="88" y="55" width="12" height="12" rx="2"/>
|
||||
<text class="t2" x="106" y="65">other tools</text>
|
||||
<text class="t1" x="22" y="78">No network</text>
|
||||
<line class="grid" x1="200.0" y1="90" x2="200.0" y2="242"/>
|
||||
<line class="grid" x1="294.0" y1="90" x2="294.0" y2="242"/>
|
||||
<line class="grid" x1="388.0" y1="90" x2="388.0" y2="242"/>
|
||||
<line class="grid" x1="482.0" y1="90" x2="482.0" y2="242"/>
|
||||
<line class="grid" x1="576.0" y1="90" x2="576.0" y2="242"/>
|
||||
<line class="grid" x1="670.0" y1="90" x2="670.0" y2="242"/>
|
||||
<text class="lab" x="190" y="111.0" text-anchor="end">bubblewrap (host /usr)</text>
|
||||
<path class="oth" d="M200.0,98.0 h3.9 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-3.9 z"><title>bubblewrap (host /usr): 8.4 ms</title></path>
|
||||
<text class="val" x="215.9" y="111.0">8.4 ms</text>
|
||||
<text class="lab" x="190" y="141.0" text-anchor="end">doos Alpine image</text>
|
||||
<path class="acc" d="M200.0,128.0 h9.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-9.0 z"><title>doos Alpine image: 13.8 ms</title></path>
|
||||
<text class="val" x="221.0" y="141.0">13.8 ms</text>
|
||||
<text class="lab" x="190" y="171.0" text-anchor="end">doos host mode</text>
|
||||
<path class="acc" d="M200.0,158.0 h13.6 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-13.6 z"><title>doos host mode: 18.7 ms</title></path>
|
||||
<text class="val" x="225.6" y="171.0">18.7 ms</text>
|
||||
<text class="lab" x="190" y="201.0" text-anchor="end">Podman Alpine</text>
|
||||
<path class="oth" d="M200.0,188.0 h191.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-191.3 z"><title>Podman Alpine: 208 ms</title></path>
|
||||
<text class="val" x="403.3" y="201.0">208 ms</text>
|
||||
<text class="lab" x="190" y="231.0" text-anchor="end">Docker Alpine (rootful)</text>
|
||||
<path class="oth" d="M200.0,218.0 h319.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-319.3 z"><title>Docker Alpine (rootful): 344 ms</title></path>
|
||||
<text class="val" x="531.3" y="231.0">344 ms</text>
|
||||
<line class="axis" x1="200" y1="90" x2="200" y2="242"/>
|
||||
<text class="tick" x="200.0" y="256" text-anchor="middle">0</text>
|
||||
<text class="tick" x="294.0" y="256" text-anchor="middle">100</text>
|
||||
<text class="tick" x="388.0" y="256" text-anchor="middle">200</text>
|
||||
<text class="tick" x="482.0" y="256" text-anchor="middle">300</text>
|
||||
<text class="tick" x="576.0" y="256" text-anchor="middle">400</text>
|
||||
<text class="tick" x="670.0" y="256" text-anchor="middle">500</text>
|
||||
<text class="t1" x="22" y="280">Default network</text>
|
||||
<line class="grid" x1="200.0" y1="292" x2="200.0" y2="474"/>
|
||||
<line class="grid" x1="294.0" y1="292" x2="294.0" y2="474"/>
|
||||
<line class="grid" x1="388.0" y1="292" x2="388.0" y2="474"/>
|
||||
<line class="grid" x1="482.0" y1="292" x2="482.0" y2="474"/>
|
||||
<line class="grid" x1="576.0" y1="292" x2="576.0" y2="474"/>
|
||||
<line class="grid" x1="670.0" y1="292" x2="670.0" y2="474"/>
|
||||
<text class="lab" x="190" y="313.0" text-anchor="end">bubblewrap (host /usr)</text>
|
||||
<path class="oth" d="M200.0,300.0 h3.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-3.0 z"><title>bubblewrap (host /usr): 7.4 ms</title></path>
|
||||
<text class="val" x="215.0" y="313.0">7.4 ms</text>
|
||||
<text class="lab" x="190" y="343.0" text-anchor="end">doos Alpine image</text>
|
||||
<path class="acc" d="M200.0,330.0 h45.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-45.0 z"><title>doos Alpine image: 52.1 ms</title></path>
|
||||
<text class="val" x="257.0" y="343.0">52.1 ms</text>
|
||||
<text class="lab" x="190" y="373.0" text-anchor="end">doos host mode</text>
|
||||
<path class="acc" d="M200.0,360.0 h49.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-49.5 z"><title>doos host mode: 56.9 ms</title></path>
|
||||
<text class="val" x="261.5" y="373.0">56.9 ms</text>
|
||||
<text class="lab" x="190" y="403.0" text-anchor="end">doos Alpine, pasta</text>
|
||||
<path class="acc" d="M200.0,390.0 h56.7 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-56.7 z"><title>doos Alpine, pasta: 64.6 ms</title></path>
|
||||
<text class="val" x="268.7" y="403.0">64.6 ms</text>
|
||||
<text class="lab" x="190" y="433.0" text-anchor="end">Podman Alpine</text>
|
||||
<path class="oth" d="M200.0,420.0 h222.7 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-222.7 z"><title>Podman Alpine: 241 ms</title></path>
|
||||
<text class="val" x="434.7" y="433.0">241 ms</text>
|
||||
<text class="lab" x="190" y="463.0" text-anchor="end">Docker Alpine (rootful)</text>
|
||||
<path class="oth" d="M200.0,450.0 h405.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-405.5 z"><title>Docker Alpine (rootful): 436 ms</title></path>
|
||||
<text class="val" x="617.5" y="463.0">436 ms</text>
|
||||
<line class="axis" x1="200" y1="292" x2="200" y2="474"/>
|
||||
<text class="tick" x="200.0" y="488" text-anchor="middle">0</text>
|
||||
<text class="tick" x="294.0" y="488" text-anchor="middle">100</text>
|
||||
<text class="tick" x="388.0" y="488" text-anchor="middle">200</text>
|
||||
<text class="tick" x="482.0" y="488" text-anchor="middle">300</text>
|
||||
<text class="tick" x="576.0" y="488" text-anchor="middle">400</text>
|
||||
<text class="tick" x="670.0" y="488" text-anchor="middle">500</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,52 @@
|
||||
# doos: Security Review
|
||||
|
||||
Date: 2026-10-23 | Reviewer: tai | Scope: `src/*.cpp/*.hpp`, `CMakeLists.txt`, `Makefile`, `apparmor/doos`, `tests/run.sh`, `docs/*`, `SPEC.md`
|
||||
Build: C++17 CMake project ("doos" container/sandbox tool using user namespaces, cgroup v2, seccomp-bpf, pasta/slirp4netns networking, overlayfs).
|
||||
|
||||
## Summary
|
||||
Rootless container runtime with generally sound isolation primitives (user ns + idmap, pivot_root, cap-drop, seccomp denylist, no-new-privs, cgroup limits). Main risks: privileged helpers trusted via PATH (`pasta`, `slirp4netns`, `newuidmap/newgidmap`), TOCTOU-prone fs setup, permissive `--hardening relaxed` and `--net host` modes, and missing supply-chain/hardening controls (no SBOM/signing, no CI fuzz/scan, trivial AppArmor profile).
|
||||
|
||||
## Findings (severity-ordered)
|
||||
1. **[MEDIUM] PATH-based exec of network helpers**: `src/network.cpp` execs `pasta`/`slirp4netns` resolved via PATH; a poisoned PATH in attach path = code exec as the user. Mitigate with absolute paths + allowlist env scrub.
|
||||
2. **[MEDIUM] Symlink-following fs staging (TOCTOU)**: `src/sandbox.cpp:133-139` mirrors host `/etc` entries with `symlink_status`/`read_symlink` then recreates; bind source handling without `O_NOFOLLOW`/fd-relative ops invites race/escape. Use `openat2(RESOLVE_NO_SYMLINKS)` or pin fds.
|
||||
3. **[MEDIUM] `relaxed` mode silently drops most controls**: per `cli.cpp:121`, relaxed = no cap-drop, no seccomp, full `/etc`. Users likely won't grasp severity. Require explicit `--i-understand` flag + warning.
|
||||
4. **[MEDIUM] No integrity for images/layers**: `src/image.cpp` untars with numeric-owner handling but no digest/signature verification. Tampered tar = full container compromise. Add sha256 manifest check.
|
||||
5. **[LOW] Temp overlay upperdir via `mkdtemp` in shared TMPDIR**: `src/image.cpp:52` uses `temp_directory_path()` (often `/tmp`, sticky but world-visible); predictable staging + 0700 chmod after the fact. Prefer per-user state dir with `O_EXCL`.
|
||||
6. **[LOW] `sscanf` parsing of cgroup stat**: `src/container.cpp:231` unchecked return; malformed file → stale values. Check return value.
|
||||
7. **[LOW] AppArmor profile is a stub**: `apparmor/doos` (12 lines) grants far less than runtime needs / isn't enforced by code. Either complete or remove to avoid false assurance.
|
||||
8. **[INFO] No seccomp allowlist, denylist only**: `hardening.cpp` blocks keyring/bpf/perf/io_uring/mount/kexec/module-load; reasonable, but bypass surface remains (ptrace? userfaultfd covered). Document residual attack surface.
|
||||
9. **[INFO] No network egress policy**: pasta/slirp give full user-mode egress; no DNS/TLS filtering. Expected for dev tool; document.
|
||||
|
||||
### What is done well
|
||||
- User-namespace-first, no setuid binary; `newuidmap` delegation preferred.
|
||||
- `NO_NEW_PRIVS` + capability bounding set to Docker-default keep-list.
|
||||
- `/proc`/`/sys` remounts read-only, masked paths (`kcore`, `keys`), minimal `/dev`.
|
||||
- Cgroup v2 `main/` vs `shell/` split with freeze + rescue path is thoughtful anti-ransom design.
|
||||
- `pivot_root` (not just chroot), `umask`, `0700/0640` perms on state.
|
||||
|
||||
## Grades (0–10, 10=excellent)
|
||||
| Category | Score | Rationale |
|
||||
|---|---|---|
|
||||
| Isolation & sandboxing | 7.5 | userns+pivot+cap-drop+seccomp+nonewprivs solid; weakened by relaxed mode, helper PATH trust |
|
||||
| Filesystem security | 6.5 | Good perms/skeleton; symlink TOCTOU + /tmp staging + no dm-verity/readonly-root |
|
||||
| Privilege & identity mgmt | 7.0 | No setuid, subuid mapping correct; host-uid file visibility nuance documented but subtle |
|
||||
| Network security | 6.0 | User-mode net avoids root; no egress/filtering, PATH-exec helpers, `--net host` footgun |
|
||||
| Resource controls (DoS) | 8.0 | cgroup v2 mem/pids/cpu + freeze model; best-effort when undelegated (fail-open must warn) |
|
||||
| Input validation & parsing | 6.5 | CLI fixed-value flags good; tar/symlink handling + sscanf without strict checks |
|
||||
| Secrets & state handling | 7.0 | State under DOOS_HOME with 0700; env (HOME/PATH) trusted too broadly |
|
||||
| Supply chain & build | 5.0 | No SBOM, signing, pinning, CI SAST/fuzz; CMake clean but no `-D_FORTIFY`/`-fstack-protector` audit |
|
||||
| Observability & audit | 5.5 | `doos list` table ok; no security event log, no seccomp-deny telemetry |
|
||||
| Defaults & secure-by-default | 6.0 | Default mode good; relaxed/host modes one flag away with weak friction |
|
||||
| **Overall** | **6.5** | Solid rootless dev-sandbox; fix PATH-exec, TOCTOU, image integrity before untrusted workloads |
|
||||
|
||||
## Recommendations (prioritized)
|
||||
1. Absolute-path helpers + minimal sanitized env for all `execvp`.
|
||||
2. `openat2` / fd-pinned fs staging; fail closed on symlink races.
|
||||
3. Image digest verification (sha256 manifest) before untar.
|
||||
4. Gate `relaxed`/`--net host` behind explicit confirmation + log warning.
|
||||
5. Harden build flags (`-Wall -Wextra -Werror -D_FORTIFY_SOURCE=3 -fstack-protector-strong -Wl,-z,RelRO,Now`), add SBOM + CI (CodeQL/Semgrep/Trivy).
|
||||
6. Check `sscanf` returns; move temp staging under state dir.
|
||||
7. Finish or drop AppArmor profile; add seccomp-deny + OOM audit logging.
|
||||
|
||||
## Verdict
|
||||
Fit for trusted-dev use. **Not yet recommended** for running untrusted images/payloads until (1), (2), (3) are fixed.
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reproducible measurements behind COMPARISON.md and the charts. Writes JSON (default docs/charts/bench.json).
|
||||
# Tools that are not installed or not usable are skipped and recorded as null, never guessed.
|
||||
# Usage: scripts/bench.sh [output.json] Environment: BENCH_RUNS (default 10)
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
OUT=${1:-docs/charts/bench.json}
|
||||
RUNS=${BENCH_RUNS:-10}
|
||||
DOOS=$(readlink -f build/doos)
|
||||
[ -x "$DOOS" ] || { echo "bench: build first (make build)" >&2; exit 1; }
|
||||
export DOOS_HOME=$(mktemp -d)
|
||||
trap '"$DOOS" delete --yes ha >/dev/null 2>&1; rm -rf "$DOOS_HOME"' EXIT
|
||||
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
now() { date +%s%N; }
|
||||
# avg_ms LABEL CMD... -> prints average ms per run, or "null" when the command fails
|
||||
avg_ms() {
|
||||
timeout 180 "$@" >/dev/null 2>&1 || { echo null; return; } # warm-up, may pull an image
|
||||
local s e i; s=$(now)
|
||||
for i in $(seq "$RUNS"); do timeout 60 "$@" >/dev/null 2>&1 || { echo null; return; }; done
|
||||
e=$(now); awk -v s="$s" -v e="$e" -v n="$RUNS" 'BEGIN{printf "%.1f", (e-s)/1e6/n}'
|
||||
}
|
||||
note() { echo "bench: $*" >&2; }
|
||||
|
||||
# ---- setup doos containers (host mode needs nothing, image mode downloads Alpine once)
|
||||
"$DOOS" image add alpine >/dev/null 2>&1 || note "alpine image unavailable, image-mode rows will be null"
|
||||
"$DOOS" get_or_create --net none hn >/dev/null 2>&1
|
||||
"$DOOS" get_or_create hs >/dev/null 2>&1
|
||||
"$DOOS" get_or_create --image alpine --net none an >/dev/null 2>&1
|
||||
"$DOOS" get_or_create --image alpine as >/dev/null 2>&1
|
||||
"$DOOS" get_or_create --image alpine --net pasta ap >/dev/null 2>&1
|
||||
|
||||
BW_BASE="--unshare-user --unshare-pid --unshare-ipc --unshare-uts --die-with-parent --ro-bind /usr /usr --symlink usr/bin /bin --symlink usr/lib /lib --symlink usr/lib64 /lib64 --proc /proc --dev /dev"
|
||||
rows=()
|
||||
add() { rows+=("{\"id\":\"$1\",\"label\":\"$2\",\"group\":\"$3\",\"kind\":\"$4\",\"ms\":$5}"); }
|
||||
|
||||
echo "bench: startup, $RUNS runs each" >&2
|
||||
if have bwrap; then
|
||||
add bwrap_none "bubblewrap (host /usr)" none other "$(avg_ms bwrap --unshare-all $BW_BASE true)"
|
||||
add bwrap_net "bubblewrap (host /usr)" default other "$(avg_ms bwrap $BW_BASE true)"
|
||||
fi
|
||||
add doos_host_none "doos host mode" none doos "$(avg_ms "$DOOS" attach hn -- true)"
|
||||
add doos_alpine_none "doos Alpine image" none doos "$(avg_ms "$DOOS" attach an -- true)"
|
||||
add doos_host_slirp "doos host mode" default doos "$(avg_ms "$DOOS" attach hs -- true)"
|
||||
add doos_alpine_slirp "doos Alpine image" default doos "$(avg_ms "$DOOS" attach as -- true)"
|
||||
add doos_alpine_pasta "doos Alpine, pasta" default doos "$(avg_ms "$DOOS" attach ap -- true)"
|
||||
if have podman; then
|
||||
add podman_none "Podman Alpine" none other "$(avg_ms podman run --rm --network none alpine true)"
|
||||
add podman_net "Podman Alpine" default other "$(avg_ms podman run --rm alpine true)"
|
||||
fi
|
||||
if have docker; then
|
||||
add docker_none "Docker Alpine (rootful)" none other "$(avg_ms docker run --rm --network none alpine true)"
|
||||
add docker_net "Docker Alpine (rootful)" default other "$(avg_ms docker run --rm alpine true)"
|
||||
fi
|
||||
|
||||
# ---- binary sizes
|
||||
size_of() { local p; p=$(command -v "$1" 2>/dev/null) && stat -L -c %s "$p" || echo null; }
|
||||
sizes=()
|
||||
for t in bwrap crun doos docker podman; do
|
||||
if [ "$t" = doos ]; then b=$(stat -c %s "$DOOS"); else b=$(size_of "$t"); fi
|
||||
sizes+=("{\"id\":\"$t\",\"bytes\":$b}")
|
||||
done
|
||||
|
||||
# ---- cpu limit: 4 busy loops for 4 seconds, CPU seconds consumed (user+sys)
|
||||
cpu_run() {
|
||||
/usr/bin/time -f "%U %S" "$DOOS" attach "$1" -- sh -c 'for i in 1 2 3 4; do (timeout 4 sh -c "while :; do :; done" &); done; sleep 4.2' 2>&1 >/dev/null | tail -1 | awk '{printf "%.2f", $1+$2}'
|
||||
}
|
||||
echo "bench: cpu limit" >&2
|
||||
"$DOOS" get_or_create hc >/dev/null 2>&1; "$DOOS" get_or_create --cpus 0.5 hc5 >/dev/null 2>&1
|
||||
cpu_free=$(cpu_run hc); cpu_half=$(cpu_run hc5)
|
||||
"$DOOS" delete --yes hc >/dev/null 2>&1; "$DOOS" delete --yes hc5 >/dev/null 2>&1
|
||||
|
||||
ver() { "$@" 2>/dev/null | head -1 | tr -d '"\\' ; }
|
||||
json_join() { local IFS=,; echo "$*"; }
|
||||
cat > "$OUT" <<JSON
|
||||
{
|
||||
"meta": {
|
||||
"date": "$(date +%F)",
|
||||
"kernel": "$(uname -r)",
|
||||
"cpus": $(nproc),
|
||||
"runs": $RUNS,
|
||||
"doos": "$(cat VERSION)",
|
||||
"podman": "$(have podman && ver podman --version || echo none)",
|
||||
"docker": "$(have docker && ver docker --version || echo none)",
|
||||
"bubblewrap": "$(have bwrap && ver bwrap --version || echo none)",
|
||||
"src_lines": $(cat src/*.cpp src/*.hpp | wc -l),
|
||||
"shared_libs": $(ldd "$DOOS" | wc -l)
|
||||
},
|
||||
"startup": [$(json_join "${rows[@]}")],
|
||||
"sizes": [$(json_join "${sizes[@]}")],
|
||||
"cpu": {"wall_seconds": 4.2, "busy_loops": 4, "unlimited": ${cpu_free:-null}, "half_cpu": ${cpu_half:-null}}
|
||||
}
|
||||
JSON
|
||||
echo "bench: wrote $OUT" >&2
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Checks the documentation set: every markdown file links to every other one, and every relative link resolves.
|
||||
# Usage: scripts/check-docs.sh (exit 1 on any problem, lists them all)
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
DOCS=(README.md SPEC.md COMPARISON.md AGENTS.md CHANGELOG.md docs/ARCHITECTURE.md docs/RESEARCH.md)
|
||||
bad=0
|
||||
fail() { echo "docs: $*"; bad=$((bad+1)); }
|
||||
|
||||
# no markdown file may exist outside the checked set (so a new doc cannot dodge the rule)
|
||||
while IFS= read -r f; do
|
||||
f=${f#./}
|
||||
printf '%s\n' "${DOCS[@]}" | grep -qxF "$f" || fail "$f is not in the DOCS list of scripts/check-docs.sh"
|
||||
done < <(find . -name '*.md' -not -path './build/*' -not -path './.git/*' -not -path './prompt.md' -not -path './audit/*' -not -path './review.md' -not -path './valgrind/*')
|
||||
# prompt.md: the original task statement, not documentation.
|
||||
# audit/*, valgrind/*: separate, self-contained doc sets (one README per CVE check, one for the
|
||||
# valgrind setup); linked from the main docs but not forced into their full cross-link mesh.
|
||||
# review.md: a standalone external security review, not part of the maintained doc set.
|
||||
|
||||
for f in "${DOCS[@]}"; do
|
||||
[ -f "$f" ] || { fail "$f is missing"; continue; }
|
||||
dir=$(dirname "$f")
|
||||
# every other document must be linked
|
||||
for g in "${DOCS[@]}"; do
|
||||
[ "$f" = "$g" ] && continue
|
||||
rel=$(realpath --relative-to="$dir" "$g")
|
||||
grep -qE "\]\(${rel//./\\.}(#[^)]*)?\)" "$f" || fail "$f does not link to $g (expected ]($rel))"
|
||||
done
|
||||
# every relative link must point at something that exists
|
||||
while IFS= read -r target; do
|
||||
case "$target" in http://*|https://*|mailto:*|\#*) continue ;; esac
|
||||
path=${target%%#*}
|
||||
[ -z "$path" ] && continue
|
||||
[ -e "$dir/$path" ] || fail "$f links to missing $target"
|
||||
done < <(grep -oE '\]\([^)]+\)' "$f" | sed -E 's/^\]\(//; s/\)$//')
|
||||
done
|
||||
|
||||
[ "$bad" -eq 0 ] && echo "docs: ${#DOCS[@]} documents, all cross-linked, all links resolve" || { echo "docs: $bad problem(s)"; exit 1; }
|
||||
+3
-1
@@ -22,6 +22,8 @@ if grep -rl $'\xe2\x80\x94' --exclude-dir=build --exclude-dir=.git . | grep -q .
|
||||
echo "em dash found, remove it first" >&2; exit 1
|
||||
fi
|
||||
|
||||
python3 docs/charts/make_charts.py
|
||||
scripts/check-docs.sh
|
||||
echo "$new" > VERSION
|
||||
cmake -S . -B build >/dev/null
|
||||
cmake --build build
|
||||
@@ -30,6 +32,6 @@ tests/run.sh
|
||||
git add -A
|
||||
git commit -m "$subject
|
||||
|
||||
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||||
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
|
||||
git tag -a "v$new" -m "doos $new"
|
||||
echo "released v$new"
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs a fixed, named set of doos invocations under valgrind's memcheck, using
|
||||
# valgrind/doos.supp to silence the known, explained allocation sites (see that file's
|
||||
# comments), and fails only on a real, unsuppressed memory error or leak. Does not rebuild;
|
||||
# run `make build` (or just `make valgrind`, which depends on it) first.
|
||||
#
|
||||
# Usage: scripts/valgrind.sh [path/to/doos]
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
DOOS="${1:-build/doos}"
|
||||
SUPP="$(pwd)/valgrind/doos.supp"
|
||||
REPORTS="build/valgrind-reports"
|
||||
TIMEOUT=60
|
||||
|
||||
command -v valgrind >/dev/null || { echo "valgrind: not installed"; exit 1; }
|
||||
[ -x "$DOOS" ] || { echo "valgrind: no doos binary at '$DOOS' (run 'make build' first)"; exit 1; }
|
||||
[ -f "$SUPP" ] || { echo "valgrind: suppressions file missing: $SUPP"; exit 1; }
|
||||
|
||||
rm -rf "$REPORTS"
|
||||
mkdir -p "$REPORTS"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
fails=0
|
||||
checked=0
|
||||
|
||||
run() { # run NAME -- DOOS_ARGS...
|
||||
local name="$1"; shift
|
||||
shift # drop the --
|
||||
checked=$((checked + 1))
|
||||
local log="$REPORTS/$name.log"
|
||||
timeout "$TIMEOUT" valgrind \
|
||||
--leak-check=full --show-leak-kinds=all --track-origins=yes \
|
||||
--suppressions="$SUPP" --error-exitcode=99 \
|
||||
--log-file="$log" \
|
||||
"$DOOS" "$@" >/dev/null 2>&1
|
||||
local rc=$?
|
||||
if [ "$rc" = 99 ]; then
|
||||
echo "FAIL $name: valgrind reported an unsuppressed error or leak, see $log"
|
||||
fails=$((fails + 1))
|
||||
elif [ "$rc" = 124 ]; then
|
||||
echo "FAIL $name: timed out after ${TIMEOUT}s under valgrind, see $log"
|
||||
fails=$((fails + 1))
|
||||
else
|
||||
echo "ok $name (doos exit=$rc; valgrind found nothing to report), see $log"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fixed, named set: the four CLI commands SPEC.md documents, plus a second attach to exercise
|
||||
# the join-a-running-container path. No image download here (network + extraction add their
|
||||
# own long-running C library code, better covered once by a dedicated image-mode run; see
|
||||
# valgrind/README.md for how to add one).
|
||||
run list -- list
|
||||
run get_or_create -- get_or_create --net none vgcheck
|
||||
run attach_first -- attach --net none vgcheck -- true
|
||||
run attach_join -- attach --net none vgcheck -- true
|
||||
run image_list -- image list
|
||||
run delete -- delete --yes vgcheck
|
||||
|
||||
echo "valgrind: $checked checked, $fails failed, reports in $REPORTS/"
|
||||
[ "$fails" -eq 0 ]
|
||||
+108
-22
@@ -3,6 +3,7 @@
|
||||
#include <unistd.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <sstream>
|
||||
#include <thread>
|
||||
|
||||
#include "spec.hpp"
|
||||
@@ -12,55 +13,140 @@ namespace fs = std::filesystem;
|
||||
|
||||
namespace doos {
|
||||
|
||||
std::unique_ptr<Cgroup> Cgroup::create(const std::string& name, std::string& whyNot) {
|
||||
namespace {
|
||||
bool hasController(const fs::path& dir, const std::string& name) {
|
||||
try {
|
||||
std::string self = fsutil::readFile("/proc/self/cgroup");
|
||||
std::string marker = "user@" + std::to_string(::geteuid()) + ".service";
|
||||
auto at = self.find(marker);
|
||||
if (self.rfind("0::", 0) != 0 || at == std::string::npos) {
|
||||
whyNot = "no cgroup v2 delegated to the user manager (" + marker + " not found)";
|
||||
std::istringstream in(fsutil::readFile(dir / "cgroup.controllers"));
|
||||
for (std::string c; in >> c;) {
|
||||
if (c == name) return true;
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool tryWrite(const fs::path& file, const std::string& text) {
|
||||
try {
|
||||
fsutil::writeFile(file, text);
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
fs::path Cgroup::base(std::string& whyNot) {
|
||||
std::string self;
|
||||
try {
|
||||
self = fsutil::readFile("/proc/self/cgroup");
|
||||
} catch (const std::exception& e) {
|
||||
whyNot = e.what();
|
||||
return {};
|
||||
}
|
||||
std::string marker = "user@" + std::to_string(::geteuid()) + ".service";
|
||||
auto at = self.find(marker);
|
||||
if (self.rfind("0::", 0) != 0 || at == std::string::npos) {
|
||||
whyNot = "no cgroup v2 delegated to the user manager (" + marker + " not found)";
|
||||
return {};
|
||||
}
|
||||
return fs::path("/sys/fs/cgroup") / self.substr(3, at + marker.size() - 3).substr(1);
|
||||
}
|
||||
|
||||
std::unique_ptr<Cgroup> Cgroup::create(const std::string& name, const Limits& limits, std::string& whyNot,
|
||||
std::string& notEnforced) {
|
||||
try {
|
||||
fs::path b = base(whyNot);
|
||||
if (b.empty()) return nullptr;
|
||||
fs::path top = b / std::string(spec::kCgroupDir);
|
||||
std::error_code ec;
|
||||
fs::create_directory(top, ec);
|
||||
if (ec && ec != std::errc::file_exists) {
|
||||
whyNot = "cannot create " + top.string() + ": " + ec.message();
|
||||
return nullptr;
|
||||
}
|
||||
fs::path base = fs::path("/sys/fs/cgroup") / self.substr(3, at + marker.size() - 3).substr(1);
|
||||
fs::path dir = base / std::string(spec::kCgroupDir);
|
||||
std::error_code ec;
|
||||
// Controllers must be enabled for children of `doos/` for the limit files to exist in `doos/<name>`.
|
||||
std::string enable;
|
||||
for (const char* c : {"memory", "pids", "cpu"}) {
|
||||
if (hasController(b, c)) enable += std::string(" +") + c;
|
||||
}
|
||||
if (!enable.empty()) tryWrite(top / "cgroup.subtree_control", enable);
|
||||
|
||||
fs::path dir = top / name;
|
||||
fs::create_directory(dir, ec);
|
||||
if (ec && ec != std::errc::file_exists) {
|
||||
whyNot = "cannot create " + dir.string() + ": " + ec.message();
|
||||
return nullptr;
|
||||
}
|
||||
fs::path leaf = dir / name;
|
||||
fs::create_directory(leaf, ec);
|
||||
if (ec && ec != std::errc::file_exists) {
|
||||
whyNot = "cannot create " + leaf.string() + ": " + ec.message();
|
||||
for (const char* leaf : {"main", "shell"}) fs::create_directory(dir / leaf, ec);
|
||||
if (::access((dir / "main" / "cgroup.freeze").c_str(), W_OK) != 0) {
|
||||
whyNot = "cgroup.freeze not writable in " + dir.string();
|
||||
return nullptr;
|
||||
}
|
||||
if (::access((leaf / "cgroup.freeze").c_str(), W_OK) != 0) {
|
||||
whyNot = "cgroup.freeze not writable in " + leaf.string();
|
||||
fs::remove(leaf, ec);
|
||||
return nullptr;
|
||||
auto note = [&](const std::string& what) { notEnforced += (notEnforced.empty() ? "" : ", ") + what; };
|
||||
if (limits.memoryBytes) {
|
||||
if (!tryWrite(dir / "memory.max", std::to_string(limits.memoryBytes))) note("memory");
|
||||
else {
|
||||
tryWrite(dir / "memory.swap.max", "0"); // otherwise the cap can be dodged through swap
|
||||
tryWrite(dir / "memory.oom.group", "1"); // an OOM kill takes the whole container, not one process
|
||||
}
|
||||
}
|
||||
return std::unique_ptr<Cgroup>(new Cgroup(leaf));
|
||||
if (limits.pids && !tryWrite(dir / "pids.max", std::to_string(limits.pids))) note("pids");
|
||||
if (limits.milliCpus) {
|
||||
long quota = static_cast<long>(limits.milliCpus) * spec::kCpuPeriodUs / 1000;
|
||||
if (!tryWrite(dir / "cpu.max", std::to_string(quota) + " " + std::to_string(spec::kCpuPeriodUs))) note("cpus");
|
||||
}
|
||||
return std::unique_ptr<Cgroup>(new Cgroup(dir, true));
|
||||
} catch (const std::exception& e) {
|
||||
whyNot = e.what();
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
std::unique_ptr<Cgroup> Cgroup::open(const std::string& name) {
|
||||
std::string why;
|
||||
fs::path b = base(why);
|
||||
if (b.empty()) return nullptr;
|
||||
fs::path dir = b / std::string(spec::kCgroupDir) / name;
|
||||
std::error_code ec;
|
||||
if (!fs::is_directory(dir / "shell", ec)) return nullptr;
|
||||
return std::unique_ptr<Cgroup>(new Cgroup(dir, false));
|
||||
}
|
||||
|
||||
Cgroup::~Cgroup() {
|
||||
// Processes die with the container; give the kernel a moment to empty the group, then remove it.
|
||||
for (int i = 0; i < 20; ++i) {
|
||||
if (!owner_) return;
|
||||
// Everything dies with the container's init; kill any straggler, then remove the leaves and the group.
|
||||
tryWrite(dir_ / "cgroup.kill", "1");
|
||||
for (int i = 0; i < 40; ++i) {
|
||||
std::error_code ec;
|
||||
fs::remove(dir_ / "main", ec);
|
||||
fs::remove(dir_ / "shell", ec);
|
||||
if (fs::remove(dir_, ec)) return;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(25));
|
||||
}
|
||||
}
|
||||
|
||||
void Cgroup::add(pid_t pid) const { fsutil::writeFile(dir_ / "cgroup.procs", std::to_string(pid)); }
|
||||
void Cgroup::addMain(pid_t pid) const { fsutil::writeFile(dir_ / "main" / "cgroup.procs", std::to_string(pid)); }
|
||||
void Cgroup::addShell(pid_t pid) const { fsutil::writeFile(dir_ / "shell" / "cgroup.procs", std::to_string(pid)); }
|
||||
|
||||
void Cgroup::freeze(bool on) const {
|
||||
fsutil::writeFile(dir_ / "cgroup.freeze", on ? "1" : "0");
|
||||
fsutil::writeFile(dir_ / "main" / "cgroup.freeze", on ? "1" : "0");
|
||||
frozen_ = on;
|
||||
}
|
||||
|
||||
std::uint64_t Cgroup::counter(const char* file, const char* key) const {
|
||||
try {
|
||||
std::istringstream in(fsutil::readFile(dir_ / file));
|
||||
std::string k;
|
||||
std::uint64_t v;
|
||||
while (in >> k >> v) {
|
||||
if (k == key) return v;
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::uint64_t Cgroup::oomKills() const { return counter("memory.events", "oom_kill"); }
|
||||
std::uint64_t Cgroup::pidFailures() const { return counter("pids.events", "max"); }
|
||||
|
||||
} // namespace doos
|
||||
|
||||
+27
-6
@@ -1,29 +1,50 @@
|
||||
#pragma once
|
||||
#include <sys/types.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace doos {
|
||||
|
||||
// One cgroup v2 leaf per running container, under the cgroup delegated to the user's systemd manager.
|
||||
// Used to freeze a container that hit its size cap (freezing needs no controller). Best effort:
|
||||
// create() returns null when delegation is absent, and the caller must say so.
|
||||
struct Limits {
|
||||
std::uint64_t memoryBytes = 0; // 0 = not limited
|
||||
std::uint32_t pids = 0;
|
||||
std::uint32_t milliCpus = 0; // 0 = all cores
|
||||
};
|
||||
|
||||
// cgroup v2 subtree of one running container under the cgroup delegated to the user's systemd manager:
|
||||
//
|
||||
// doos/<name>/ limits live here (memory.max, pids.max, cpu.max), no processes
|
||||
// main/ the container's init and everything it starts; frozen when the size cap is hit
|
||||
// shell/ processes started by later `doos attach` calls; never frozen (rescue path), same limits
|
||||
//
|
||||
// Best effort: create() returns null when delegation is absent, and the caller must say why.
|
||||
class Cgroup {
|
||||
public:
|
||||
static std::unique_ptr<Cgroup> create(const std::string& name, std::string& whyNot);
|
||||
static std::unique_ptr<Cgroup> create(const std::string& name, const Limits& limits, std::string& whyNot,
|
||||
std::string& notEnforced);
|
||||
// Existing group of a running container, for joiners. Null when none.
|
||||
static std::unique_ptr<Cgroup> open(const std::string& name);
|
||||
~Cgroup();
|
||||
Cgroup(const Cgroup&) = delete;
|
||||
Cgroup& operator=(const Cgroup&) = delete;
|
||||
|
||||
void add(pid_t pid) const;
|
||||
void addMain(pid_t pid) const;
|
||||
void addShell(pid_t pid) const;
|
||||
void freeze(bool on) const;
|
||||
bool frozen() const { return frozen_; }
|
||||
// Number of OOM kills and of fork failures at the pids limit since creation.
|
||||
std::uint64_t oomKills() const;
|
||||
std::uint64_t pidFailures() const;
|
||||
|
||||
private:
|
||||
explicit Cgroup(std::filesystem::path dir) : dir_(std::move(dir)) {}
|
||||
Cgroup(std::filesystem::path dir, bool owner) : dir_(std::move(dir)), owner_(owner) {}
|
||||
static std::filesystem::path base(std::string& whyNot);
|
||||
std::uint64_t counter(const char* file, const char* key) const;
|
||||
std::filesystem::path dir_;
|
||||
bool owner_;
|
||||
mutable bool frozen_ = false;
|
||||
};
|
||||
|
||||
|
||||
+70
-7
@@ -15,11 +15,15 @@ namespace doos {
|
||||
|
||||
namespace {
|
||||
|
||||
std::uint32_t parseCount(const std::string& text);
|
||||
std::uint32_t parseCpus(const std::string& text);
|
||||
|
||||
// Leading --options, then the first positional, then everything else untouched.
|
||||
struct Parsed {
|
||||
Options opts;
|
||||
std::optional<std::string> sha256;
|
||||
bool yes = false;
|
||||
bool iUnderstand = false; // --i-understand, required for relaxed / --net host
|
||||
std::vector<std::string> positional; // up to maxPositional, the rest lands in tail
|
||||
std::vector<std::string> tail;
|
||||
};
|
||||
@@ -47,7 +51,16 @@ Parsed parse(const std::vector<std::string>& rest, Allowed allow, std::size_t wa
|
||||
else if (allow.container && is(a, "--image")) p.opts.image = value(i, "--image");
|
||||
else if (allow.container && is(a, "--size")) p.opts.size = parseSize(value(i, "--size"));
|
||||
else if (allow.container && is(a, "--ids")) p.opts.ids = IdMap::parse(value(i, "--ids"));
|
||||
else if (allow.container && is(a, "--memory")) p.opts.memory = parseSize(value(i, "--memory"));
|
||||
else if (allow.container && is(a, "--pids")) p.opts.pids = parseCount(value(i, "--pids"));
|
||||
else if (allow.container && is(a, "--cpus")) p.opts.milliCpus = parseCpus(value(i, "--cpus"));
|
||||
else if (allow.container && is(a, "--hardening")) {
|
||||
std::string v = value(i, "--hardening");
|
||||
if (v != "default" && v != "relaxed") throw std::runtime_error("--hardening takes default or relaxed");
|
||||
p.opts.relaxed = (v == "relaxed");
|
||||
}
|
||||
else if (allow.yes && a == "--yes") p.yes = true;
|
||||
else if (a == "--i-understand") p.iUnderstand = true;
|
||||
else if (allow.sha256 && is(a, "--sha256")) p.sha256 = value(i, "--sha256");
|
||||
else if (a.size() > 1 && a[0] == '-') throw std::runtime_error("unknown option " + a);
|
||||
else if (p.positional.size() < wantPositional) p.positional.push_back(a);
|
||||
@@ -59,6 +72,41 @@ Parsed parse(const std::vector<std::string>& rest, Allowed allow, std::size_t wa
|
||||
return p;
|
||||
}
|
||||
|
||||
void hardenNotice(const Container& c, const Parsed& p) {
|
||||
bool relaxed = p.opts.relaxed.value_or(c.relaxed());
|
||||
bool hostNet = (p.opts.net.has_value() ? *p.opts.net : c.net()) == NetMode::Host;
|
||||
if ((relaxed || hostNet) && !p.iUnderstand) {
|
||||
throw std::runtime_error("--hardening relaxed and --net host drop capability drop, seccomp filtering and/or "
|
||||
"network isolation; pass --i-understand to confirm you accept the reduced sandboxing");
|
||||
}
|
||||
if (relaxed || hostNet) {
|
||||
std::fprintf(stderr, "doos: warning: reduced sandboxing (%s%s%s)\n",
|
||||
relaxed ? "no capability drop, no seccomp, full /etc" : "",
|
||||
(relaxed && hostNet) ? "; " : "",
|
||||
hostNet ? "shares the host network namespace" : "");
|
||||
}
|
||||
}
|
||||
|
||||
std::uint32_t parseCount(const std::string& text) {
|
||||
if (text.empty() || text.size() > 9 || text.find_first_not_of("0123456789") != std::string::npos) {
|
||||
throw std::runtime_error("invalid number '" + text + "'");
|
||||
}
|
||||
return static_cast<std::uint32_t>(std::stoul(text));
|
||||
}
|
||||
|
||||
// "2", "0.5", "1.25" cpus -> thousandths; "0" means all cores.
|
||||
std::uint32_t parseCpus(const std::string& text) {
|
||||
std::size_t dot = text.find('.');
|
||||
std::string whole = text.substr(0, dot), frac = dot == std::string::npos ? "" : text.substr(dot + 1);
|
||||
if (whole.empty() || whole.size() > 5 || frac.size() > 3 ||
|
||||
whole.find_first_not_of("0123456789") != std::string::npos ||
|
||||
frac.find_first_not_of("0123456789") != std::string::npos) {
|
||||
throw std::runtime_error("invalid cpus '" + text + "' (examples: 2, 0.5, 1.25, 0 for all cores)");
|
||||
}
|
||||
while (frac.size() < 3) frac += '0';
|
||||
return static_cast<std::uint32_t>(std::stoul(whole)) * 1000 + static_cast<std::uint32_t>(std::stoul(frac));
|
||||
}
|
||||
|
||||
std::string age(long created) {
|
||||
long d = std::time(nullptr) - created;
|
||||
if (d < 120) return std::to_string(d) + "s";
|
||||
@@ -81,14 +129,21 @@ void Cli::usage(std::FILE* out) {
|
||||
" doos image list\n"
|
||||
" doos image add [--sha256 HEX] SOURCE [NAME]\n"
|
||||
" doos image delete [--yes] NAME\n"
|
||||
" doos apparmor print the AppArmor profile this binary needs on Ubuntu 24.04+\n"
|
||||
"\n"
|
||||
"options (fixed at creation, except --size):\n"
|
||||
" --image NAME start from an image instead of the host system (fetched on demand)\n"
|
||||
" --size SIZE writable-layer cap, 64m..1t, default 4g (e.g. 512m, 8g)\n"
|
||||
" --net slirp|host|none network mode, default slirp\n"
|
||||
" --ids single|subuid id mapping, default: subuid when available\n"
|
||||
"options:\n"
|
||||
" --image NAME start from an image instead of the host system (fetched on demand) [fixed]\n"
|
||||
" --net slirp|pasta|host|none network, default slirp (pasta: host-like, private sockets) [fixed]\n"
|
||||
" --ids single|subuid id mapping, default: subuid when available [fixed]\n"
|
||||
" --hardening default|relaxed relaxed = no capability drop, no seccomp, full /etc [fixed]\n"
|
||||
" --i-understand confirm you accept reduced sandboxing when using relaxed or --net host\n"
|
||||
" --size SIZE writable-layer cap, 64m..1t, default 4g\n"
|
||||
" --memory SIZE memory cap, 64m..1t, default 8g\n"
|
||||
" --pids N process cap, 16..4194304, default 4096\n"
|
||||
" --cpus N cpu cap, 0.1..1024, default 0 (all cores)\n"
|
||||
" size, memory, pids and cpus can be changed on any later call; they apply at the next start.\n"
|
||||
"\n"
|
||||
"SOURCE for image add: alpine, ubuntu, ubuntu-24.04, an https URL (needs --sha256), or a tar file.\n"
|
||||
"SOURCE for image add: alpine, ubuntu, ubuntu-24.04, debian, debian-bookworm, an https URL (needs --sha256), or a tar file.\n"
|
||||
"attach creates NAME when missing. delete only reports unless --yes is given.\n"
|
||||
"State lives in $DOOS_HOME or ~/.local/share/doos.\n",
|
||||
out);
|
||||
@@ -108,6 +163,7 @@ int Cli::run() {
|
||||
if (cmd == "attach") return cmdAttach(rest);
|
||||
if (cmd == "delete") return cmdDelete(rest);
|
||||
if (cmd == "image") return cmdImage(rest);
|
||||
if (cmd == "apparmor") return cmdApparmor();
|
||||
std::fprintf(stderr, "doos: unknown command '%s'\n\n", args_[0].c_str());
|
||||
usage(stderr);
|
||||
return 2;
|
||||
@@ -131,6 +187,7 @@ int Cli::cmdGetOrCreate(std::vector<std::string> rest) {
|
||||
if (!p.tail.empty()) throw std::runtime_error("get_or_create takes only a name");
|
||||
Store store = Store::fromEnvironment();
|
||||
auto [c, created] = store.getOrCreate(p.positional[0], p.opts);
|
||||
hardenNotice(c, p);
|
||||
std::printf("%s %s\n", created ? "created" : "exists", c.name().c_str());
|
||||
return 0;
|
||||
}
|
||||
@@ -140,6 +197,7 @@ int Cli::cmdAttach(std::vector<std::string> rest) {
|
||||
Store store = Store::fromEnvironment();
|
||||
auto [c, created] = store.getOrCreate(p.positional[0], p.opts);
|
||||
(void)created;
|
||||
hardenNotice(c, p);
|
||||
return Sandbox(std::move(c), std::move(p.tail)).run();
|
||||
}
|
||||
|
||||
@@ -151,6 +209,11 @@ int Cli::cmdDelete(std::vector<std::string> rest) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int Cli::cmdApparmor() {
|
||||
std::fputs(Sandbox::apparmorProfile().c_str(), stdout);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int Cli::cmdImage(std::vector<std::string> rest) {
|
||||
if (rest.empty()) throw std::runtime_error("image needs a subcommand: list, add, delete");
|
||||
std::string sub = rest[0];
|
||||
@@ -194,7 +257,7 @@ int Cli::cmdImageAdd(std::vector<std::string> rest) {
|
||||
src.sha256 = *p.sha256;
|
||||
} else {
|
||||
if (!std::filesystem::is_regular_file(source)) {
|
||||
throw std::runtime_error("'" + source + "' is neither a catalog image (alpine, ubuntu, ubuntu-VERSION), an https URL, nor a file");
|
||||
throw std::runtime_error("'" + source + "' is neither a catalog image (alpine, ubuntu, ubuntu-VERSION, debian, debian-RELEASE), an https URL, nor a file");
|
||||
}
|
||||
src.location = std::filesystem::absolute(source).string();
|
||||
src.isFile = true;
|
||||
|
||||
@@ -15,6 +15,7 @@ private:
|
||||
int cmdGetOrCreate(std::vector<std::string> rest);
|
||||
int cmdAttach(std::vector<std::string> rest);
|
||||
int cmdDelete(std::vector<std::string> rest);
|
||||
int cmdApparmor();
|
||||
int cmdImage(std::vector<std::string> rest);
|
||||
int cmdImageList();
|
||||
int cmdImageAdd(std::vector<std::string> rest);
|
||||
|
||||
+117
-10
@@ -1,6 +1,8 @@
|
||||
#include "container.hpp"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
@@ -82,6 +84,23 @@ void Container::seed(const std::string& marker, const std::string& label,
|
||||
fsutil::writeFile(m, "ok\n");
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Names that may hold credentials are never copied, even inside allowed directories.
|
||||
bool etcDenied(const fs::path& p) {
|
||||
std::string n = p.filename().string();
|
||||
if (n.size() > 4 && n.compare(n.size() - 4, 4, ".key") == 0) return true;
|
||||
for (auto d : spec::kEtcDenyNames) {
|
||||
std::string pat(d);
|
||||
if (!pat.empty() && pat.back() == '*') {
|
||||
if (n.rfind(pat.substr(0, pat.size() - 1), 0) == 0) return true;
|
||||
} else if (n == pat) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void Container::prepareLayout() const {
|
||||
fsutil::mkdirs(rootfs());
|
||||
if (imageMode()) {
|
||||
@@ -154,8 +173,17 @@ void Container::prepareLayout() const {
|
||||
for (auto d : spec::kCopiedDirs) {
|
||||
std::error_code ec;
|
||||
if (!fs::is_directory(std::string(d), ec)) continue;
|
||||
seed("copy-" + std::string(slug(d)), "copy of " + std::string(d),
|
||||
[&] { return fsutil::copyTree(std::string(d), privateDir(std::string(d))); });
|
||||
std::string key = slug(d);
|
||||
std::replace(key.begin(), key.end(), '/', '_');
|
||||
if (relaxed_) {
|
||||
seed("copy-" + key, "copy of " + std::string(d) + " (relaxed)",
|
||||
[&] { return fsutil::copyTree(std::string(d), privateDir(std::string(d))); });
|
||||
} else {
|
||||
seed("copy-" + key, "allowlisted " + std::string(d), [&] {
|
||||
std::vector<std::string> names(spec::kEtcAllow.begin(), spec::kEtcAllow.end());
|
||||
return fsutil::copyEntries(std::string(d), privateDir(std::string(d)), names, etcDenied);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,15 +205,42 @@ std::uint64_t Container::usedBytes(std::size_t& skipped) const {
|
||||
std::error_code ec;
|
||||
if (fs::exists(dir_ / "layers", ec)) findUppers(dir_ / "layers", 0, dirs);
|
||||
if (fs::exists(dir_ / "private", ec)) dirs.push_back(dir_ / "private");
|
||||
std::uint64_t total = 0;
|
||||
for (const auto& d : dirs) total += fsutil::diskUsage(d, skipped);
|
||||
return total;
|
||||
auto scan = [&](std::size_t& sk) {
|
||||
std::uint64_t total = 0;
|
||||
for (const auto& d : dirs) total += fsutil::diskUsage(d, sk);
|
||||
return total;
|
||||
};
|
||||
if (ids_ == IdMode::Single) return scan(skipped);
|
||||
// Files of other container ids (host uids 100000+) and their mode 700 directories are invisible to the host
|
||||
// user; measuring as root of the id map sees them.
|
||||
int p[2];
|
||||
if (::pipe2(p, O_CLOEXEC) != 0) throw SysError("pipe");
|
||||
Fd rd(p[0]), wr(p[1]);
|
||||
int rc = UserNsJob::run(ids_, [&] {
|
||||
std::size_t sk = 0;
|
||||
std::uint64_t t = scan(sk);
|
||||
::dprintf(wr.get(), "%llu %zu\n", static_cast<unsigned long long>(t), sk);
|
||||
return 0;
|
||||
});
|
||||
wr.reset();
|
||||
if (rc != 0) throw std::runtime_error("size scan failed");
|
||||
char buf[64] = {0};
|
||||
ssize_t rd_n = ::read(rd.get(), buf, sizeof buf - 1);
|
||||
if (rd_n <= 0) throw std::runtime_error("size scan returned nothing");
|
||||
buf[rd_n] = '\0';
|
||||
unsigned long long t = 0;
|
||||
std::size_t sk = 0;
|
||||
if (std::sscanf(buf, "%llu %zu", &t, &sk) != 2) throw std::runtime_error("size scan returned malformed data");
|
||||
skipped += sk;
|
||||
return t;
|
||||
}
|
||||
|
||||
void Container::writeMeta() const {
|
||||
fsutil::writeFile(metaFile(), "net=" + Network::name(net_) + "\ncreated=" + std::to_string(created_) +
|
||||
"\nversion=" DOOS_VERSION "\nimage=" + image_ + "\nids=" + IdMap::name(ids_) +
|
||||
"\nsize=" + std::to_string(size_) + "\n");
|
||||
"\nsize=" + std::to_string(size_) + "\nmemory=" + std::to_string(memory_) +
|
||||
"\npids=" + std::to_string(pids_) + "\ncpus=" + std::to_string(milliCpus_) +
|
||||
"\nhardening=" + (relaxed_ ? "relaxed" : "default") + "\n");
|
||||
}
|
||||
|
||||
void Container::setSizeCap(std::uint64_t bytes) {
|
||||
@@ -197,11 +252,41 @@ void Container::setSizeCap(std::uint64_t bytes) {
|
||||
writeMeta();
|
||||
}
|
||||
|
||||
void Container::create(const NetMode& net, const std::string& image, IdMode ids, std::uint64_t size) {
|
||||
void Container::setLimits(std::optional<std::uint64_t> memory, std::optional<std::uint32_t> pids,
|
||||
std::optional<std::uint32_t> milliCpus) {
|
||||
if (memory) {
|
||||
if (*memory < spec::kMinMemoryBytes || *memory > spec::kMaxMemoryBytes) {
|
||||
throw std::runtime_error("memory must be between " + formatSize(spec::kMinMemoryBytes) + " and " +
|
||||
formatSize(spec::kMaxMemoryBytes));
|
||||
}
|
||||
memory_ = *memory;
|
||||
}
|
||||
if (pids) {
|
||||
if (*pids < spec::kMinPids || *pids > spec::kMaxPids) {
|
||||
throw std::runtime_error("pids must be between " + std::to_string(spec::kMinPids) + " and " +
|
||||
std::to_string(spec::kMaxPids));
|
||||
}
|
||||
pids_ = *pids;
|
||||
}
|
||||
if (milliCpus) {
|
||||
if (*milliCpus != 0 && (*milliCpus < spec::kMinMilliCpus || *milliCpus > spec::kMaxMilliCpus)) {
|
||||
throw std::runtime_error("cpus must be between 0.1 and 1024 (0 = all cores)");
|
||||
}
|
||||
milliCpus_ = *milliCpus;
|
||||
}
|
||||
writeMeta();
|
||||
}
|
||||
|
||||
void Container::create(const NetMode& net, const std::string& image, IdMode ids, const Options& o) {
|
||||
net_ = net;
|
||||
image_ = image;
|
||||
ids_ = ids;
|
||||
size_ = size;
|
||||
size_ = o.size.value_or(spec::kDefaultSizeBytes);
|
||||
relaxed_ = o.relaxed.value_or(false);
|
||||
memory_ = spec::kDefaultMemoryBytes;
|
||||
pids_ = spec::kDefaultPids;
|
||||
milliCpus_ = 0;
|
||||
if (o.memory || o.pids || o.milliCpus) setLimits(o.memory, o.pids, o.milliCpus);
|
||||
created_ = std::time(nullptr);
|
||||
fsutil::mkdirs(dir_);
|
||||
prepareLayout();
|
||||
@@ -220,6 +305,10 @@ void Container::readMeta() {
|
||||
image_ = kv.count("image") ? kv["image"] : "";
|
||||
ids_ = IdMap::parse(kv.count("ids") ? kv["ids"] : "single");
|
||||
size_ = kv.count("size") ? std::stoull(kv["size"]) : spec::kDefaultSizeBytes;
|
||||
memory_ = kv.count("memory") ? std::stoull(kv["memory"]) : spec::kDefaultMemoryBytes;
|
||||
pids_ = kv.count("pids") ? static_cast<std::uint32_t>(std::stoul(kv["pids"])) : spec::kDefaultPids;
|
||||
milliCpus_ = kv.count("cpus") ? static_cast<std::uint32_t>(std::stoul(kv["cpus"])) : 0;
|
||||
relaxed_ = kv.count("hardening") && kv["hardening"] == "relaxed";
|
||||
}
|
||||
|
||||
std::optional<pid_t> Container::runningPid() const {
|
||||
@@ -307,7 +396,9 @@ std::pair<Container, bool> Store::getOrCreate(const std::string& name, const Opt
|
||||
if (opts.net) fixed("net", Network::name(c.net()), Network::name(*opts.net));
|
||||
if (image) fixed("image", c.image().empty() ? "none" : c.image(), *image);
|
||||
if (opts.ids) fixed("ids", IdMap::name(c.ids()), IdMap::name(*opts.ids));
|
||||
if (opts.relaxed) fixed("hardening", c.relaxed() ? "relaxed" : "default", *opts.relaxed ? "relaxed" : "default");
|
||||
if (opts.size && *opts.size != c.sizeCap()) c.setSizeCap(*opts.size);
|
||||
if (opts.memory || opts.pids || opts.milliCpus) c.setLimits(opts.memory, opts.pids, opts.milliCpus);
|
||||
};
|
||||
|
||||
if (fs::exists(dir / "meta", ec)) {
|
||||
@@ -329,7 +420,7 @@ std::pair<Container, bool> Store::getOrCreate(const std::string& name, const Opt
|
||||
ImageStore store = images();
|
||||
if (!store.has(*image)) {
|
||||
if (!Catalog::knows(*image)) {
|
||||
throw std::runtime_error("unknown image '" + *image + "'. Add it: doos image add SOURCE [NAME] (catalog: alpine, ubuntu)");
|
||||
throw std::runtime_error("unknown image '" + *image + "'. Add it: doos image add SOURCE [NAME] (catalog: alpine, ubuntu, debian)");
|
||||
}
|
||||
std::fprintf(stderr, "doos: image '%s' not present, fetching it\n", image->c_str());
|
||||
store.add(Catalog::resolve(*image), ids);
|
||||
@@ -353,7 +444,23 @@ std::pair<Container, bool> Store::getOrCreate(const std::string& name, const Opt
|
||||
verifyExisting();
|
||||
return {c, false};
|
||||
}
|
||||
c.create(opts.net.value_or(NetMode::Slirp), image.value_or(""), ids, size);
|
||||
Options resolved = opts;
|
||||
resolved.size = size;
|
||||
try {
|
||||
c.create(opts.net.value_or(NetMode::Slirp), image.value_or(""), ids, resolved);
|
||||
} catch (...) {
|
||||
// a container that failed halfway must not stay behind as an unreadable directory
|
||||
try {
|
||||
if (ids == IdMode::Subuid) UserNsJob::run(ids, [&] { fsutil::removeTree(dir); return 0; });
|
||||
else fsutil::removeTree(dir);
|
||||
} catch (...) {
|
||||
}
|
||||
throw;
|
||||
}
|
||||
if (c.net() == NetMode::Host) {
|
||||
std::fprintf(stderr, "doos: note: --net host shares the host's abstract unix sockets and loopback; "
|
||||
"--net pasta gives the same reachability in a private namespace\n");
|
||||
}
|
||||
return {c, true};
|
||||
}
|
||||
|
||||
|
||||
+17
-1
@@ -21,8 +21,13 @@ struct Options {
|
||||
std::optional<std::string> image;
|
||||
std::optional<IdMode> ids;
|
||||
std::optional<std::uint64_t> size;
|
||||
std::optional<std::uint64_t> memory;
|
||||
std::optional<std::uint32_t> pids;
|
||||
std::optional<std::uint32_t> milliCpus; // 0 = all cores
|
||||
std::optional<bool> relaxed; // --hardening relaxed
|
||||
};
|
||||
|
||||
|
||||
class Container {
|
||||
public:
|
||||
Container(std::filesystem::path dir, std::string name) : dir_(std::move(dir)), name_(std::move(name)) {}
|
||||
@@ -47,8 +52,14 @@ public:
|
||||
IdMode ids() const { return ids_; }
|
||||
std::uint64_t sizeCap() const { return size_; }
|
||||
void setSizeCap(std::uint64_t bytes);
|
||||
std::uint64_t memoryCap() const { return memory_; }
|
||||
std::uint32_t pidsCap() const { return pids_; }
|
||||
std::uint32_t milliCpus() const { return milliCpus_; }
|
||||
bool relaxed() const { return relaxed_; }
|
||||
// Limits take effect the next time the container starts.
|
||||
void setLimits(std::optional<std::uint64_t> memory, std::optional<std::uint32_t> pids, std::optional<std::uint32_t> milliCpus);
|
||||
|
||||
void create(const NetMode& net, const std::string& image, IdMode ids, std::uint64_t size);
|
||||
void create(const NetMode& net, const std::string& image, IdMode ids, const Options& resolved);
|
||||
void readMeta(); // meta only, no side effects
|
||||
void load(); // readMeta + heal the layout
|
||||
|
||||
@@ -60,6 +71,7 @@ public:
|
||||
// Mountpoints, layer dirs, and one-time seeding (skeleton, /etc copy). Idempotent.
|
||||
void prepareLayout() const;
|
||||
// Bytes the container wrote (writable layers and private dirs), as the host user can see them.
|
||||
// Measured from inside the id map, so directories of other container ids are counted too.
|
||||
std::uint64_t usedBytes(std::size_t& skipped) const;
|
||||
|
||||
private:
|
||||
@@ -73,6 +85,10 @@ private:
|
||||
std::string image_;
|
||||
IdMode ids_ = IdMode::Single;
|
||||
std::uint64_t size_ = 0;
|
||||
std::uint64_t memory_ = 0;
|
||||
std::uint32_t pids_ = 0;
|
||||
std::uint32_t milliCpus_ = 0;
|
||||
bool relaxed_ = false;
|
||||
};
|
||||
|
||||
class Store {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
#include "hardening.hpp"
|
||||
|
||||
#include <linux/audit.h>
|
||||
#include <linux/filter.h>
|
||||
#include <linux/seccomp.h>
|
||||
#include <sched.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <cstddef>
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
|
||||
#include "spec.hpp"
|
||||
#include "util.hpp"
|
||||
|
||||
#ifndef SECCOMP_RET_KILL_PROCESS
|
||||
#define SECCOMP_RET_KILL_PROCESS 0x80000000U
|
||||
#endif
|
||||
#ifndef CAP_LAST_CAP_FALLBACK
|
||||
#define CAP_LAST_CAP_FALLBACK 40
|
||||
#endif
|
||||
|
||||
namespace doos {
|
||||
|
||||
namespace {
|
||||
|
||||
#if defined(__x86_64__)
|
||||
constexpr unsigned kArch = AUDIT_ARCH_X86_64;
|
||||
constexpr bool kHasX32 = true;
|
||||
#elif defined(__aarch64__)
|
||||
constexpr unsigned kArch = AUDIT_ARCH_AARCH64;
|
||||
constexpr bool kHasX32 = false;
|
||||
#else
|
||||
constexpr unsigned kArch = 0;
|
||||
constexpr bool kHasX32 = false;
|
||||
#endif
|
||||
|
||||
// Syscalls answered with EPERM. Entries the architecture does not have are skipped at compile time.
|
||||
std::vector<unsigned> deniedSyscalls() {
|
||||
std::vector<unsigned> v;
|
||||
#define DENY(name) v.push_back(static_cast<unsigned>(name))
|
||||
#ifdef SYS_acct
|
||||
DENY(SYS_acct);
|
||||
#endif
|
||||
#ifdef SYS_add_key
|
||||
DENY(SYS_add_key);
|
||||
#endif
|
||||
#ifdef SYS_bpf
|
||||
DENY(SYS_bpf);
|
||||
#endif
|
||||
#ifdef SYS_clock_adjtime
|
||||
DENY(SYS_clock_adjtime);
|
||||
#endif
|
||||
#ifdef SYS_clock_settime
|
||||
DENY(SYS_clock_settime);
|
||||
#endif
|
||||
#ifdef SYS_create_module
|
||||
DENY(SYS_create_module);
|
||||
#endif
|
||||
#ifdef SYS_delete_module
|
||||
DENY(SYS_delete_module);
|
||||
#endif
|
||||
#ifdef SYS_finit_module
|
||||
DENY(SYS_finit_module);
|
||||
#endif
|
||||
#ifdef SYS_init_module
|
||||
DENY(SYS_init_module);
|
||||
#endif
|
||||
#ifdef SYS_get_kernel_syms
|
||||
DENY(SYS_get_kernel_syms);
|
||||
#endif
|
||||
#ifdef SYS_keyctl
|
||||
DENY(SYS_keyctl);
|
||||
#endif
|
||||
#ifdef SYS_kexec_file_load
|
||||
DENY(SYS_kexec_file_load);
|
||||
#endif
|
||||
#ifdef SYS_kexec_load
|
||||
DENY(SYS_kexec_load);
|
||||
#endif
|
||||
#ifdef SYS_lookup_dcookie
|
||||
DENY(SYS_lookup_dcookie);
|
||||
#endif
|
||||
#ifdef SYS_nfsservctl
|
||||
DENY(SYS_nfsservctl);
|
||||
#endif
|
||||
#ifdef SYS_open_by_handle_at
|
||||
DENY(SYS_open_by_handle_at);
|
||||
#endif
|
||||
#ifdef SYS_perf_event_open
|
||||
DENY(SYS_perf_event_open);
|
||||
#endif
|
||||
#ifdef SYS_pivot_root
|
||||
DENY(SYS_pivot_root);
|
||||
#endif
|
||||
#ifdef SYS_process_vm_readv
|
||||
DENY(SYS_process_vm_readv);
|
||||
#endif
|
||||
#ifdef SYS_process_vm_writev
|
||||
DENY(SYS_process_vm_writev);
|
||||
#endif
|
||||
#ifdef SYS_query_module
|
||||
DENY(SYS_query_module);
|
||||
#endif
|
||||
#ifdef SYS_quotactl
|
||||
DENY(SYS_quotactl);
|
||||
#endif
|
||||
#ifdef SYS_reboot
|
||||
DENY(SYS_reboot);
|
||||
#endif
|
||||
#ifdef SYS_request_key
|
||||
DENY(SYS_request_key);
|
||||
#endif
|
||||
#ifdef SYS_settimeofday
|
||||
DENY(SYS_settimeofday);
|
||||
#endif
|
||||
#ifdef SYS_stime
|
||||
DENY(SYS_stime);
|
||||
#endif
|
||||
#ifdef SYS_swapon
|
||||
DENY(SYS_swapon);
|
||||
#endif
|
||||
#ifdef SYS_swapoff
|
||||
DENY(SYS_swapoff);
|
||||
#endif
|
||||
#ifdef SYS_sysfs
|
||||
DENY(SYS_sysfs);
|
||||
#endif
|
||||
#ifdef SYS__sysctl
|
||||
DENY(SYS__sysctl);
|
||||
#endif
|
||||
#ifdef SYS_uselib
|
||||
DENY(SYS_uselib);
|
||||
#endif
|
||||
#ifdef SYS_userfaultfd
|
||||
DENY(SYS_userfaultfd);
|
||||
#endif
|
||||
#ifdef SYS_ustat
|
||||
DENY(SYS_ustat);
|
||||
#endif
|
||||
#ifdef SYS_mount
|
||||
DENY(SYS_mount);
|
||||
#endif
|
||||
#ifdef SYS_umount2
|
||||
DENY(SYS_umount2);
|
||||
#endif
|
||||
#ifdef SYS_umount
|
||||
DENY(SYS_umount);
|
||||
#endif
|
||||
#ifdef SYS_mount_setattr
|
||||
DENY(SYS_mount_setattr);
|
||||
#endif
|
||||
#ifdef SYS_move_mount
|
||||
DENY(SYS_move_mount);
|
||||
#endif
|
||||
#ifdef SYS_open_tree
|
||||
DENY(SYS_open_tree);
|
||||
#endif
|
||||
#ifdef SYS_fsopen
|
||||
DENY(SYS_fsopen);
|
||||
#endif
|
||||
#ifdef SYS_fsconfig
|
||||
DENY(SYS_fsconfig);
|
||||
#endif
|
||||
#ifdef SYS_fsmount
|
||||
DENY(SYS_fsmount);
|
||||
#endif
|
||||
#ifdef SYS_fspick
|
||||
DENY(SYS_fspick);
|
||||
#endif
|
||||
#ifdef SYS_io_uring_setup
|
||||
DENY(SYS_io_uring_setup);
|
||||
#endif
|
||||
#ifdef SYS_io_uring_enter
|
||||
DENY(SYS_io_uring_enter);
|
||||
#endif
|
||||
#ifdef SYS_io_uring_register
|
||||
DENY(SYS_io_uring_register);
|
||||
#endif
|
||||
#ifdef SYS_setns
|
||||
DENY(SYS_setns);
|
||||
#endif
|
||||
#ifdef SYS_kcmp
|
||||
DENY(SYS_kcmp);
|
||||
#endif
|
||||
#undef DENY
|
||||
return v;
|
||||
}
|
||||
|
||||
sock_filter stmt(unsigned short code, unsigned k) { return BPF_STMT(code, k); }
|
||||
sock_filter jump(unsigned short code, unsigned k, unsigned char jt, unsigned char jf) { return BPF_JUMP(code, k, jt, jf); }
|
||||
|
||||
constexpr unsigned kOffNr = offsetof(seccomp_data, nr);
|
||||
constexpr unsigned kOffArch = offsetof(seccomp_data, arch);
|
||||
constexpr unsigned kOffArg0 = offsetof(seccomp_data, args); // low 32 bits on little-endian
|
||||
|
||||
std::vector<sock_filter> buildFilter() {
|
||||
const unsigned eperm = SECCOMP_RET_ERRNO | EPERM;
|
||||
const unsigned enosys = SECCOMP_RET_ERRNO | ENOSYS;
|
||||
std::vector<sock_filter> f;
|
||||
// wrong architecture: kill, never guess
|
||||
f.push_back(stmt(BPF_LD | BPF_W | BPF_ABS, kOffArch));
|
||||
f.push_back(jump(BPF_JMP | BPF_JEQ | BPF_K, kArch, 1, 0));
|
||||
f.push_back(stmt(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS));
|
||||
f.push_back(stmt(BPF_LD | BPF_W | BPF_ABS, kOffNr));
|
||||
if (kHasX32) { // x32 syscall numbers must not dodge the numbers below
|
||||
f.push_back(jump(BPF_JMP | BPF_JSET | BPF_K, 0x40000000U, 0, 1));
|
||||
f.push_back(stmt(BPF_RET | BPF_K, enosys));
|
||||
}
|
||||
// clone3 cannot be inspected (flags live in a struct): ENOSYS makes libc fall back to clone()
|
||||
#ifdef SYS_clone3
|
||||
f.push_back(jump(BPF_JMP | BPF_JEQ | BPF_K, static_cast<unsigned>(SYS_clone3), 0, 1));
|
||||
f.push_back(stmt(BPF_RET | BPF_K, enosys));
|
||||
#endif
|
||||
// clone/unshare with CLONE_NEWUSER: EPERM (nested user namespaces are the usual first step of kernel exploits)
|
||||
for (unsigned nr : {static_cast<unsigned>(SYS_clone), static_cast<unsigned>(SYS_unshare)}) {
|
||||
f.push_back(jump(BPF_JMP | BPF_JEQ | BPF_K, nr, 0, 4));
|
||||
f.push_back(stmt(BPF_LD | BPF_W | BPF_ABS, kOffArg0));
|
||||
f.push_back(jump(BPF_JMP | BPF_JSET | BPF_K, CLONE_NEWUSER, 0, 1));
|
||||
f.push_back(stmt(BPF_RET | BPF_K, eperm));
|
||||
f.push_back(stmt(BPF_LD | BPF_W | BPF_ABS, kOffNr)); // restore the accumulator for the next compare
|
||||
}
|
||||
for (unsigned nr : deniedSyscalls()) {
|
||||
f.push_back(jump(BPF_JMP | BPF_JEQ | BPF_K, nr, 0, 1));
|
||||
f.push_back(stmt(BPF_RET | BPF_K, eperm));
|
||||
}
|
||||
f.push_back(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
|
||||
if (f.size() > 4096) throw std::runtime_error("seccomp program too large");
|
||||
return f;
|
||||
}
|
||||
|
||||
void dropCapabilities() {
|
||||
int last = CAP_LAST_CAP_FALLBACK;
|
||||
std::ifstream in("/proc/sys/kernel/cap_last_cap");
|
||||
if (in) in >> last;
|
||||
for (int cap = 0; cap <= last; ++cap) {
|
||||
if (std::find(spec::kKeptCaps.begin(), spec::kKeptCaps.end(), cap) != spec::kKeptCaps.end()) continue;
|
||||
if (::prctl(PR_CAPBSET_DROP, cap, 0, 0, 0) != 0 && errno != EINVAL) {
|
||||
throw SysError("drop capability " + std::to_string(cap));
|
||||
}
|
||||
}
|
||||
::prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Hardening::apply() {
|
||||
dropCapabilities();
|
||||
if (kArch == 0) {
|
||||
std::fprintf(stderr, "doos: no seccomp filter for this CPU architecture, capabilities were still reduced\n");
|
||||
return;
|
||||
}
|
||||
std::vector<sock_filter> prog = buildFilter();
|
||||
sock_fprog fp{static_cast<unsigned short>(prog.size()), prog.data()};
|
||||
if (::prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &fp) != 0) throw SysError("install seccomp filter");
|
||||
}
|
||||
|
||||
} // namespace doos
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
namespace doos {
|
||||
|
||||
// Confinement applied to every process that runs a container command, right before exec:
|
||||
// 1. capability bounding set reduced to the Docker default set (spec::kKeptCaps),
|
||||
// 2. a seccomp-bpf denylist (keyring, bpf, perf, userfaultfd, io_uring, mount family, kexec, module loading,
|
||||
// nested user namespaces and the like), with an architecture check and x32 guard.
|
||||
// Both need no_new_privs, which the caller sets. Throws when the filter cannot be installed.
|
||||
class Hardening {
|
||||
public:
|
||||
static void apply();
|
||||
};
|
||||
|
||||
} // namespace doos
|
||||
+3
-12
@@ -30,16 +30,6 @@ void writeProcFile(pid_t pid, const char* file, const std::string& content) {
|
||||
}
|
||||
}
|
||||
|
||||
bool onPath(std::string_view bin) {
|
||||
const char* path = std::getenv("PATH");
|
||||
std::stringstream ss(path ? path : "/usr/bin:/bin");
|
||||
for (std::string dir; std::getline(ss, dir, ':');) {
|
||||
std::string full = dir + "/" + std::string(bin);
|
||||
if (::access(full.c_str(), X_OK) == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
IdMode IdMap::parse(const std::string& s) {
|
||||
@@ -71,7 +61,8 @@ std::optional<SubRange> IdMap::range(const char* file) {
|
||||
}
|
||||
|
||||
bool IdMap::subuidAvailable() {
|
||||
return range("/etc/subuid") && range("/etc/subgid") && onPath(spec::kNewUidMap) && onPath(spec::kNewGidMap);
|
||||
return range("/etc/subuid") && range("/etc/subgid") && ::access(std::string(spec::kNewUidMap).c_str(), X_OK) == 0 &&
|
||||
::access(std::string(spec::kNewGidMap).c_str(), X_OK) == 0;
|
||||
}
|
||||
|
||||
IdMode IdMap::best() { return subuidAvailable() ? IdMode::Subuid : IdMode::Single; }
|
||||
@@ -86,7 +77,7 @@ void IdMap::apply(pid_t pid, IdMode mode) {
|
||||
}
|
||||
auto u = range("/etc/subuid");
|
||||
auto g = range("/etc/subgid");
|
||||
if (!u || !g || !onPath(spec::kNewUidMap) || !onPath(spec::kNewGidMap)) {
|
||||
if (!u || !g || ::access(std::string(spec::kNewUidMap).c_str(), X_OK) != 0 || ::access(std::string(spec::kNewGidMap).c_str(), X_OK) != 0) {
|
||||
throw std::runtime_error("subuid mapping unavailable: need /etc/subuid, /etc/subgid, newuidmap and newgidmap "
|
||||
"(install uidmap, or use --ids single)");
|
||||
}
|
||||
|
||||
+148
-17
@@ -1,5 +1,8 @@
|
||||
#include "image.hpp"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <sys/stat.h>
|
||||
#include <cstdio>
|
||||
#include <sys/utsname.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -28,13 +31,101 @@ std::string cpuArch(bool debianStyle) {
|
||||
}
|
||||
|
||||
std::string fetchText(const std::string& url) {
|
||||
ProcResult r = runProcess({"curl", "--fail", "--silent", "--show-error", "--location", "--proto", "=https",
|
||||
"--max-time", "60", url},
|
||||
ProcResult r = runProcess({resolveBinary("curl"), "--fail", "--silent", "--show-error", "--location",
|
||||
"--proto", "=https", "--max-time", "60", url},
|
||||
true);
|
||||
if (r.code != 0) throw std::runtime_error("download failed: " + url + " (curl exit " + std::to_string(r.code) + ")");
|
||||
return r.out;
|
||||
}
|
||||
|
||||
void fetchFile(const std::string& url, const fs::path& out) {
|
||||
ProcResult r = runProcess({resolveBinary("curl"), "--fail", "--silent", "--show-error", "--location",
|
||||
"--proto", "=https", "--max-time", "60", "--max-filesize",
|
||||
std::to_string(spec::kMaxCaptureBytes), "--output", out.string(), url});
|
||||
if (r.code != 0) throw std::runtime_error("download failed: " + url + " (curl exit " + std::to_string(r.code) + ")");
|
||||
}
|
||||
|
||||
// Private scratch directory, removed on scope exit.
|
||||
class TempDir {
|
||||
public:
|
||||
TempDir() {
|
||||
fs::path base;
|
||||
if (const char* h = std::getenv("DOOS_HOME"); h && *h) base = h;
|
||||
else if (const char* x = std::getenv("XDG_DATA_HOME"); x && *x) base = fs::path(x) / "doos";
|
||||
else if (const char* home = std::getenv("HOME"); home && *home) base = fs::path(home) / ".local/share/doos";
|
||||
else base = fs::temp_directory_path();
|
||||
fs::path scratch = base / "tmp";
|
||||
std::error_code ec;
|
||||
fs::create_directories(scratch, ec);
|
||||
if (ec) scratch = fs::temp_directory_path();
|
||||
std::string tmpl = (scratch / "doos-XXXXXX").string();
|
||||
if (!::mkdtemp(tmpl.data())) throw SysError("mkdtemp");
|
||||
path_ = tmpl;
|
||||
::chmod(path_.c_str(), 0700);
|
||||
}
|
||||
~TempDir() {
|
||||
std::error_code ec;
|
||||
fs::remove_all(path_, ec);
|
||||
}
|
||||
TempDir(const TempDir&) = delete;
|
||||
TempDir& operator=(const TempDir&) = delete;
|
||||
const fs::path& path() const { return path_; }
|
||||
|
||||
private:
|
||||
fs::path path_;
|
||||
};
|
||||
|
||||
std::string upper(std::string s) {
|
||||
for (auto& c : s) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
return s;
|
||||
}
|
||||
|
||||
// Verifies a detached signature against a publisher key that is fetched over https and accepted only when its
|
||||
// fingerprint equals the pinned one. Returns false (with a loud note) when gpg is not installed; throws when the
|
||||
// signature is bad, missing, or made by another key.
|
||||
bool verifySignature(const fs::path& data, const fs::path& sig, const std::string& keyUrl, const std::string& fpr) {
|
||||
std::string gpg;
|
||||
try {
|
||||
gpg = resolveBinary("gpg");
|
||||
} catch (const std::exception&) {
|
||||
std::fprintf(stderr, "note: signature NOT checked (gpg is not installed); relying on https and the publisher's checksum\n");
|
||||
return false;
|
||||
}
|
||||
TempDir home;
|
||||
::chmod(home.path().c_str(), 0700);
|
||||
fs::path keyFile = home.path() / "key.asc";
|
||||
fetchFile(keyUrl, keyFile);
|
||||
std::string h = home.path().string();
|
||||
if (runProcess({gpg, "--batch", "--quiet", "--homedir", h, "--import", keyFile.string()}, false, true).code != 0) {
|
||||
throw std::runtime_error("cannot import the publisher key from " + keyUrl);
|
||||
}
|
||||
ProcResult fp = runProcess({gpg, "--batch", "--homedir", h, "--with-colons", "--fingerprint"}, true, true);
|
||||
if (fp.out.find(":::::::::" + upper(fpr) + ":") == std::string::npos) {
|
||||
throw std::runtime_error("the key at " + keyUrl + " does not have the pinned fingerprint " + fpr);
|
||||
}
|
||||
ProcResult v = runProcess({gpg, "--batch", "--homedir", h, "--status-fd", "1", "--verify", sig.string(), data.string()}, true, true);
|
||||
bool good = false, valid = false;
|
||||
std::istringstream in(v.out);
|
||||
for (std::string line; std::getline(in, line);) {
|
||||
if (line.find("BADSIG") != std::string::npos || line.find("ERRSIG") != std::string::npos ||
|
||||
line.find("EXPKEYSIG") != std::string::npos || line.find("REVKEYSIG") != std::string::npos) {
|
||||
throw std::runtime_error("bad signature on " + data.filename().string());
|
||||
}
|
||||
if (line.find("[GNUPG:] GOODSIG") == 0) good = true;
|
||||
if (line.find("[GNUPG:] VALIDSIG") == 0) {
|
||||
std::istringstream ls(line);
|
||||
std::vector<std::string> tok;
|
||||
for (std::string t; ls >> t;) tok.push_back(upper(t));
|
||||
if (tok.size() > 2 && (tok[2] == upper(fpr) || tok.back() == upper(fpr))) valid = true;
|
||||
}
|
||||
}
|
||||
if (v.code != 0 || !good || !valid) {
|
||||
throw std::runtime_error("signature check failed for " + data.filename().string() + " (not signed by " + fpr + ")");
|
||||
}
|
||||
std::fprintf(stderr, "signature verified (key %s)\n", fpr.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isHex64(const std::string& s) {
|
||||
return s.size() == 64 && std::all_of(s.begin(), s.end(), [](unsigned char c) { return std::isxdigit(c); });
|
||||
}
|
||||
@@ -53,10 +144,11 @@ std::string lowerCase(std::string s) {
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::string> Catalog::names() { return {"alpine", "ubuntu", "ubuntu-24.04"}; }
|
||||
std::vector<std::string> Catalog::names() { return {"alpine", "ubuntu", "ubuntu-24.04", "debian"}; }
|
||||
|
||||
bool Catalog::knows(const std::string& n) {
|
||||
return n == "alpine" || n == "ubuntu" || (n.rfind("ubuntu-", 0) == 0 && n.size() > 7);
|
||||
return n == "alpine" || n == "ubuntu" || (n.rfind("ubuntu-", 0) == 0 && n.size() > 7) || n == "debian" ||
|
||||
(n.rfind("debian-", 0) == 0 && n.size() > 7);
|
||||
}
|
||||
|
||||
ImageSource Catalog::resolve(const std::string& n) {
|
||||
@@ -84,6 +176,32 @@ ImageSource Catalog::resolve(const std::string& n) {
|
||||
}
|
||||
src.location = base + file;
|
||||
src.sha256 = sha;
|
||||
src.sigUrl = src.location + ".asc";
|
||||
src.keyUrl = std::string(spec::kAlpineKeyUrl);
|
||||
src.keyFpr = std::string(spec::kAlpineKeyFpr);
|
||||
return src;
|
||||
}
|
||||
if (n == "debian" || n.rfind("debian-", 0) == 0) {
|
||||
std::string rel = n == "debian" ? std::string(spec::kDebianDefaultRelease) : n.substr(7);
|
||||
if (rel.empty() || !std::all_of(rel.begin(), rel.end(), [](unsigned char c) { return std::islower(c); })) {
|
||||
throw std::runtime_error("invalid debian release '" + rel + "' (example: debian-bookworm)");
|
||||
}
|
||||
std::string arch = cpuArch(true);
|
||||
std::string dist = arch == "amd64" ? "dist-amd64" : "dist-arm64v8";
|
||||
std::string base = std::string(spec::kDebianBase) + dist + "/" + rel + "/slim/oci/";
|
||||
std::string index = fetchText(base + "index.json");
|
||||
// The manifest is embedded (base64) in the index; its single layer digest is the sha256 of the rootfs tarball.
|
||||
auto d = index.find("\"data\":\"");
|
||||
if (d == std::string::npos) throw std::runtime_error("unexpected debian index.json (no embedded manifest)");
|
||||
d += 8;
|
||||
std::string manifest = base64Decode(index.substr(d, index.find('"', d) - d));
|
||||
auto l = manifest.find("\"layers\"");
|
||||
auto dg = l == std::string::npos ? l : manifest.find("sha256:", l);
|
||||
if (dg == std::string::npos) throw std::runtime_error("unexpected debian manifest (no layer digest)");
|
||||
std::string sha = manifest.substr(dg + 7, 64);
|
||||
if (!isHex64(sha)) throw std::runtime_error("unexpected debian layer digest");
|
||||
src.location = base + "blobs/rootfs.tar.gz";
|
||||
src.sha256 = sha; // content-addressed: the digest is the integrity check; there is no publisher signature
|
||||
return src;
|
||||
}
|
||||
if (n == "ubuntu" || n.rfind("ubuntu-", 0) == 0) {
|
||||
@@ -93,7 +211,12 @@ ImageSource Catalog::resolve(const std::string& n) {
|
||||
}
|
||||
std::string arch = cpuArch(true);
|
||||
std::string base = std::string(spec::kUbuntuBase) + ver + "/release/";
|
||||
std::string sums = fetchText(base + "SHA256SUMS");
|
||||
TempDir tmp;
|
||||
fetchFile(base + "SHA256SUMS", tmp.path() / "SHA256SUMS");
|
||||
fetchFile(base + "SHA256SUMS.gpg", tmp.path() / "SHA256SUMS.gpg");
|
||||
verifySignature(tmp.path() / "SHA256SUMS", tmp.path() / "SHA256SUMS.gpg", std::string(spec::kUbuntuKeyUrl),
|
||||
std::string(spec::kUbuntuKeyFpr));
|
||||
std::string sums = fsutil::readFile(tmp.path() / "SHA256SUMS");
|
||||
std::string suffix = "-base-" + arch + ".tar.gz";
|
||||
std::string bestFile, bestSha;
|
||||
std::vector<unsigned long> bestVer;
|
||||
@@ -114,7 +237,7 @@ ImageSource Catalog::resolve(const std::string& n) {
|
||||
src.sha256 = bestSha;
|
||||
return src;
|
||||
}
|
||||
throw std::runtime_error("unknown catalog image '" + n + "' (known: alpine, ubuntu, ubuntu-VERSION)");
|
||||
throw std::runtime_error("unknown catalog image '" + n + "' (known: alpine, ubuntu, ubuntu-VERSION, debian, debian-RELEASE)");
|
||||
}
|
||||
|
||||
std::string ImageStore::normalize(const std::string& name) {
|
||||
@@ -179,10 +302,13 @@ ImageInfo ImageStore::add(const ImageSource& src, IdMode ids) {
|
||||
if (count >= spec::kMaxImages) {
|
||||
throw std::runtime_error("image limit reached (" + std::to_string(spec::kMaxImages) + "); delete one first");
|
||||
}
|
||||
for (const char* tool : {"curl", "tar", "sha256sum"}) {
|
||||
if (runProcess({"sh", "-c", std::string("command -v ") + tool}, true, true).code != 0) {
|
||||
throw std::runtime_error(std::string("required tool not found: ") + tool);
|
||||
}
|
||||
std::string curl, tar, sha256sum;
|
||||
try {
|
||||
curl = resolveBinary("curl");
|
||||
tar = resolveBinary("tar");
|
||||
sha256sum = resolveBinary("sha256sum");
|
||||
} catch (const std::exception& e) {
|
||||
throw std::runtime_error(std::string("required tool missing: ") + e.what());
|
||||
}
|
||||
fs::path tmp = root_ / (".tmp-" + src.name);
|
||||
{
|
||||
@@ -202,32 +328,37 @@ ImageInfo ImageStore::add(const ImageSource& src, IdMode ids) {
|
||||
} else {
|
||||
archive = tmp / "download";
|
||||
std::fprintf(stderr, "downloading %s\n", src.location.c_str());
|
||||
ProcResult d = runProcess({"curl", "--fail", "--location", "--proto", "=https", "--progress-bar",
|
||||
ProcResult d = runProcess({curl, "--fail", "--location", "--proto", "=https", "--progress-bar",
|
||||
"--max-filesize", std::to_string(spec::kMaxImageDownloadBytes), "--max-time",
|
||||
std::to_string(spec::kDownloadTimeoutSec), "--output", archive.string(), src.location});
|
||||
if (d.code != 0) throw std::runtime_error("download failed (curl exit " + std::to_string(d.code) + ")");
|
||||
}
|
||||
if (!src.sha256.empty()) {
|
||||
ProcResult h = runProcess({"sha256sum", archive.string()}, true);
|
||||
ProcResult h = runProcess({sha256sum, archive.string()}, true);
|
||||
std::string got = h.out.substr(0, 64);
|
||||
if (h.code != 0 || got != lowerCase(src.sha256)) {
|
||||
throw std::runtime_error("sha256 mismatch for " + src.location + ": expected " + src.sha256 + ", got " + got);
|
||||
}
|
||||
std::fprintf(stderr, "sha256 verified\n");
|
||||
if (!src.sigUrl.empty()) {
|
||||
TempDir sigDir;
|
||||
fetchFile(src.sigUrl, sigDir.path() / "file.asc");
|
||||
verifySignature(archive, sigDir.path() / "file.asc", src.keyUrl, src.keyFpr);
|
||||
}
|
||||
} else {
|
||||
std::fprintf(stderr, "note: local file used without --sha256, not verified\n");
|
||||
}
|
||||
fs::path root = tmp / "rootfs";
|
||||
fsutil::mkdirs(root, 0755);
|
||||
std::vector<std::string> tar = {"tar", "-x", "-p", "--numeric-owner", "--exclude=./dev/*", "--exclude=dev/*",
|
||||
"-f", archive.string(), "-C", root.string()};
|
||||
if (ids == IdMode::Single) tar.insert(tar.begin() + 1, "--no-same-owner");
|
||||
std::vector<std::string> tarArgv = {tar, "-x", "-p", "--numeric-owner", "--exclude=./dev/*", "--exclude=dev/*",
|
||||
"-f", archive.string(), "-C", root.string()};
|
||||
if (ids == IdMode::Single) tarArgv.insert(tarArgv.begin() + 1, "--no-same-owner");
|
||||
std::fprintf(stderr, "extracting (ids: %s)\n", IdMap::name(ids).c_str());
|
||||
int rc = UserNsJob::run(ids, [&] {
|
||||
std::vector<char*> a;
|
||||
for (auto& s : tar) a.push_back(s.data());
|
||||
for (auto& s : tarArgv) a.push_back(s.data());
|
||||
a.push_back(nullptr);
|
||||
::execvp(a[0], a.data());
|
||||
::execv(a[0], a.data());
|
||||
return 127;
|
||||
});
|
||||
if (rc != 0) throw std::runtime_error("tar failed (exit " + std::to_string(rc) + ")");
|
||||
|
||||
+6
-1
@@ -15,6 +15,10 @@ struct ImageSource {
|
||||
std::string location; // https URL or local file path
|
||||
std::string sha256; // expected, empty only for local files
|
||||
bool isFile = false;
|
||||
// Optional detached signature over the downloaded file; the key is fetched and must match the pinned fingerprint.
|
||||
std::string sigUrl;
|
||||
std::string keyUrl;
|
||||
std::string keyFpr;
|
||||
};
|
||||
|
||||
struct ImageInfo {
|
||||
@@ -25,7 +29,8 @@ struct ImageInfo {
|
||||
std::uint64_t bytes = 0;
|
||||
};
|
||||
|
||||
// Built-in sources: alpine, ubuntu (latest LTS), ubuntu-VERSION. Resolved from the publishers over https.
|
||||
// Built-in sources: alpine, ubuntu (latest LTS), ubuntu-VERSION, debian, debian-RELEASE. Resolved from the
|
||||
// publishers over https; Alpine and Ubuntu are also checked against their pinned release signing keys.
|
||||
class Catalog {
|
||||
public:
|
||||
static std::vector<std::string> names();
|
||||
|
||||
+63
-5
@@ -18,14 +18,16 @@ namespace doos {
|
||||
|
||||
NetMode Network::parse(const std::string& s) {
|
||||
if (s == "slirp") return NetMode::Slirp;
|
||||
if (s == "pasta") return NetMode::Pasta;
|
||||
if (s == "host") return NetMode::Host;
|
||||
if (s == "none") return NetMode::None;
|
||||
throw std::runtime_error("unknown network mode '" + s + "' (use slirp, host or none)");
|
||||
throw std::runtime_error("unknown network mode '" + s + "' (use slirp, pasta, host or none)");
|
||||
}
|
||||
|
||||
std::string Network::name(NetMode m) {
|
||||
switch (m) {
|
||||
case NetMode::Slirp: return "slirp";
|
||||
case NetMode::Pasta: return "pasta";
|
||||
case NetMode::Host: return "host";
|
||||
case NetMode::None: return "none";
|
||||
}
|
||||
@@ -36,8 +38,10 @@ std::string Network::resolvConf(NetMode m) {
|
||||
switch (m) {
|
||||
case NetMode::Slirp: return "nameserver " + std::string(spec::kSlirpDns) + "\n";
|
||||
case NetMode::None: return "";
|
||||
case NetMode::Pasta:
|
||||
case NetMode::Host: {
|
||||
// Host netns is shared, so the host resolver config is valid. Stub on 127.0.0.53 works too.
|
||||
// pasta forwards the namespace's loopback to the host and host mode shares it, so the host resolver
|
||||
// config is valid as is, including the 127.0.0.53 stub.
|
||||
std::ifstream in("/etc/resolv.conf");
|
||||
return std::string(std::istreambuf_iterator<char>(in), {});
|
||||
}
|
||||
@@ -46,6 +50,9 @@ std::string Network::resolvConf(NetMode m) {
|
||||
}
|
||||
|
||||
void SlirpSession::start(pid_t targetPid) {
|
||||
const std::string bin(spec::kSlirpBinary);
|
||||
if (::access(bin.c_str(), X_OK) != 0) throw std::runtime_error("slirp4netns helper not found at " + bin +
|
||||
" (install slirp4netns or use --net none)");
|
||||
int ready[2];
|
||||
if (::pipe2(ready, O_CLOEXEC) != 0) throw SysError("pipe");
|
||||
Fd readEnd(ready[0]), writeEnd(ready[1]);
|
||||
@@ -54,14 +61,16 @@ void SlirpSession::start(pid_t targetPid) {
|
||||
if (pid_ < 0) throw SysError("fork slirp4netns");
|
||||
if (pid_ == 0) {
|
||||
::prctl(PR_SET_PDEATHSIG, SIGKILL);
|
||||
// Do not trust the caller's PATH: only system directories may be searched by anything the helper runs.
|
||||
::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1);
|
||||
int nul = ::open("/dev/null", O_WRONLY);
|
||||
if (nul >= 0) { ::dup2(nul, 1); ::dup2(nul, 2); }
|
||||
int wfd = ::dup(writeEnd.get()); // dup clears CLOEXEC
|
||||
std::string readyArg = "--ready-fd=" + std::to_string(wfd);
|
||||
std::string target = std::to_string(targetPid);
|
||||
std::string bin(spec::kSlirpBinary), tap(spec::kSlirpTapName);
|
||||
::execlp(bin.c_str(), bin.c_str(), "--configure", "--mtu=65520", "--disable-host-loopback",
|
||||
readyArg.c_str(), target.c_str(), tap.c_str(), static_cast<char*>(nullptr));
|
||||
std::string tap(spec::kSlirpTapName);
|
||||
::execl(bin.c_str(), bin.c_str(), "--configure", "--mtu=65520", "--disable-host-loopback",
|
||||
readyArg.c_str(), target.c_str(), tap.c_str(), static_cast<char*>(nullptr));
|
||||
::dprintf(2, "doos: cannot exec %s: %s\n", bin.c_str(), std::strerror(errno));
|
||||
::_exit(127);
|
||||
}
|
||||
@@ -84,3 +93,52 @@ void SlirpSession::stop() {
|
||||
}
|
||||
|
||||
} // namespace doos
|
||||
|
||||
namespace doos {
|
||||
|
||||
void PastaSession::start(pid_t targetPid) {
|
||||
const std::string bin(spec::kPastaBinary);
|
||||
if (::access(bin.c_str(), X_OK) != 0) throw std::runtime_error("pasta helper not found at " + bin +
|
||||
" (install pasta or use --net none)");
|
||||
pid_ = ::fork();
|
||||
if (pid_ < 0) throw SysError("fork pasta");
|
||||
if (pid_ == 0) {
|
||||
::prctl(PR_SET_PDEATHSIG, SIGKILL);
|
||||
::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1);
|
||||
int nul = ::open("/dev/null", O_WRONLY);
|
||||
if (nul >= 0) { ::dup2(nul, 1); ::dup2(nul, 2); }
|
||||
std::string target = std::to_string(targetPid);
|
||||
::execl(bin.c_str(), bin.c_str(), "--foreground", "--quiet", "--config-net", target.c_str(),
|
||||
static_cast<char*>(nullptr));
|
||||
::_exit(127);
|
||||
}
|
||||
// pasta has no ready fd: wait until the target namespace has an interface besides lo.
|
||||
std::string dev = "/proc/" + std::to_string(targetPid) + "/net/dev";
|
||||
for (int waited = 0; waited < spec::kNetReadyTimeoutMs; waited += 20) {
|
||||
int st = 0;
|
||||
if (::waitpid(pid_, &st, WNOHANG) == pid_) {
|
||||
pid_ = -1;
|
||||
throw std::runtime_error("pasta exited early (is it installed and permitted?)");
|
||||
}
|
||||
std::ifstream in(dev);
|
||||
int lines = 0;
|
||||
for (std::string line; std::getline(in, line);) ++lines;
|
||||
if (lines > 3) return; // two header lines + lo + at least one more interface
|
||||
::poll(nullptr, 0, 20);
|
||||
}
|
||||
stop();
|
||||
throw std::runtime_error("pasta not ready in time");
|
||||
}
|
||||
|
||||
void PastaSession::stop() {
|
||||
if (pid_ <= 0) return;
|
||||
// pasta drops to another uid after start, so we cannot signal it, and it leaves by itself shortly after its
|
||||
// namespace is gone. Reap it if it is already done; otherwise let init adopt it instead of blocking the exit.
|
||||
for (int waited = 0; waited < 20; waited += 5) {
|
||||
if (::waitpid(pid_, nullptr, WNOHANG) == pid_) break;
|
||||
::poll(nullptr, 0, 5);
|
||||
}
|
||||
pid_ = -1;
|
||||
}
|
||||
|
||||
} // namespace doos
|
||||
|
||||
+20
-1
@@ -5,7 +5,12 @@
|
||||
|
||||
namespace doos {
|
||||
|
||||
enum class NetMode { Slirp, Host, None };
|
||||
// slirp: private netns, outbound only, host loopback blocked (default).
|
||||
// pasta: private netns that behaves like the host network (host loopback, bound ports forwarded), but with
|
||||
// its own abstract unix sockets.
|
||||
// host: the host's network namespace itself (shares abstract sockets), explicit opt-in.
|
||||
// none: loopback only.
|
||||
enum class NetMode { Slirp, Pasta, Host, None };
|
||||
|
||||
class Network {
|
||||
public:
|
||||
@@ -17,6 +22,20 @@ public:
|
||||
static std::string resolvConf(NetMode m);
|
||||
};
|
||||
|
||||
// Owns a pasta process attached to a container's network namespace.
|
||||
class PastaSession {
|
||||
public:
|
||||
PastaSession() = default;
|
||||
PastaSession(const PastaSession&) = delete;
|
||||
PastaSession& operator=(const PastaSession&) = delete;
|
||||
~PastaSession() { stop(); }
|
||||
void start(pid_t targetPid);
|
||||
void stop();
|
||||
|
||||
private:
|
||||
pid_t pid_ = -1;
|
||||
};
|
||||
|
||||
// Owns a slirp4netns process attached to a container's network namespace.
|
||||
class SlirpSession {
|
||||
public:
|
||||
|
||||
+113
-19
@@ -15,12 +15,15 @@
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
|
||||
#include <poll.h>
|
||||
#include <algorithm>
|
||||
|
||||
#include <chrono>
|
||||
|
||||
#include "cgroup.hpp"
|
||||
#include "hardening.hpp"
|
||||
#include "spec.hpp"
|
||||
#include "util.hpp"
|
||||
|
||||
@@ -127,16 +130,26 @@ void mountHostLayers(const Container& c, const fs::path& root) {
|
||||
for (auto d : spec::kCompatDirs) {
|
||||
std::error_code e;
|
||||
std::string host(d);
|
||||
auto st = fs::symlink_status(host, e);
|
||||
if (e) continue;
|
||||
if (fs::is_symlink(st)) {
|
||||
fs::path target = fs::read_symlink(host);
|
||||
fs::path link = root / host.substr(1);
|
||||
fs::remove(link, e);
|
||||
fs::create_symlink(target, link);
|
||||
} else if (fs::is_directory(st)) {
|
||||
mountOverlay(c, root, d);
|
||||
// Read any symlink through an fd opened on its parent so the target cannot be swapped
|
||||
// between the check and the recreation (TOCTOU). Real directories are overlaid; absent
|
||||
// entries are skipped. Symlinks whose target we cannot pin are not recreated: fail closed.
|
||||
fs::path parent = fs::path(host).parent_path();
|
||||
int pfd = ::open(parent.c_str(), O_PATH | O_DIRECTORY | O_CLOEXEC);
|
||||
if (pfd < 0) continue;
|
||||
Fd guard(pfd);
|
||||
std::string base = fs::path(host).filename().string();
|
||||
char target[4096];
|
||||
ssize_t n = ::readlinkat(pfd, base.c_str(), target, sizeof target - 1);
|
||||
if (n < 0) {
|
||||
if (errno == EINVAL || errno == ENOENT) {
|
||||
if (fs::is_directory(host, e) && !e) mountOverlay(c, root, d);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
target[n] = '\0';
|
||||
fs::path link = root / host.substr(1);
|
||||
fs::remove(link, e);
|
||||
fs::create_symlink(target, link);
|
||||
}
|
||||
for (auto d : spec::kPrivateDirs) doMount(c.privateDir(std::string(d)).c_str(), sub(root, d), nullptr, MS_BIND);
|
||||
// overlays that live inside a private dir must be mounted after it
|
||||
@@ -200,12 +213,16 @@ void applyLimitsAndEnv(const Container& c) {
|
||||
if (::prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) throw SysError("no_new_privs");
|
||||
rlimit core{0, 0};
|
||||
::setrlimit(RLIMIT_CORE, &core);
|
||||
// No single file can outgrow the size cap, which bounds the overshoot between two usage scans.
|
||||
rlimit fsize{c.sizeCap(), c.sizeCap()};
|
||||
::setrlimit(RLIMIT_FSIZE, &fsize);
|
||||
rlimit nofile;
|
||||
if (::getrlimit(RLIMIT_NOFILE, &nofile) == 0) {
|
||||
if (nofile.rlim_max > spec::kMaxOpenFiles) nofile.rlim_max = spec::kMaxOpenFiles;
|
||||
if (nofile.rlim_cur > nofile.rlim_max) nofile.rlim_cur = nofile.rlim_max;
|
||||
::setrlimit(RLIMIT_NOFILE, &nofile);
|
||||
}
|
||||
if (!c.relaxed()) Hardening::apply();
|
||||
const char* term = std::getenv("TERM");
|
||||
std::string termv = term && *term ? term : "xterm";
|
||||
const char* lang = std::getenv("LANG");
|
||||
@@ -287,12 +304,60 @@ int containerInit(void* arg) {
|
||||
::_exit(code);
|
||||
} catch (const std::exception& e) {
|
||||
::dprintf(2, "doos: %s\n", e.what());
|
||||
auto* se = dynamic_cast<const SysError*>(&e);
|
||||
std::ifstream restrictUserns("/proc/sys/kernel/apparmor_restrict_unprivileged_userns");
|
||||
int restricted = 0;
|
||||
if (restrictUserns) restrictUserns >> restricted;
|
||||
if (se && (se->code() == EPERM || se->code() == EACCES) && restricted == 1) {
|
||||
::dprintf(2, "doos: AppArmor restricts user namespaces for this binary. Install a profile once with:\n %s\n",
|
||||
Sandbox::apparmorInstallHint().c_str());
|
||||
}
|
||||
::_exit(125);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
fs::path selfPath() {
|
||||
std::error_code ec;
|
||||
fs::path self = fs::read_symlink("/proc/self/exe", ec);
|
||||
if (ec) throw std::runtime_error("cannot resolve the path of this binary: " + ec.message());
|
||||
std::string path = self.string();
|
||||
if (path.find_first_of("{}[]*?\"\n,") != std::string::npos) {
|
||||
throw std::runtime_error("binary path contains characters AppArmor treats as globs: " + path);
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
// One profile per binary path, so installing doos in a second place does not replace the first profile.
|
||||
std::string profileName(const fs::path& self) {
|
||||
std::string n = "doos" + self.string();
|
||||
for (auto& c : n) {
|
||||
if (c == '/') c = '-';
|
||||
}
|
||||
return n;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string Sandbox::apparmorProfile() {
|
||||
fs::path self = selfPath();
|
||||
return "# Lets doos use unprivileged user namespaces where kernel.apparmor_restrict_unprivileged_userns=1.\n"
|
||||
"# Generated by: doos apparmor (profile is for exactly this path)\n"
|
||||
"abi <abi/5.0>,\n"
|
||||
"include <tunables/global>\n"
|
||||
"\n"
|
||||
"profile " + profileName(self) + " " + self.string() + " flags=(unconfined) {\n"
|
||||
" userns,\n"
|
||||
" @{exec_path} mr,\n"
|
||||
"}\n";
|
||||
}
|
||||
|
||||
std::string Sandbox::apparmorInstallHint() {
|
||||
std::string file = "/etc/apparmor.d/" + profileName(selfPath());
|
||||
return "doos apparmor | sudo tee " + file + " >/dev/null && sudo apparmor_parser -r " + file;
|
||||
}
|
||||
|
||||
int Sandbox::run() {
|
||||
if (command_.size() > spec::kMaxCommandArgs) throw std::runtime_error("too many command arguments");
|
||||
// Decide start-or-join under the lock so concurrent attaches agree.
|
||||
@@ -340,12 +405,20 @@ int Sandbox::start(FileLock& lock) {
|
||||
IdMap::apply(child, container_.ids());
|
||||
|
||||
SlirpSession slirp;
|
||||
PastaSession pasta;
|
||||
if (net == NetMode::Slirp) slirp.start(child);
|
||||
if (net == NetMode::Pasta) pasta.start(child);
|
||||
|
||||
std::string cgroupWhy;
|
||||
std::unique_ptr<Cgroup> cgroup = Cgroup::create(container_.name(), cgroupWhy);
|
||||
if (cgroup) cgroup->add(child);
|
||||
else std::fprintf(stderr, "doos: size cap only warns, cannot freeze: %s\n", cgroupWhy.c_str());
|
||||
Limits limits{container_.memoryCap(), container_.pidsCap(), container_.milliCpus()};
|
||||
std::string cgroupWhy, notEnforced;
|
||||
std::unique_ptr<Cgroup> cgroup = Cgroup::create(container_.name(), limits, cgroupWhy, notEnforced);
|
||||
if (cgroup) {
|
||||
cgroup->addMain(child);
|
||||
if (!notEnforced.empty()) std::fprintf(stderr, "doos: limits not enforced (controller missing): %s\n", notEnforced.c_str());
|
||||
} else {
|
||||
std::fprintf(stderr, "doos: memory, pids and cpu limits are not enforced and the size cap only warns: %s\n",
|
||||
cgroupWhy.c_str());
|
||||
}
|
||||
|
||||
container_.recordPid(child);
|
||||
struct PidGuard {
|
||||
@@ -369,34 +442,50 @@ int Sandbox::start(FileLock& lock) {
|
||||
|
||||
int status = watch(child, cgroup.get());
|
||||
reaper.armed = false;
|
||||
if (cgroup && cgroup->oomKills() > 0) {
|
||||
std::fprintf(stderr, "doos: '%s' was killed by its memory limit (%s); raise it with --memory\n",
|
||||
container_.name().c_str(), formatSize(container_.memoryCap()).c_str());
|
||||
}
|
||||
if (cgroup && cgroup->pidFailures() > 0) {
|
||||
std::fprintf(stderr, "doos: '%s' hit its process limit (%u) %llu times; raise it with --pids\n",
|
||||
container_.name().c_str(), container_.pidsCap(),
|
||||
static_cast<unsigned long long>(cgroup->pidFailures()));
|
||||
}
|
||||
return exitCodeOf(status);
|
||||
}
|
||||
|
||||
// Waits for the container's init while enforcing the size cap: warn at kSizeWarnPercent, freeze at the cap,
|
||||
// thaw again once usage drops below kSizeThawPercent. Nothing is ever deleted by this loop.
|
||||
int Sandbox::watch(pid_t child, Cgroup* cgroup) {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
long fd = ::syscall(SYS_pidfd_open, child, 0);
|
||||
Fd pidfd(static_cast<int>(fd));
|
||||
const std::uint64_t cap = container_.sizeCap();
|
||||
bool warned = false;
|
||||
auto lastScan = std::chrono::steady_clock::now();
|
||||
auto nextScan = Clock::now() + std::chrono::milliseconds(spec::kSizeCheckMinIntervalMs);
|
||||
for (;;) {
|
||||
auto left = std::chrono::duration_cast<std::chrono::milliseconds>(nextScan - Clock::now()).count();
|
||||
int timeout = static_cast<int>(std::max<long>(left, 50));
|
||||
if (pidfd.valid()) {
|
||||
pollfd p{pidfd.get(), POLLIN, 0};
|
||||
int r = ::poll(&p, 1, spec::kSizeCheckIntervalMs);
|
||||
int r = ::poll(&p, 1, timeout);
|
||||
if (r > 0) break;
|
||||
if (r < 0 && errno != EINTR) break;
|
||||
} else {
|
||||
int st = 0;
|
||||
if (::waitpid(child, &st, WNOHANG) == child) return st;
|
||||
::poll(nullptr, 0, 1000);
|
||||
::poll(nullptr, 0, std::min(timeout, 1000));
|
||||
}
|
||||
if (g_terminating && cgroup && cgroup->frozen()) cgroup->freeze(false); // a frozen init cannot see SIGTERM
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
if (now - lastScan < std::chrono::milliseconds(spec::kSizeCheckIntervalMs)) continue;
|
||||
lastScan = now;
|
||||
if (Clock::now() < nextScan) continue;
|
||||
|
||||
auto began = Clock::now();
|
||||
std::size_t skipped = 0;
|
||||
std::uint64_t used = container_.usedBytes(skipped);
|
||||
// The scan may use at most 1/kSizeScanDutyFactor of the time: big trees are scanned less often.
|
||||
auto took = Clock::now() - began;
|
||||
nextScan = Clock::now() + std::max(std::chrono::milliseconds(spec::kSizeCheckMinIntervalMs),
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(took) * spec::kSizeScanDutyFactor);
|
||||
const char* name = container_.name().c_str();
|
||||
if (used >= cap) {
|
||||
if (cgroup && !cgroup->frozen()) {
|
||||
@@ -444,10 +533,15 @@ int Sandbox::join(pid_t initPid, FileLock& lock) {
|
||||
if (!rootFd.valid()) throw SysError("open container root");
|
||||
|
||||
lock.unlock();
|
||||
// Later attaches live in the `shell` leaf: same limits as the container, but never frozen.
|
||||
std::unique_ptr<Cgroup> cgroup = Cgroup::open(container_.name());
|
||||
pid_t child = ::fork();
|
||||
if (child < 0) throw SysError("fork");
|
||||
if (child == 0) {
|
||||
try {
|
||||
if (cgroup) {
|
||||
try { cgroup->addShell(::getpid()); } catch (const std::exception&) { /* limits only; keep going */ }
|
||||
}
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
if (fds[i].valid() && ::setns(fds[i].get(), kinds[i]) != 0) {
|
||||
throw SysError(std::string("setns ") + names[i]);
|
||||
|
||||
@@ -18,6 +18,11 @@ public:
|
||||
|
||||
int run();
|
||||
|
||||
// AppArmor profile (text) that lets this very binary use user namespaces on Ubuntu 24.04+.
|
||||
static std::string apparmorProfile();
|
||||
// Shell command that installs it; shown when a start fails because of AppArmor.
|
||||
static std::string apparmorInstallHint();
|
||||
|
||||
private:
|
||||
int start(FileLock& lock);
|
||||
int watch(pid_t child, Cgroup* cgroup);
|
||||
|
||||
+47
-4
@@ -25,19 +25,32 @@ constexpr std::uint64_t kMinSizeBytes = 64ULL << 20;
|
||||
constexpr std::uint64_t kMaxSizeBytes = 1ULL << 40;
|
||||
constexpr int kSizeWarnPercent = 80;
|
||||
constexpr int kSizeThawPercent = 95; // thaw again below this share of the cap
|
||||
constexpr int kSizeCheckIntervalMs = 10000; // floor between usage scans, never faster
|
||||
constexpr int kSizeCheckMinIntervalMs = 2000; // floor between usage scans, never faster
|
||||
constexpr int kSizeScanDutyFactor = 10; // next scan waits at least 10x the last scan's duration
|
||||
constexpr std::size_t kMaxScanEntries = 5000000; // usage scan stops (and reports) beyond this
|
||||
constexpr std::string_view kCgroupDir = "doos"; // under the delegated user@UID.service cgroup
|
||||
|
||||
// Resource limits per container (--memory, --pids, --cpus). Enforced by cgroup v2; cpus 0 means "all cores".
|
||||
constexpr std::uint64_t kDefaultMemoryBytes = 8ULL << 30;
|
||||
constexpr std::uint64_t kMinMemoryBytes = 64ULL << 20;
|
||||
constexpr std::uint64_t kMaxMemoryBytes = 1ULL << 40;
|
||||
constexpr std::uint32_t kDefaultPids = 4096;
|
||||
constexpr std::uint32_t kMinPids = 16;
|
||||
constexpr std::uint32_t kMaxPids = 4194304;
|
||||
constexpr std::uint32_t kMaxMilliCpus = 1024000; // 1024 cpus
|
||||
constexpr std::uint32_t kMinMilliCpus = 100; // 0.1 cpu
|
||||
constexpr int kCpuPeriodUs = 100000;
|
||||
|
||||
// Seeding limits
|
||||
constexpr std::size_t kMaxSkeletonDirs = 200000;
|
||||
constexpr std::size_t kMaxEtcCopyFiles = 50000;
|
||||
constexpr std::uint64_t kMaxEtcCopyBytes = 256ULL << 20;
|
||||
|
||||
// Id mapping: container ids 0..kMaxIdRange. 0 maps to the invoking user, the rest to the subuid range.
|
||||
// Helpers are invoked by absolute path (no PATH lookup) to avoid RCE via a poisoned PATH.
|
||||
constexpr std::uint32_t kMaxIdRange = 65536;
|
||||
constexpr std::string_view kNewUidMap = "newuidmap";
|
||||
constexpr std::string_view kNewGidMap = "newgidmap";
|
||||
constexpr std::string_view kNewUidMap = "/usr/bin/newuidmap";
|
||||
constexpr std::string_view kNewGidMap = "/usr/bin/newgidmap";
|
||||
|
||||
// Images
|
||||
constexpr std::size_t kMaxImages = 16;
|
||||
@@ -47,9 +60,20 @@ constexpr std::size_t kMaxCaptureBytes = 1 << 20; // listing / checksum fetches
|
||||
constexpr std::string_view kAlpineBase = "https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/";
|
||||
constexpr std::string_view kUbuntuBase = "https://cdimage.ubuntu.com/ubuntu-base/releases/";
|
||||
constexpr std::string_view kUbuntuDefaultVersion = "26.04";
|
||||
constexpr std::string_view kDebianBase = "https://raw.githubusercontent.com/debuerreotype/docker-debian-artifacts/";
|
||||
constexpr std::string_view kDebianDefaultRelease = "trixie";
|
||||
|
||||
// Release signing keys, pinned by fingerprint. Keys are fetched from the publisher and rejected unless the
|
||||
// fingerprint matches. Sources of the fingerprints: alpinelinux.org/downloads, ubuntu.com verify tutorial.
|
||||
constexpr std::string_view kAlpineKeyUrl = "https://alpinelinux.org/keys/ncopa.asc";
|
||||
constexpr std::string_view kAlpineKeyFpr = "0482D84022F52DF1C4E7CD43293ACD0907D9495A";
|
||||
constexpr std::string_view kUbuntuKeyUrl =
|
||||
"https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x843938DF228D22F7B3742BC0D94AA3F0EFE21092";
|
||||
constexpr std::string_view kUbuntuKeyFpr = "843938DF228D22F7B3742BC0D94AA3F0EFE21092";
|
||||
|
||||
// Networking
|
||||
constexpr std::string_view kSlirpBinary = "slirp4netns";
|
||||
constexpr std::string_view kSlirpBinary = "/usr/bin/slirp4netns";
|
||||
constexpr std::string_view kPastaBinary = "/usr/bin/pasta";
|
||||
constexpr std::string_view kSlirpDns = "10.0.2.3";
|
||||
constexpr std::string_view kSlirpTapName = "tap0";
|
||||
|
||||
@@ -65,6 +89,25 @@ constexpr std::array<std::string_view, 2> kOverlayDirs = {"/usr", "/opt"};
|
||||
// chown --reference and in-place edits work). Unreadable entries are skipped and counted.
|
||||
constexpr std::array<std::string_view, 1> kCopiedDirs = {"/etc"};
|
||||
|
||||
// Host mode /etc under --hardening default: only these entries are copied (relaxed copies everything readable).
|
||||
// Entries that may hold credentials are skipped even inside allowed directories.
|
||||
constexpr std::array<std::string_view, 53> kEtcAllow = {
|
||||
"passwd", "group", "nsswitch.conf", "hosts", "hostname", "host.conf", "localtime", "timezone", "machine-id",
|
||||
"ld.so.conf", "ld.so.conf.d", "ld.so.cache", "ssl", "ca-certificates", "ca-certificates.conf", "pki",
|
||||
"alternatives", "apt", "dpkg", "bash.bashrc", "bashrc", "profile", "profile.d", "environment", "inputrc",
|
||||
"os-release", "lsb-release", "issue", "issue.net", "debian_version", "shells", "protocols", "services",
|
||||
"mime.types", "magic", "gai.conf", "locale.gen", "locale.conf", "default", "gitconfig", "bash_completion",
|
||||
"bash_completion.d", "vim", "emacs", "xdg", "fonts", "mailcap", "wgetrc", "ssh/ssh_config",
|
||||
"ssh/ssh_config.d", "perl", "python3", "terminfo"};
|
||||
constexpr std::array<std::string_view, 8> kEtcDenyNames = {"private", "shadow", "gshadow", "sudoers", "sudoers.d",
|
||||
"auth.conf", "auth.conf.d", "ssh_host_*"};
|
||||
|
||||
// Capabilities kept in the bounding set under --hardening default (the Docker default set).
|
||||
constexpr std::array<int, 14> kKeptCaps = {0 /*CHOWN*/, 1 /*DAC_OVERRIDE*/, 3 /*FOWNER*/, 4 /*FSETID*/,
|
||||
5 /*KILL*/, 6 /*SETGID*/, 7 /*SETUID*/, 8 /*SETPCAP*/,
|
||||
10 /*NET_BIND_SERVICE*/, 13 /*NET_RAW*/, 18 /*SYS_CHROOT*/,
|
||||
27 /*MKNOD*/, 29 /*AUDIT_WRITE*/, 31 /*SETFCAP*/};
|
||||
|
||||
// Extra overlay mounted inside the private /var, only when the host has the directory (dpkg database).
|
||||
constexpr std::array<std::string_view, 1> kVarOverlayDirs = {"/var/lib/dpkg"};
|
||||
|
||||
|
||||
+125
-10
@@ -81,6 +81,29 @@ std::string formatSize(std::uint64_t b) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string base64Decode(const std::string& in) {
|
||||
std::string out;
|
||||
unsigned val = 0;
|
||||
int bits = -8;
|
||||
for (unsigned char c : in) {
|
||||
int d;
|
||||
if (c >= 'A' && c <= 'Z') d = c - 'A';
|
||||
else if (c >= 'a' && c <= 'z') d = c - 'a' + 26;
|
||||
else if (c >= '0' && c <= '9') d = c - '0' + 52;
|
||||
else if (c == '+' || c == '-') d = 62;
|
||||
else if (c == '/' || c == '_') d = 63;
|
||||
else if (c == '=' || std::isspace(c)) continue;
|
||||
else throw std::runtime_error("invalid base64 input");
|
||||
val = (val << 6) | static_cast<unsigned>(d);
|
||||
bits += 6;
|
||||
if (bits >= 0) {
|
||||
out.push_back(static_cast<char>((val >> bits) & 0xFF));
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
ProcResult runProcess(const std::vector<std::string>& argv, bool capture, bool quiet) {
|
||||
int pipefd[2] = {-1, -1};
|
||||
if (capture && ::pipe2(pipefd, O_CLOEXEC) != 0) throw SysError("pipe");
|
||||
@@ -93,6 +116,8 @@ ProcResult runProcess(const std::vector<std::string>& argv, bool capture, bool q
|
||||
int nul = ::open("/dev/null", O_WRONLY);
|
||||
if (nul >= 0) { ::dup2(nul, 2); if (!capture) ::dup2(nul, 1); }
|
||||
}
|
||||
// Never trust the caller's PATH: helpers are found only in system directories.
|
||||
::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1);
|
||||
std::vector<char*> a;
|
||||
for (const auto& x : argv) a.push_back(const_cast<char*>(x.c_str()));
|
||||
a.push_back(nullptr);
|
||||
@@ -145,28 +170,34 @@ void mirrorRec(const fs::path& lower, const fs::path& upper, SeedStats& st, std:
|
||||
}
|
||||
}
|
||||
|
||||
void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_t depth) {
|
||||
void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_t depth, const SkipFn& skip) {
|
||||
if (depth > spec::kMaxTreeDepth) throw std::runtime_error("tree too deep: " + from.string());
|
||||
std::error_code ec;
|
||||
int dirfd = ::open(from.c_str(), O_PATH | O_DIRECTORY | O_CLOEXEC);
|
||||
fs::directory_iterator it(from, ec), end;
|
||||
if (ec) { ++st.skipped; return; }
|
||||
if (ec) { ++st.skipped; if (dirfd >= 0) ::close(dirfd); return; }
|
||||
for (; it != end; it.increment(ec)) {
|
||||
if (ec) { ++st.skipped; return; }
|
||||
if (ec) { ++st.skipped; continue; }
|
||||
struct stat sb;
|
||||
const fs::path& src = it->path();
|
||||
fs::path dst = to / src.filename();
|
||||
if (::lstat(src.c_str(), &sb) != 0) { ++st.skipped; continue; }
|
||||
if (skip && skip(src)) { ++st.skipped; continue; }
|
||||
if (st.made >= spec::kMaxEtcCopyFiles) throw std::runtime_error("copy file cap reached: " + from.string());
|
||||
if (S_ISDIR(sb.st_mode)) {
|
||||
if (::mkdir(dst.c_str(), (sb.st_mode & 07777) | 0700) != 0 && errno != EEXIST) {
|
||||
throw SysError("mkdir " + dst.string());
|
||||
}
|
||||
++st.made;
|
||||
copyRec(src, dst, st, depth + 1);
|
||||
copyRec(src, dst, st, depth + 1, skip);
|
||||
} else if (S_ISLNK(sb.st_mode)) {
|
||||
char buf[4096];
|
||||
ssize_t n = (dirfd >= 0) ? ::readlinkat(dirfd, src.filename().c_str(), buf, sizeof buf - 1) : -1;
|
||||
if (n < 0) { ++st.skipped; continue; }
|
||||
buf[n] = '\0';
|
||||
std::error_code e2;
|
||||
fs::remove(dst, e2);
|
||||
fs::create_symlink(fs::read_symlink(src), dst);
|
||||
fs::create_symlink(std::string(buf, static_cast<std::size_t>(n)), dst);
|
||||
++st.made;
|
||||
} else if (S_ISREG(sb.st_mode)) {
|
||||
if (::access(src.c_str(), R_OK) != 0) { ++st.skipped; continue; }
|
||||
@@ -181,6 +212,7 @@ void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_
|
||||
++st.skipped;
|
||||
}
|
||||
}
|
||||
if (dirfd >= 0) ::close(dirfd);
|
||||
}
|
||||
|
||||
struct InodeHash {
|
||||
@@ -217,13 +249,53 @@ SeedStats mirrorDirs(const fs::path& lower, const fs::path& upper) {
|
||||
return st;
|
||||
}
|
||||
|
||||
SeedStats copyTree(const fs::path& from, const fs::path& to) {
|
||||
SeedStats copyTree(const fs::path& from, const fs::path& to, const SkipFn& skip) {
|
||||
SeedStats st;
|
||||
mkdirs(to);
|
||||
copyRec(from, to, st, 0);
|
||||
copyRec(from, to, st, 0, skip);
|
||||
return st;
|
||||
}
|
||||
|
||||
SeedStats copyEntries(const fs::path& from, const fs::path& to, const std::vector<std::string>& entries,
|
||||
const SkipFn& skip) {
|
||||
SeedStats total;
|
||||
mkdirs(to);
|
||||
for (const auto& e : entries) {
|
||||
fs::path src = from / e, dst = to / e;
|
||||
struct stat sb;
|
||||
if (::lstat(src.c_str(), &sb) != 0) continue; // not present on this host: nothing to copy, nothing skipped
|
||||
if (skip && skip(src)) { ++total.skipped; continue; }
|
||||
mkdirs(dst.parent_path());
|
||||
if (S_ISDIR(sb.st_mode)) {
|
||||
SeedStats s = copyTree(src, dst, skip);
|
||||
total.made += s.made; total.skipped += s.skipped; total.bytes += s.bytes;
|
||||
} else if (S_ISLNK(sb.st_mode)) {
|
||||
fs::path parent = src.parent_path();
|
||||
int pfd = ::open(parent.c_str(), O_PATH | O_DIRECTORY | O_CLOEXEC);
|
||||
char buf[4096];
|
||||
ssize_t n = (pfd >= 0) ? ::readlinkat(pfd, src.filename().c_str(), buf, sizeof buf - 1) : -1;
|
||||
if (pfd >= 0) ::close(pfd);
|
||||
if (n < 0) { ++total.skipped; continue; }
|
||||
buf[n] = '\0';
|
||||
std::error_code ec;
|
||||
fs::remove(dst, ec);
|
||||
fs::create_symlink(std::string(buf, static_cast<std::size_t>(n)), dst);
|
||||
++total.made;
|
||||
} else if (S_ISREG(sb.st_mode) && ::access(src.c_str(), R_OK) == 0) {
|
||||
std::error_code ec;
|
||||
fs::copy_file(src, dst, fs::copy_options::overwrite_existing, ec);
|
||||
if (ec) { ++total.skipped; continue; }
|
||||
::chmod(dst.c_str(), (sb.st_mode & 07777) | 0600);
|
||||
total.bytes += static_cast<std::uint64_t>(sb.st_size);
|
||||
++total.made;
|
||||
} else {
|
||||
++total.skipped;
|
||||
}
|
||||
if (total.bytes > spec::kMaxEtcCopyBytes) throw std::runtime_error("copy byte cap reached: " + from.string());
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
SeedStats copyFiles(const fs::path& from, const fs::path& to) {
|
||||
SeedStats st;
|
||||
std::error_code ec;
|
||||
@@ -255,9 +327,29 @@ std::uint64_t diskUsage(const fs::path& p, std::size_t& skipped) {
|
||||
}
|
||||
|
||||
void mkdirs(const fs::path& p, mode_t mode) {
|
||||
std::error_code ec;
|
||||
fs::create_directories(p, ec);
|
||||
if (ec) throw std::runtime_error("mkdir " + p.string() + ": " + ec.message());
|
||||
if (p.empty()) return;
|
||||
std::vector<std::string> comps;
|
||||
for (const auto& part : p) {
|
||||
std::string s = part.string();
|
||||
if (s.empty() || s == ".") continue;
|
||||
if (s == "..") throw std::runtime_error("mkdir " + p.string() + ": '..' not allowed");
|
||||
comps.push_back(s);
|
||||
}
|
||||
if (comps.empty()) return;
|
||||
bool absolute = p.is_absolute();
|
||||
int base = ::open(absolute ? "/" : ".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
|
||||
if (base < 0) throw SysError("mkdir " + p.string());
|
||||
Fd guard(base);
|
||||
for (std::size_t i = 0; i < comps.size(); ++i) {
|
||||
bool last = (i + 1 == comps.size());
|
||||
mode_t m = last ? mode : 0755;
|
||||
if (::mkdirat(guard.get(), comps[i].c_str(), m) != 0 && errno != EEXIST) {
|
||||
throw SysError("mkdir " + p.string());
|
||||
}
|
||||
int nfd = ::openat(guard.get(), comps[i].c_str(), O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (nfd < 0) throw SysError("mkdir " + p.string() + " (not a directory or symlink)");
|
||||
guard.reset(nfd);
|
||||
}
|
||||
::chmod(p.c_str(), mode);
|
||||
}
|
||||
|
||||
@@ -301,4 +393,27 @@ std::size_t countEntries(const fs::path& p, std::size_t cap) {
|
||||
}
|
||||
|
||||
} // namespace fsutil
|
||||
|
||||
std::string resolveBinary(const std::string& name) {
|
||||
if (name.find('/') != std::string::npos) return name;
|
||||
// The state dir's bin/ wins: it is the trusted configuration location (0700, like /etc) and lets tests
|
||||
// or a user override a helper without touching system directories. Never the caller's PATH.
|
||||
std::string base;
|
||||
if (const char* h = ::getenv("DOOS_HOME"); h && *h) base = h;
|
||||
else if (const char* x = ::getenv("XDG_DATA_HOME"); x && *x) base = std::string(x) + "/doos";
|
||||
else if (const char* home = ::getenv("HOME"); home && *home) base = std::string(home) + "/.local/share/doos";
|
||||
if (!base.empty()) {
|
||||
std::string p = base + "/bin/" + name;
|
||||
struct stat st;
|
||||
if (::stat(p.c_str(), &st) == 0 && S_ISREG(st.st_mode) && ::access(p.c_str(), X_OK) == 0) return p;
|
||||
}
|
||||
for (const char* dir : {"/usr/local/bin", "/usr/bin", "/usr/local/sbin", "/usr/sbin", "/bin", "/sbin"}) {
|
||||
std::string p = std::string(dir) + "/" + name;
|
||||
struct stat st;
|
||||
if (::stat(p.c_str(), &st) == 0 && S_ISREG(st.st_mode) && ::access(p.c_str(), X_OK) == 0) return p;
|
||||
}
|
||||
throw std::runtime_error("required helper '" + name +
|
||||
"' not found in the state dir's bin/, /usr/local/bin, /usr/bin, /usr/sbin, /bin or /sbin (install it)");
|
||||
}
|
||||
|
||||
} // namespace doos
|
||||
|
||||
+13
-1
@@ -2,6 +2,7 @@
|
||||
#include <cerrno>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -51,6 +52,8 @@ private:
|
||||
std::uint64_t parseSize(const std::string& text);
|
||||
std::string formatSize(std::uint64_t bytes);
|
||||
|
||||
std::string base64Decode(const std::string& in); // lenient on whitespace, throws on bad characters
|
||||
|
||||
struct ProcResult {
|
||||
int code = -1;
|
||||
std::string out; // stdout, only when captured
|
||||
@@ -67,7 +70,12 @@ struct SeedStats {
|
||||
// Creates every directory of `lower` in `upper` (owner rwx added) so a unmapped-owner lower stays writable.
|
||||
SeedStats mirrorDirs(const std::filesystem::path& lower, const std::filesystem::path& upper);
|
||||
// Copies dirs, regular files and symlinks readable by the caller. Capped by spec.hpp limits.
|
||||
SeedStats copyTree(const std::filesystem::path& from, const std::filesystem::path& to);
|
||||
// `skip` (optional) is asked for every entry and returns true to leave it out (counted as skipped).
|
||||
using SkipFn = std::function<bool(const std::filesystem::path&)>;
|
||||
SeedStats copyTree(const std::filesystem::path& from, const std::filesystem::path& to, const SkipFn& skip = nullptr);
|
||||
// Copies only the named entries (relative paths, files, links or dirs) of `from` into `to`.
|
||||
SeedStats copyEntries(const std::filesystem::path& from, const std::filesystem::path& to,
|
||||
const std::vector<std::string>& entries, const SkipFn& skip);
|
||||
// Copies only the regular files directly inside `from` (readable ones) into `to`; existing targets stay.
|
||||
SeedStats copyFiles(const std::filesystem::path& from, const std::filesystem::path& to);
|
||||
// Sum of allocated bytes (st_blocks) below p, lstat only. `skipped` counts unreadable dirs.
|
||||
@@ -80,4 +88,8 @@ void removeTree(const std::filesystem::path& p, std::size_t depth = 0);
|
||||
std::size_t countEntries(const std::filesystem::path& p, std::size_t cap);
|
||||
} // namespace fsutil
|
||||
|
||||
// Resolves an external helper binary against a fixed set of trusted directories
|
||||
// (never the caller's PATH), throwing when it is not found.
|
||||
std::string resolveBinary(const std::string& name);
|
||||
|
||||
} // namespace doos
|
||||
|
||||
+109
-12
@@ -12,6 +12,7 @@ out() { "$DOOS" attach t -- sh -c "$1" 2>&1; }
|
||||
trap '"$DOOS" delete --yes t >/dev/null 2>&1; rm -rf "$DOOS_HOME"' EXIT
|
||||
|
||||
check "list empty" "$DOOS" list
|
||||
check "docs cross-linked, links resolve" "$(dirname "$0")/../scripts/check-docs.sh"
|
||||
check "get_or_create creates" "$DOOS" get_or_create t
|
||||
[ "$("$DOOS" get_or_create t)" = "exists t" ] && ok "get_or_create idempotent" || bad "get_or_create idempotent"
|
||||
check "invalid name rejected" bash -c "! '$DOOS' get_or_create 'Bad Name'"
|
||||
@@ -49,8 +50,8 @@ wait; sleep 0.5
|
||||
"$DOOS" get_or_create --net none n >/dev/null
|
||||
"$DOOS" attach n -- bash -c 'exec 3<>/dev/tcp/1.1.1.1/443' >/dev/null 2>&1 && bad "net none blocks" || ok "net none blocks"
|
||||
"$DOOS" delete --yes n >/dev/null
|
||||
"$DOOS" get_or_create --net host h >/dev/null
|
||||
"$DOOS" attach h -- bash -c 'exec 3<>/dev/tcp/1.1.1.1/443' >/dev/null 2>&1 && ok "net host works" || bad "net host works"
|
||||
"$DOOS" get_or_create --net host --i-understand h >/dev/null
|
||||
"$DOOS" attach h --i-understand -- bash -c 'exec 3<>/dev/tcp/1.1.1.1/443' >/dev/null 2>&1 && ok "net host works" || bad "net host works"
|
||||
"$DOOS" delete --yes h >/dev/null
|
||||
|
||||
# delete: dry-run by default
|
||||
@@ -102,22 +103,118 @@ check "image in use is a dry run too" bash -c "! '$DOOS' image delete alpine"
|
||||
"$DOOS" attach --image ubuntu u1 -- sh -c 'apt-get update >/dev/null 2>&1; apt-get install -y --no-install-recommends cowsay >/dev/null 2>&1; dpkg --audit && /usr/games/cowsay ok >/dev/null' && ok "ubuntu image: apt install completes" || bad "ubuntu image: apt install completes"
|
||||
"$DOOS" delete --yes u1 >/dev/null; "$DOOS" image delete --yes ubuntu >/dev/null
|
||||
|
||||
# ---- size cap (needs cgroup v2 delegation; takes about 40 s because the scan interval has a 10 s floor)
|
||||
echo "--- size cap"
|
||||
"$DOOS" get_or_create --image alpine --size 64m cap >/dev/null 2>&1
|
||||
"$DOOS" attach cap -- sh -c 'dd if=/dev/zero of=/root/big bs=1M count=100 2>/dev/null; sleep 40' >/tmp/doos-cap.$$ 2>&1 &
|
||||
sleep 15
|
||||
# ---- size cap (needs cgroup v2 delegation; scans every 2 s at the earliest)
|
||||
echo "--- size cap, accounting of other ids"
|
||||
"$DOOS" get_or_create --size 64m cap >/dev/null 2>&1
|
||||
# 80 MB written as container uid 5 into a mode-700 directory the host user cannot read: only an id-map scan sees it
|
||||
"$DOOS" attach cap -- python3 -c '
|
||||
import os,time
|
||||
os.mkdir("/root/d5"); os.chown("/root/d5",5,5); os.chmod("/root/d5",0o700); os.setgid(5); os.setuid(5)
|
||||
for n in (1,2):
|
||||
with open("/root/d5/f%d"%n,"wb") as f: f.write(b"x"*(40*1024*1024)); f.flush(); os.fsync(f.fileno())
|
||||
time.sleep(40)' >/tmp/doos-cap.$$ 2>&1 &
|
||||
sleep 9
|
||||
if grep -q "FROZEN" /tmp/doos-cap.$$; then
|
||||
ok "size cap freezes"
|
||||
"$DOOS" attach cap -- rm /root/big >/dev/null 2>&1; sleep 12
|
||||
ok "size cap freezes (counts other uids)"
|
||||
"$DOOS" attach cap -- sh -c 'grep -q /shell /proc/self/cgroup 2>/dev/null; rm -rf /root/d5' >/dev/null 2>&1; sleep 5
|
||||
grep -q thawed /tmp/doos-cap.$$ && ok "size cap thaws after cleanup" || bad "size cap thaws after cleanup"
|
||||
elif grep -q "cannot freeze" /tmp/doos-cap.$$; then
|
||||
elif grep -q "cannot freeze\|not enforced" /tmp/doos-cap.$$; then
|
||||
echo "skip size cap: no cgroup delegation here (warn-only mode confirmed)"
|
||||
else
|
||||
bad "size cap freezes"
|
||||
bad "size cap freezes (counts other uids)"
|
||||
fi
|
||||
kill %% 2>/dev/null; wait 2>/dev/null; rm -f /tmp/doos-cap.$$
|
||||
"$DOOS" delete --yes cap >/dev/null 2>&1; "$DOOS" image delete --yes alpine >/dev/null 2>&1
|
||||
"$DOOS" delete --yes cap >/dev/null 2>&1
|
||||
|
||||
# ---- resource limits, rescue leaf, hardening
|
||||
echo "--- limits and hardening"
|
||||
"$DOOS" get_or_create --memory 256m --pids 32 --cpus 0.5 lim >/dev/null 2>&1
|
||||
"$DOOS" list | grep -q "^lim" && ok "limit options accepted" || bad "limit options accepted"
|
||||
r=$("$DOOS" attach lim -- python3 -c 'b=bytearray(b"x"*(400*1024*1024)); print("survived")' 2>&1)
|
||||
echo "$r" | grep -q "killed by its memory limit" && ! echo "$r" | grep -q survived && ok "memory limit kills the container" || bad "memory limit kills the container"
|
||||
r=$("$DOOS" attach lim -- sh -c 'for i in $(seq 1 80); do sleep 3 & done 2>&1; wait' 2>&1)
|
||||
echo "$r" | grep -q "process limit" && ok "pids limit enforced and reported" || bad "pids limit enforced and reported"
|
||||
check "limits can change later" "$DOOS" get_or_create --memory 512m lim
|
||||
check "memory below minimum refused" bash -c "! '$DOOS' get_or_create --memory 1m z3"
|
||||
[ ! -e "$DOOS_HOME/containers/z3" ] && ok "failed create leaves nothing" || bad "failed create leaves nothing"
|
||||
check "bad cpus refused" bash -c "! '$DOOS' get_or_create --cpus abc z4"
|
||||
"$DOOS" attach lim -- sleep 6 >/dev/null 2>&1 & sleep 1.5
|
||||
[ "$("$DOOS" attach lim -- sh -c 'grep -c /shell /proc/self/cgroup')" = "1" ] && ok "second attach runs in the shell cgroup" || bad "second attach runs in the shell cgroup"
|
||||
wait
|
||||
h() { "$DOOS" attach lim -- python3 -c "$1" 2>&1; }
|
||||
[ "$(h 'import ctypes,errno;l=ctypes.CDLL(None,use_errno=True);print(l.syscall(250,0,0,0,0,0),errno.errorcode[ctypes.get_errno()])')" = "-1 EPERM" ] && ok "seccomp: keyctl denied" || bad "seccomp: keyctl denied"
|
||||
[ "$(h 'import ctypes,errno;l=ctypes.CDLL(None,use_errno=True);print(l.syscall(272,0x10000000),errno.errorcode[ctypes.get_errno()])')" = "-1 EPERM" ] && ok "seccomp: nested user namespace denied" || bad "seccomp: nested user namespace denied"
|
||||
[ "$(h 'import ctypes,errno;l=ctypes.CDLL(None,use_errno=True);print(l.syscall(435,0,0),errno.errorcode[ctypes.get_errno()])')" = "-1 ENOSYS" ] && ok "seccomp: clone3 answers ENOSYS" || bad "seccomp: clone3 answers ENOSYS"
|
||||
"$DOOS" attach lim -- sh -c 'mount -t tmpfs t /mnt' >/dev/null 2>&1 && bad "mount denied" || ok "mount denied"
|
||||
[ "$("$DOOS" attach lim -- sh -c 'grep ^CapEff /proc/self/status | cut -f2')" = "00000000a80425fb" ] && ok "capabilities reduced to the default set" || bad "capabilities reduced to the default set"
|
||||
"$DOOS" attach lim -- sh -c 'gcc --version >/dev/null && curl -sS -o /dev/null https://example.com && git --version >/dev/null && python3 -c "import ssl" && ping -c1 -W3 1.1.1.1 >/dev/null' && ok "common tools work under hardening" || bad "common tools work under hardening"
|
||||
[ "$("$DOOS" attach lim -- sh -c 'ls /etc | wc -l')" -lt 80 ] && ok "etc is an allowlist" || bad "etc is an allowlist"
|
||||
"$DOOS" attach lim -- sh -c 'ls /etc/shadow /etc/sudoers /etc/ssh/ssh_host_rsa_key' >/dev/null 2>&1 && bad "etc: no credentials" || ok "etc: no credentials"
|
||||
"$DOOS" get_or_create --hardening relaxed --i-understand rx >/dev/null 2>&1
|
||||
"$DOOS" attach rx --i-understand -- sh -c 'unshare -U true && grep -q "^Seccomp:.0" /proc/self/status' >/dev/null 2>&1 && ok "relaxed: no seccomp, nested userns allowed" || bad "relaxed: no seccomp, nested userns allowed"
|
||||
check "hardening fixed at creation" bash -c "! '$DOOS' get_or_create --hardening relaxed --i-understand lim"
|
||||
"$DOOS" delete --yes lim >/dev/null; "$DOOS" delete --yes rx >/dev/null
|
||||
|
||||
# ---- pasta network mode
|
||||
echo "--- pasta"
|
||||
python3 -c '
|
||||
import socket,time
|
||||
s=socket.socket(); s.bind(("127.0.0.1",48765)); s.listen(5)
|
||||
a=socket.socket(socket.AF_UNIX); a.bind("\0doos-abstract-test"); a.listen(5)
|
||||
time.sleep(25)' & lp=$!
|
||||
sleep 1
|
||||
"$DOOS" get_or_create --net pasta pa >/dev/null 2>&1
|
||||
pr() { "$DOOS" attach pa -- python3 -c "$1" 2>&1; }
|
||||
[ "$(pr 'import socket;s=socket.create_connection(("127.0.0.1",48765),2);print("ok")')" = "ok" ] && ok "pasta: host loopback reachable" || bad "pasta: host loopback reachable"
|
||||
[ "$(pr 'import socket;s=socket.socket(socket.AF_UNIX)
|
||||
try: s.connect("\0doos-abstract-test");print("reachable")
|
||||
except Exception: print("blocked")')" = "blocked" ] && ok "pasta: host abstract sockets not shared" || bad "pasta: host abstract sockets not shared"
|
||||
[ "$(pr 'import urllib.request;print(urllib.request.urlopen("https://example.com",timeout=6).status)')" = "200" ] && ok "pasta: dns and https" || bad "pasta: dns and https"
|
||||
"$DOOS" attach pa -- python3 -c '
|
||||
import socket
|
||||
s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind(("0.0.0.0",48911)); s.listen(1); s.settimeout(8)
|
||||
c,_=s.accept(); print(c.recv(5).decode())' >/tmp/doos-in.$$ 2>&1 &
|
||||
sleep 2.5; python3 -c 'import socket;s=socket.create_connection(("127.0.0.1",48911),2);s.sendall(b"hello")' 2>/dev/null; wait %2 2>/dev/null; sleep 0.5
|
||||
grep -q hello /tmp/doos-in.$$ && ok "pasta: inbound port forwarded" || bad "pasta: inbound port forwarded"
|
||||
rm -f /tmp/doos-in.$$; kill $lp 2>/dev/null; wait 2>/dev/null
|
||||
"$DOOS" delete --yes pa >/dev/null
|
||||
|
||||
# ---- documented network examples that were not covered yet, and the apparmor command
|
||||
echo "--- network examples"
|
||||
"$DOOS" apparmor | grep -q "profile doos.* $DOOS flags=(unconfined)" && ok "apparmor: profile names this binary" || bad "apparmor: profile names this binary"
|
||||
"$DOOS" attach --net none iso -- sh -c 'curl -sS -m 3 https://example.com >/dev/null 2>&1; [ $? -eq 6 ] && [ "$(tail -n +3 /proc/net/dev | cut -d: -f1 | tr -d " ")" = lo ]' >/dev/null 2>&1 && ok "none: curl exits 6, only lo" || bad "none: curl exits 6, only lo"
|
||||
"$DOOS" attach nets -- timeout 5 python3 -m http.server 8077 --bind 0.0.0.0 >/dev/null 2>&1 & sleep 2.5
|
||||
curl -sS -m 2 -o /dev/null http://127.0.0.1:8077/ >/dev/null 2>&1 && bad "slirp: no inbound path" || ok "slirp: no inbound path"
|
||||
wait %% 2>/dev/null
|
||||
"$DOOS" get_or_create --net pasta pa1 >/dev/null 2>&1; "$DOOS" get_or_create --net pasta pa2 >/dev/null 2>&1
|
||||
"$DOOS" attach pa1 -- timeout 25 python3 -m http.server 8066 --bind 0.0.0.0 >/dev/null 2>&1 &
|
||||
# pasta notices a newly bound port after a few seconds: poll instead of sleeping a fixed time
|
||||
c2c=000
|
||||
for _ in $(seq 1 18); do
|
||||
sleep 1
|
||||
c2c=$("$DOOS" attach pa2 -- curl -sS -m 2 -o /dev/null -w '%{http_code}' http://127.0.0.1:8066/ 2>/dev/null)
|
||||
[ "$c2c" = "200" ] && break
|
||||
done
|
||||
[ "$c2c" = "200" ] && ok "pasta: container to container via loopback" || bad "pasta: container to container via loopback"
|
||||
wait 2>/dev/null
|
||||
"$DOOS" delete --yes pa1 >/dev/null; "$DOOS" delete --yes pa2 >/dev/null; "$DOOS" delete --yes nets >/dev/null; "$DOOS" delete --yes iso >/dev/null
|
||||
|
||||
# ---- signatures and more catalog images
|
||||
echo "--- signatures"
|
||||
"$DOOS" image add alpine 2>&1 | grep -q "signature verified" && ok "alpine signature verified against pinned key" || bad "alpine signature verified against pinned key"
|
||||
"$DOOS" image delete --yes alpine >/dev/null
|
||||
shim=$(mktemp -d)
|
||||
mkdir -p "$DOOS_HOME/bin"
|
||||
printf '#!/bin/sh\ncase "$*" in\n *--with-colons*) echo "fpr:::::::::0482D84022F52DF1C4E7CD43293ACD0907D9495A:";;\n *--verify*) echo "[GNUPG:] GOODSIG AAAAAAAAAAAAAAAA M"; echo "[GNUPG:] VALIDSIG 1111111111111111111111111111111111111111 2026-01-01 1 0 4 0 1 10 00 1111111111111111111111111111111111111111";;\nesac\nexit 0\n' > $shim/gpg; chmod +x $shim/gpg
|
||||
rm -f "$DOOS_HOME/bin/gpg"; cp $shim/gpg "$DOOS_HOME/bin/gpg"
|
||||
"$DOOS" image add alpine >/dev/null 2>&1 && bad "signature by the wrong key refused" || ok "signature by the wrong key refused"
|
||||
[ ! -e "$DOOS_HOME/images/alpine" ] && ok "refused signature leaves no image" || bad "refused signature leaves no image"
|
||||
sed -i 's/GOODSIG AAAAAAAAAAAAAAAA M/BADSIG 293ACD0907D9495A N/' $shim/gpg
|
||||
cp $shim/gpg "$DOOS_HOME/bin/gpg"
|
||||
"$DOOS" image add alpine >/dev/null 2>&1 && bad "BADSIG refused" || ok "BADSIG refused"
|
||||
rm -rf $shim; rm -f "$DOOS_HOME/bin/gpg"
|
||||
"$DOOS" attach --image debian d1 -- sh -c '. /etc/os-release; echo $ID; apt-get update >/dev/null 2>&1; apt-get install -y --no-install-recommends cowsay >/dev/null 2>&1; dpkg --audit && /usr/games/cowsay ok >/dev/null' 2>/dev/null | grep -q debian && ok "debian image: apt install completes" || bad "debian image: apt install completes"
|
||||
"$DOOS" delete --yes d1 >/dev/null; "$DOOS" image delete --yes debian >/dev/null
|
||||
|
||||
echo "passed=$pass failed=$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# valgrind
|
||||
|
||||
**Scope:** [README](../README.md) · [AGENTS](../AGENTS.md) · [docs/RESEARCH](../docs/RESEARCH.md)
|
||||
|
||||
Memcheck run against a fixed, named set of `doos` invocations, to catch real memory bugs
|
||||
(invalid reads/writes, use-after-free, definite leaks) in doos's own code: namespace setup,
|
||||
mount handling, the CLI, the image store. Separate from `audit/` (named CVEs against other
|
||||
software) and from `tests/run.sh` (behavior, not memory safety).
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
make valgrind # builds build/doos first, then runs scripts/valgrind.sh against it
|
||||
scripts/valgrind.sh PATH # run directly against a specific binary, e.g. a copy with an
|
||||
# installed AppArmor profile (see README's apparmor section)
|
||||
```
|
||||
|
||||
Reports land in `build/valgrind-reports/<step>.log`, one per invocation, kept (not deleted)
|
||||
after the run so a failure can be inspected. Each step prints `ok` or `FAIL`; the run exits
|
||||
non-zero only if valgrind itself flagged something, never because `doos` exited non-zero for
|
||||
its own reasons (an expected `delete` dry-run notice, an AppArmor block on a binary with no
|
||||
profile installed, and so on, all exit non-zero as designed and are not memory bugs).
|
||||
|
||||
## What it checks
|
||||
|
||||
A fixed, capped list (`scripts/valgrind.sh`): `list`, `get_or_create`, `attach` (twice, to
|
||||
exercise both the create path and joining an already-running container), `image list`,
|
||||
`delete --yes`. All run with `--net none` and no image, so the check needs no network and no
|
||||
download; it is about doos's own allocation code, not about what happens after it execs the
|
||||
contained command.
|
||||
|
||||
## Suppressions (`doos.supp`)
|
||||
|
||||
Six specific, named, commented allocation sites, each captured with
|
||||
`valgrind --gen-suppressions=all` against a real run and explained in the file itself: the
|
||||
clone(2) child's 1 MiB stack buffer (lifetime tied to the child process, not a scope), and five
|
||||
"an error path constructed an exception or opened a stream, then the namespace child exited
|
||||
right after" patterns (`doMount`'s thrown `SysError`'s message string and the exception object
|
||||
itself, once for a mount failure inside `setupRoot` and once for the mount that runs directly in
|
||||
`containerInit` before it, such as an AppArmor-blocked `mount bind /`; plus an `ifstream` opened
|
||||
in `containerInit`'s cold path). None of these are "definitely lost"; valgrind calls all of them
|
||||
"possibly lost" because the process ends before the normal C++ unwind would run their
|
||||
destructors, which is expected for a namespace child that is about to disappear regardless. A
|
||||
genuinely new leak anywhere else is not covered by these exact stacks and still fails the check.
|
||||
|
||||
To add a suppression for a new, understood, one-time allocation: reproduce it, add
|
||||
`--gen-suppressions=all` to the `valgrind` invocation in `scripts/valgrind.sh` temporarily, copy
|
||||
the exact block `doos.supp` already prints into the file with a name and a comment explaining
|
||||
why it is not a real leak, then remove the temporary flag. Never add a suppression you cannot
|
||||
explain in one sentence.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- Anything that happens after doos execs the user's command: valgrind traces doos's own process
|
||||
tree, not an arbitrary program run inside the container.
|
||||
- Image download and extraction (`--image`): not in the fixed list, since it needs network and
|
||||
adds `curl`/`tar`/`gpg` subprocess noise that is not doos's own memory. Add a step to
|
||||
`scripts/valgrind.sh` following the same pattern if that code path needs a dedicated run.
|
||||
- `slirp4netns`/`pasta`: separate processes, not doos; see `audit/CVE-2020-7039` for how their
|
||||
memory safety is tracked instead (upstream version, not valgrind).
|
||||
|
||||
Back to [README](../README.md), [AGENTS](../AGENTS.md), [docs/RESEARCH](../docs/RESEARCH.md).
|
||||
@@ -0,0 +1,105 @@
|
||||
# Suppressions for `make valgrind` (see scripts/valgrind.sh and valgrind/README.md).
|
||||
#
|
||||
# Each entry here is a specific, named allocation site, not a blanket "ignore leaks" rule, and
|
||||
# each one is explained. They were captured with `valgrind --gen-suppressions=all` against a
|
||||
# real `doos attach` run and trimmed to the exact stack valgrind printed; a new, different leak
|
||||
# anywhere else still fails the check. Regenerate a candidate block with
|
||||
# `valgrind --gen-suppressions=all` if doos's internals change and one of these stops matching.
|
||||
|
||||
{
|
||||
doos-clone-child-stack
|
||||
# Sandbox::start allocates the 1 MiB stack clone(2) needs for the namespace child. Its
|
||||
# lifetime is tied to that child process, not to a scope in the parent, so it is never
|
||||
# `delete[]`d: freeing it after clone() returns would be safe only once the child has copied
|
||||
# what it needs, and doing that bookkeeping would add a second code path purely to silence
|
||||
# a tool, not to fix a real leak. Confirmed "possibly lost", never "definitely lost".
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:_Znwm
|
||||
fun:_ZN4doos7Sandbox5startERNS_8FileLockE
|
||||
fun:_ZN4doos7Sandbox3runEv
|
||||
fun:_ZN4doos3Cli9cmdAttachESt6vectorINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEESaIS7_EE
|
||||
fun:_ZN4doos3Cli3runEv
|
||||
fun:main
|
||||
}
|
||||
|
||||
{
|
||||
doos-mount-error-exception-object
|
||||
# doMount's cold (error) path throws a SysError built from a std::string; when the namespace
|
||||
# child terminates right after printing that error, the in-flight exception object itself
|
||||
# (allocated by __cxa_allocate_exception) has not yet been destroyed by the unwinder. This is
|
||||
# the standard valgrind artifact for a C++ exception that is still alive when the process
|
||||
# exits, not a missing `delete`.
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:malloc
|
||||
fun:__cxa_allocate_exception
|
||||
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||
fun:_ZN4doos12_GLOBAL__N_1L9setupRootERKNS_9ContainerERKNSt10filesystem7__cxx114pathE
|
||||
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||
fun:clone
|
||||
}
|
||||
|
||||
{
|
||||
doos-mount-error-message-string
|
||||
# Same doMount error path: the message string's internal buffer, built while constructing
|
||||
# the SysError/runtime_error that is about to be thrown. Same explanation as
|
||||
# doos-mount-error-exception-object, one frame earlier in the chain.
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:_Znwm
|
||||
fun:_ZNSs4_Rep9_S_createEmmRKSaIcE
|
||||
fun:_ZNSt12__cow_stringC2EPKcm
|
||||
fun:_ZNSt13runtime_errorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
|
||||
fun:_ZN4doos8SysErrorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEi
|
||||
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||
fun:_ZN4doos12_GLOBAL__N_1L9setupRootERKNS_9ContainerERKNSt10filesystem7__cxx114pathE
|
||||
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||
fun:clone
|
||||
}
|
||||
|
||||
{
|
||||
doos-mount-error-exception-object-top-level
|
||||
# Same explanation as doos-mount-error-exception-object, for the mount call that runs
|
||||
# directly in containerInit before setupRoot is reached (the very first "mount bind /",
|
||||
# which is what fails when AppArmor blocks user namespaces for this binary's path).
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:malloc
|
||||
fun:__cxa_allocate_exception
|
||||
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||
fun:clone
|
||||
}
|
||||
|
||||
{
|
||||
doos-mount-error-message-string-top-level
|
||||
# Same explanation as doos-mount-error-message-string, one level up: the mount call that
|
||||
# runs directly in containerInit before setupRoot is reached.
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:_Znwm
|
||||
fun:_ZNSs4_Rep9_S_createEmmRKSaIcE
|
||||
fun:_ZNSt12__cow_stringC2EPKcm
|
||||
fun:_ZNSt13runtime_errorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
|
||||
fun:_ZN4doos8SysErrorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEi
|
||||
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||
fun:clone
|
||||
}
|
||||
|
||||
{
|
||||
doos-containerinit-ifstream-buffer
|
||||
# containerInit's cold (error) path opens an ifstream (a /proc file, for a diagnostic) whose
|
||||
# libstdc++ filebuf buffer is still open when the namespace child exits right after. Same
|
||||
# "process exited before an RAII object on an about-to-unwind error path ran its destructor"
|
||||
# pattern as the two entries above, for a stream instead of an exception.
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:_Znam
|
||||
fun:_ZNSt13basic_filebufIcSt11char_traitsIcEE27_M_allocate_internal_bufferEv
|
||||
fun:_ZNSt13basic_filebufIcSt11char_traitsIcEE4openEPKcSt13_Ios_Openmode
|
||||
fun:_ZNSt14basic_ifstreamIcSt11char_traitsIcEEC1EPKcSt13_Ios_Openmode
|
||||
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv.cold
|
||||
fun:clone
|
||||
}
|
||||
Reference in New Issue
Block a user