7 Commits
Author SHA1 Message Date
retoorandClaude Sonnet 5 4c754b20da Add Gitea CI, fix size overflow and helper-path inconsistency from review2.md
ci / build (push) Successful in 54s
ci / valgrind (push) Successful in 1m42s
ci / test (push) Canceled after 0s
ci / audit (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-24 01:20:18 +02:00
retoorandClaude Sonnet 5 ded4e7f1a1 Mark gap G8 fixed in docs, matching 1.3.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-24 00:51:00 +02:00
retoorandClaude Sonnet 5 598a585eb8 Harden network helpers and compat-dir mounts per security review
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-24 00:45:27 +02:00
retoorandClaude Sonnet 5 4c4bb1a83a Fix commit trailer model name in release script
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-23 23:44:17 +02:00
retoorandClaude Sonnet 5.5 ed16dda2ec Add audit/ with 8 named CVE regression checks against doos
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 23:42:00 +02:00
retoorandClaude Sonnet 5.5 3f4e96937c Document security verdict versus Docker
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 23:26:57 +02:00
retoorandClaude Sonnet 5.5 97fa625a50 Charts and reproducible benchmarks, Makefile, expanded .gitignore
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:24:56 +02:00
93 changed files with 3238 additions and 123 deletions
+69
View File
@@ -0,0 +1,69 @@
# Gitea Actions. See docs/CI.md for what each job needs, the one-time runner setup, and the
# known gaps between Gitea Actions and GitHub Actions this file was written around (notably:
# timeout-minutes and concurrency are parsed but not enforced by Gitea as of the 1.25 docs, so
# the real cap on every step is the `timeout <seconds>` wrapped around it, not the YAML key).
name: ci
on:
push:
branches: ["**"]
pull_request:
jobs:
# Needs only a C++20 toolchain and CMake: runs on any Docker-mode act_runner.
build:
runs-on: ubuntu-latest
timeout-minutes: 10 # label only on Gitea (see docs/CI.md); the real cap is the `timeout` below
steps:
- uses: actions/checkout@v4
- name: install build dependencies
run: timeout 180 sh -c 'apt-get update && apt-get install -y --no-install-recommends build-essential cmake'
- name: build (-Werror)
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
- name: docs cross-link check
run: timeout 60 scripts/check-docs.sh
# No special runner privilege needed: scripts/valgrind.sh only fails on what valgrind itself
# reports, never on doos's own exit code, so it is still a real check even when this container's
# own policy blocks the user namespace doos tries to create (see docs/CI.md).
valgrind:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: install build and valgrind dependencies
run: timeout 180 sh -c 'apt-get update && apt-get install -y --no-install-recommends build-essential cmake valgrind'
- name: build
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
- name: memcheck against the fixed invocation set
run: timeout 300 scripts/valgrind.sh "$(pwd)/build/doos"
- name: keep the per-step logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: valgrind-reports
path: build/valgrind-reports/
# Real overlayfs, real user/mount/pid namespaces, real cgroup v2 limits, real downloads: needs
# the self-hosted host-mode runner set up per docs/CI.md (label "doos-host"), not a container.
test:
runs-on: [self-hosted, doos-host]
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: build
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
- name: end-to-end suite
run: timeout 600 tests/run.sh "$(pwd)/build/doos"
# Named CVE regression set against the binary this run just built. Same runner requirement as
# `test`, for the same reason (several checks attach a real container).
audit:
runs-on: [self-hosted, doos-host]
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: build
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
- name: CVE regression checks
run: timeout 300 make -C audit -j8 test DOOS="$(pwd)/build/doos"
+32
View File
@@ -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
+23 -4
View File
@@ -1,6 +1,6 @@
# 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)
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) · [CI](docs/CI.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
@@ -25,7 +25,7 @@ Usage: [README](README.md). History: [CHANGELOG](CHANGELOG.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/check-docs.sh` (the release script does too: every document must link to every other, every relative link must resolve).
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.
@@ -46,12 +46,16 @@ 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 |
| A feature, limit or measurement that appears in the comparison tables | `COMPARISON.md` (tables, the gaps list, the conclusion) |
| 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 |
| Anything above, in every release | `CHANGELOG.md` entry (`README.md` points at `VERSION`, nothing to edit there) |
| The CI workflow or what a job needs | `.gitea/workflows/ci.yml`, `docs/CI.md` |
Docs describe what the system does today. No roadmap text. A gap is listed next to
what would close it. A number in the docs needs a run behind it, else say it is unmeasured.
@@ -81,6 +85,21 @@ freeze (about 4 minutes in total, needs network, `pasta`, `gpg` and cgroup v2 de
before release. On Ubuntu 24.04+ the AppArmor profile in `apparmor/doos` must be
installed once for the binary being tested (`doos apparmor`; needs sudo, ask the user, do not do it yourself).
## CI
`.gitea/workflows/ci.yml` runs `build`, `valgrind`, `test` and `audit` on every push (Gitea Actions).
`test` and `audit` need the self-hosted host-mode runner described in [docs/CI.md](docs/CI.md) (real
namespaces, cgroup v2 delegation, the AppArmor profile); the workflow itself never runs `sudo`, same
rule as everywhere else. See that file before changing the workflow or what a job needs.
## 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
- State dir: `$DOOS_HOME`, else `$XDG_DATA_HOME/doos`, else `~/.local/share/doos`.
+37 -1
View File
@@ -1,6 +1,42 @@
# 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)
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) · [CI](docs/CI.md)
## 1.3.2 - 2026-10-24
- CI: `.gitea/workflows/ci.yml` (Gitea Actions) runs `build`, `valgrind`, `test` and `audit` on every push. `build`/`valgrind` run on any Docker-mode runner; `test`/`audit` need a self-hosted host-mode runner (real namespaces, cgroup v2 delegation, the AppArmor profile), documented with its one-time setup in the new [docs/CI.md](docs/CI.md). Every step wraps its own `timeout`, since Gitea Actions parses but does not enforce `timeout-minutes`/`concurrency` (checked against the 1.25 docs).
- Fixed, from [review2.md](review2.md): `parseSize` (`src/util.cpp`) overflowed silently on an oversized input (`--size 16777217t` was accepted as exactly `1.0T` instead of rejected); now checked before the shift and rejected. Tracked as gap G9 in [RESEARCH](docs/RESEARCH.md); regression case added to `tests/run.sh`.
- Fixed, from [review2.md](review2.md): `newuidmap`, `newgidmap`, `slirp4netns` and `pasta` were each pinned to one hardcoded `/usr/bin/...` path; they now go through `resolveBinary()` (state-dir override, then a fixed list of system directories), the same resolution `curl`/`tar`/`sha256sum`/`gpg` already used. Tracked as gap G10.
- Docs: the `setns`-before-hardening ordering invariant in `Sandbox::join` and `Sandbox::start` (seccomp denies `setns`, and a filter is inherited for life) is now a comment at both call sites, not only in reviewer heads. The hand-rolled Alpine/Debian catalog parsers are logged as an accepted trade-off, gap G11, rather than left undocumented.
- Added `LICENSE` (MIT).
- Docs: README's two overlapping, partly stale intro blocks (a leftover `currently 1.1.3` version line, a narrower duplicate usage example) collapsed into one accurate one.
## 1.3.1 - 2026-10-24
- Docs: gap G8 (`fsutil::mkdirs` following a planted symlink, [RESEARCH](docs/RESEARCH.md)) marked fixed, re-measured with `make -C audit/CVE-2021-30465 test` (PASS); removed from [COMPARISON](COMPARISON.md)'s gaps list. The fix itself shipped in 1.3.0 as part of the security-review hardening; this corrects the docs to match.
## 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.
+2 -1
View File
@@ -10,6 +10,7 @@ endif()
add_executable(doos
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)
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)
+52 -21
View File
@@ -1,6 +1,6 @@
# 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)
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) · [CI](docs/CI.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:
@@ -30,7 +30,7 @@ Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), P
| 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 | 552 KiB, 6 shared libraries, 3,445 lines of C++ [M] | CLI 29.9 MiB, plus `dockerd` and `containerd` [M][K] | same | 41 MiB [M] |
| 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 |
@@ -48,29 +48,45 @@ Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), P
## 3. Measurements
Method: trivial command (`true`) started 10 times in a row, average wall time per run, warm caches, the Alpine image already present for every tool, one machine, one run set. Scripts were ad-hoc shell loops and are not part of the repository tests, so treat the numbers as indicative, not as a benchmark.
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.
| Setup | Time per run |
|---|---|
| bubblewrap, host `/usr`, all namespaces unshared, no network | 9 ms |
| bubblewrap, host `/usr`, network shared | 8 ms |
| doos host mode, `--net none` | 14 ms |
| doos image Alpine, `--net none` | 16 ms |
| doos host mode, slirp | 54 ms |
| doos image Alpine, slirp | 57 ms |
| doos image Alpine, pasta | 65 ms |
| Podman, Alpine, `--network none` | 214 ms |
| Podman, Alpine, default network | 234 ms |
| Docker (rootful), Alpine, `--network none` | 590 ms |
| Docker (rootful), Alpine, default bridge | 456 ms |
![Horizontal bar chart of the time to start a trivial command for doos, bubblewrap, Podman and Docker, with and without network. doos is highlighted. Exact values are in the table below.](docs/charts/startup.svg)
<!-- 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.
- The Docker row "none" is slower than "default" in this run; the spread between runs was not measured, so do not read a ranking into it.
- 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.
![Horizontal bar chart of executable sizes on a logarithmic axis for bubblewrap, crun, doos, the docker CLI and podman. doos is highlighted. Exact values are in the table below.](docs/charts/size.svg)
<!-- 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 |
@@ -89,8 +105,8 @@ The Podman figure is this machine's accumulated state, not a like-for-like compa
**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: 552 KiB, 3,445 lines [M], one process tree, no background service, nothing listening.
- Instant: 14 to 65 ms against 456 to 590 ms for a rootful Docker run [M].
- 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/)].
@@ -135,7 +151,22 @@ Podman is the closest comparison, since both are daemonless and rootless.
- **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].
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.
### 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
@@ -155,7 +186,7 @@ These are the gaps that matter when comparing, each next to where it lives. They
## 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 552 KiB single binary, it starts in 14 to 65 ms [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.
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:
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 retoor <retoor@molodetz.nl>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+41
View File
@@ -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)
+15 -11
View File
@@ -1,6 +1,6 @@
# doos
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.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) · [CI](docs/CI.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.
@@ -13,16 +13,7 @@ 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.1.2). 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.
```
doos list
doos get_or_create [--net slirp|host|none] NAME
doos attach [--net slirp|host|none] NAME [-- CMD...]
doos delete [--yes] NAME
```
Version: see `VERSION`. Release history: [CHANGELOG.md](CHANGELOG.md). License: [MIT](LICENSE).
Build: `cmake -S . -B build && cmake --build build`. Test: `tests/run.sh`.
@@ -118,6 +109,7 @@ Notes, all measured:
| `--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 |
@@ -168,6 +160,15 @@ run with Docker's default capability set and a seccomp filter. Memory, pids, cpu
`--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/`. CI (`.gitea/workflows/ci.yml`) runs `build`, `valgrind`, `test` and `audit` on every push; see [docs/CI.md](docs/CI.md) for what each needs and the one-time runner setup.
## Project docs
| File | Content |
@@ -178,5 +179,8 @@ What is still not covered is listed at the end of `docs/RESEARCH.md`.
| [`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`) |
| [`docs/CI.md`](docs/CI.md) | Gitea Actions: what runs where, runner setup |
Not sure whether doos is the right tool? Read the conclusion of [COMPARISON](COMPARISON.md) first.
+3 -2
View File
@@ -1,6 +1,6 @@
# doos specification
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.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) · [CI](docs/CI.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).
@@ -26,7 +26,8 @@ Container options (accepted by `get_or_create` and `attach`):
| `--image NAME` | Start from an image instead of the host system. Missing catalog images are fetched on demand. | none (host mode) | 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). | default | 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 |
+1 -1
View File
@@ -1 +1 @@
1.1.2
1.4.0
+7
View File
@@ -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)"
+33
View File
@@ -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.
+15
View File
@@ -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"
+7
View File
@@ -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)"
+35
View File
@@ -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.
+25
View File
@@ -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"
+7
View File
@@ -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)"
+41
View File
@@ -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.
+18
View File
@@ -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"
+7
View File
@@ -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)"
+39
View File
@@ -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.
+24
View File
@@ -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"
+7
View File
@@ -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)"
+43
View File
@@ -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.
+31
View File
@@ -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)"
+7
View File
@@ -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)"
+42
View File
@@ -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.
+40
View File
@@ -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"
+7
View File
@@ -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)"
+40
View File
@@ -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.
+20
View File
@@ -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)"
+7
View File
@@ -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)"
+46
View File
@@ -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.
+29
View File
@@ -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"
+7
View File
@@ -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)"
+41
View File
@@ -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.
+27
View File
@@ -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})"
+7
View File
@@ -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)"
+47
View File
@@ -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.
+49
View File
@@ -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"
+7
View File
@@ -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)"
+39
View File
@@ -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.
+32
View File
@@ -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"
+7
View File
@@ -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)"
+37
View File
@@ -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.
+33
View File
@@ -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)"
+7
View File
@@ -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)"
+38
View File
@@ -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.
+26
View File
@@ -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"
+7
View File
@@ -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)"
+38
View File
@@ -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.
+26
View File
@@ -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
+7
View File
@@ -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)"
+38
View File
@@ -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.
+24
View File
@@ -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"
+7
View File
@@ -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)"
+33
View File
@@ -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.
+26
View File
@@ -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
+7
View File
@@ -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)"
+43
View File
@@ -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.
+36
View File
@@ -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"
+7
View File
@@ -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)"
+43
View File
@@ -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.
+47
View File
@@ -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"
+30
View File
@@ -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
+71
View File
@@ -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).
+1 -1
View File
@@ -1,6 +1,6 @@
# Architecture
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md)
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md) · [CI](CI.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).
+87
View File
@@ -0,0 +1,87 @@
# CI: Gitea Actions
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md) · [CI](CI.md)
The workflow is [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml). It needs two kinds of
runner, because the thing doos tests is the thing most CI containers are built to prevent: a
process creating its own user, mount and pid namespaces and an overlayfs mount.
## What runs where
| Job | Runner | Needs | Proves |
|---|---|---|---|
| `build` | any `docker://` runner (label `ubuntu-latest`) | a C++20 toolchain and CMake | the binary still compiles with `-Werror` and the documentation set is still fully cross-linked |
| `valgrind` | any `docker://` runner (label `ubuntu-latest`) | the above, plus `valgrind` | no new memory error or leak in doos's own code (see [valgrind/README.md](../valgrind/README.md)); this still gives a real answer even when the container's own AppArmor/seccomp policy blocks the user namespace doos tries to create, because `scripts/valgrind.sh` only fails on what valgrind itself reports, never on doos's own exit code |
| `test` | self-hosted, host mode, label `doos-host` | see below | the real end-to-end suite (`tests/run.sh`, currently 88 checks): real overlayfs, real namespaces, real downloads, real cgroup v2 limits |
| `audit` | self-hosted, host mode, label `doos-host` | see below | the named CVE regression set in [`audit/`](../audit/README.md) against the binary this run just built |
`build` and `valgrind` run in an ephemeral Docker-mode act_runner container and need nothing this
repository does not already ask of any build machine. `test` and `audit` need a real Linux host
(or VM) that act_runner reaches in *host mode* (a plain label, not a `docker://` one), because the
things under test are precisely the things a container runtime's own default policy restricts: an
unprivileged user namespace that creates an overlayfs mount, a systemd user manager with cgroup v2
delegated to it, and (on Ubuntu 24.04+) an AppArmor profile that permits user namespaces for this
one binary path. Running those two jobs inside another container would either need that container
privileged (defeating the point of isolating the runner) or would silently test less than it claims
to, the same vacuous-pass risk `review2.md` section 2 warns about for the audit script's own design.
## One-time host runner setup (by whoever administers the runner, not by the workflow)
The workflow never runs anything with `sudo`, on purpose (same rule as everywhere else in this
project: AppArmor and system changes are requested from a human, never done by an agent or a CI
job on its own). Before registering the `doos-host` runner:
```
sudo apt-get install -y build-essential cmake slirp4netns passt gnupg uidmap curl tar
# /etc/subuid and /etc/subgid must list the runner's own user; uidmap's postinst usually does this.
grep "^$(whoami):" /etc/subuid /etc/subgid
# cgroup v2 must be delegated to this user's systemd manager (loginctl enable-linger helps it
# survive without an interactive session):
loginctl enable-linger "$(whoami)"
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllers
# Build doos once at the exact path the runner will use, then install its AppArmor profile
# (needed on Ubuntu 24.04+; see README's Troubleshooting table for the symptom this fixes):
cmake -S . -B build && cmake --build build
build/doos apparmor | sudo tee /etc/apparmor.d/doos-runner >/dev/null
sudo apparmor_parser -r /etc/apparmor.d/doos-runner
```
Then register the runner itself (see
[Gitea's act_runner docs](https://docs.gitea.com/usage/actions/act-runner) for the current command
and token source):
```
./act_runner register --instance https://<your-gitea> --token <runner-token> \
--name doos-host --labels doos-host
./act_runner daemon
```
A `docker://`-labelled runner for `build` and `valgrind` needs no special setup; any act_runner in
its default Docker mode works (the Gitea docs' own suggested registration labels, such as
`ubuntu-latest:docker://gitea/runner-images:ubuntu-latest`, are enough).
## Known Gitea Actions gaps versus GitHub Actions (checked against the 1.25 docs)
- **`timeout-minutes` and `concurrency` are parsed but ignored.** The workflow still sets
`timeout-minutes` on every job for readability and for anyone who later runs the same file
under real GitHub Actions, but the actual, enforced cap in each job is a shell-level
`timeout <seconds>` wrapped around the one command that matters, consistent with this project's
own rule that every automated process gets a hard, enforced ceiling, not a cap that only works
if the runner happens to honor it.
- **`permissions` support is version-dependent** and GitHub-only scopes (`checks`, `id-token`,
`security-events`, `pages`, ...) are not supported at all. The workflow does not rely on it.
- **`uses: actions/checkout@v4`** is resolved the same way GitHub Actions resolves it (Gitea
Actions proxies to GitHub by default); an instance configured without internet access to
GitHub needs `https://gitea.com/actions/checkout` instead, or a plain `git clone` step.
## Running it without Gitea
Every job is one or two `make` targets with a `timeout` in front; none of it depends on Gitea
Actions syntax beyond `on`/`jobs`/`runs-on`/`steps`, so the same file works, unmodified, as a
GitHub Actions workflow (just move it to `.github/workflows/`) with the one exception that
`timeout-minutes` would then actually be enforced by the platform instead of being a label only
(harmless either way).
Back to [README](../README.md), [AGENTS](../AGENTS.md), [SPEC](../SPEC.md).
+14 -1
View File
@@ -1,9 +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)
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md) · [CI](CI.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.
@@ -57,6 +62,10 @@ removed afterwards. Rows marked "unmeasured" have no run behind them.
| 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` followed 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) | walk the path component by component from a pinned base fd with `mkdirat`/`openat(O_NOFOLLOW\|O_DIRECTORY)`, refusing any symlink instead of following it | **done in 1.3.0**, re-measured: `make -C audit/CVE-2021-30465 test` now PASSes | `fsutil::mkdirs` in `src/util.cpp` |
| G9 | `parseSize` left-shifted without an overflow check: `--size 16777217t` wrapped to exactly `1.0T` and was accepted instead of rejected | check `v > (UINT64_MAX >> shift)` before shifting, reject instead of wrapping | **done in 1.3.2**, reproduced before the fix, re-run after: rejected | `parseSize` in `src/util.cpp` |
| G10 | `newuidmap`/`newgidmap`/`slirp4netns`/`pasta` were pinned to a single hardcoded `/usr/bin/...` path, unlike `curl`/`tar`/`sha256sum`/`gpg` which go through `resolveBinary()`'s state-dir-override-then-fixed-dirs search; a distro installing them elsewhere would fail even though PATH-poisoning was never possible either way | route all four through `resolveBinary()`, same as the other helpers | **done in 1.3.2** | `idmap.cpp`, `network.cpp` |
| G11 | Alpine's `latest-releases.yaml` and Debian's `index.json` (embedded base64 manifest) are read with fixed string/byte-offset search, not a YAML/JSON library; a publisher reformatting either file breaks extraction with a thrown error (fails closed) rather than silently misreading it | none: a dependency to parse two small, publisher-controlled files is a worse trade than failing loudly when their format changes, given the project's own "fewer dependencies" rule | **accepted, not fixed**; this row exists so the trade-off is written down, not rediscovered | `Catalog::resolve` in `src/image.cpp` |
### G1: memory, cpu and pids limits (cgroup v2)
@@ -260,6 +269,10 @@ delegated with `cpuset cpu io memory pids`). The end-to-end suite passed 83 chec
| 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 |
![Horizontal bar chart of CPU-seconds used by four busy loops in one container, without a limit and with --cpus 0.5. The limited container is highlighted. Values are in the sentence below.](charts/cpu.svg)
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.
+121
View File
@@ -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
}
}
+48
View File
@@ -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

+292
View File
@@ -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()
+55
View File
@@ -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

+90
View File
@@ -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

+52
View File
@@ -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.
+150
View File
@@ -0,0 +1,150 @@
# doos: full review
Date: 2026-10-24 | Scope: all of `src/` (3,558 lines, read in full), `CMakeLists.txt`, `Makefile`,
`scripts/*.sh`, `tests/run.sh`, `audit/`, `valgrind/`, and every document (`README.md`, `SPEC.md`,
`docs/ARCHITECTURE.md`, `docs/RESEARCH.md`, `COMPARISON.md`, `AGENTS.md`, `CHANGELOG.md`).
Method: read every source file top to bottom (not a diff), ran the real e2e suite, the CVE audit,
and valgrind against the current binary, and tested two inputs by hand that looked suspicious while
reading. This is a second, independent pass; [`review.md`](review.md) is an earlier review whose
findings are checked against the current code, not reused.
**State at review time:** `v1.3.1`, working tree clean, `tests/run.sh` 88/88, `make audit` 18/18,
`make valgrind` 6/6 (see those tools for what they do and do not cover).
## 1. Summary
doos is a small, coherent, unusually well-specified codebase for what it is: a single C++20 binary
that builds a user-namespace container by hand (no runc, no libcontainer, no OCI anything) and
backs it with overlayfs, cgroup v2 and a seccomp denylist. The code reads like it was written by one
disciplined author following one set of rules consistently: every constant lives in `spec.hpp`, every
destructive path is capped, every external process is run from a fixed path, every error throws with
a message that says what to do about it. The project-level discipline (docs that state gaps next to
the code that closes them, a changelog per release, a `make audit`/`make valgrind` pair that produces
actual evidence instead of claims) is better than most projects an order of magnitude larger.
The flip side of "one author, one style" is that nothing here has been read by a second pair of eyes
until review.md and this review. I found one confirmed, reproducible bug (an integer overflow that
silently accepts a nonsensical `--size`) and a handful of smaller inconsistencies and gaps, listed
below. Nothing I found is a new security hole in the model doos is actually built on; the shared-kernel,
denylist-seccomp, unaudited-code risks are already disclosed in `COMPARISON.md` section 6 and
`docs/RESEARCH.md`, and I did not find reason to soften or harden that disclosure.
**Resolution (1.3.2, same day):** 2.1, 2.2 and 2.4 are fixed (see `CHANGELOG.md`); 2.3 is logged as
an accepted trade-off (gap G11 in `docs/RESEARCH.md`) rather than fixed; 2.5's CI half is resolved
by `.gitea/workflows/ci.yml` and `docs/CI.md`, and its LICENSE half by the new `LICENSE` file. The
findings below are left exactly as first written, for the record of what was found and how.
## 2. Findings
Ordered most to least severe. Each is something I read or reproduced, not a hypothetical.
### 2.1 [MEDIUM] `parseSize` silently overflows on an absurd input, instead of rejecting it
`src/util.cpp:54-71`. The function accepts up to 15 digits, then left-shifts by up to 40 bits with no
overflow check. `--size 16777217t` (16.7 million exabytes, obvious garbage) wraps around 64-bit
arithmetic to exactly `2^40`, which is `1.0T`, which is a *valid* size, so it is accepted silently:
```
$ doos get_or_create --size 16777217t sizetest2
created sizetest2
$ doos list
NAME STATE NET IMAGE SIZE AGE
sizetest2 stopped slirp host 1.0T 0s
```
Reproduced against `build/doos` at the time of this review. The same shift pattern exists for `k`/`m`/`g`
too, just with smaller multipliers that need more digits to wrap; `t` wraps with an 8-digit input. Every
caller does bound-check the *result*, which is why most overflowing inputs are still rejected (the
earlier example, `16777217t`+1 lands outside range and throws): it is specifically the inputs that
wrap into the valid `[64m, 1t]` window that go through unnoticed. Fix: check `v > (kMaxSizeBytes >>
shift)` before shifting, or reject more than 4-5 digits before a `t`/`g` suffix.
### 2.2 [LOW] Two different "no PATH lookup" strategies for external helpers, not one
`spec.hpp:52-53,75-76` hardcode `/usr/bin/newuidmap`, `/usr/bin/newgidmap`, `/usr/bin/slirp4netns`,
`/usr/bin/pasta` as single absolute paths. `resolveBinary()` (`src/util.cpp:397-417`) instead checks a
state-dir override then a list of six system directories, and is used for `curl`, `tar`, `sha256sum`
and `gpg`. Both avoid a `PATH`-poisoning attack (the actual goal, and both reach it), but the first
group breaks if a distribution installs those four tools somewhere other than exactly `/usr/bin` (for
example Fedora's `/usr/sbin` for some setuid helpers, or a non-standard prefix), while the second group
would still find them. This is not a security gap, it is an inconsistency that will show up as a
confusing "helper not found" on a distro the project has not been tested on, where the real fix (run
`resolveBinary()` for all five, or document the four as intentionally pinned) is a few lines either way.
### 2.3 [LOW] Catalog YAML/JSON parsing is hand-rolled string search, not a parser
`src/image.cpp:160-173` (Alpine's `latest-releases.yaml`) and `src/image.cpp:192-204` (Debian's
`index.json`, including a base64-embedded manifest) are read with `line.find(...)` and fixed byte
offsets, not a YAML/JSON library. This keeps the dependency count at zero, which fits the project's
"fewer moving parts" rule, and it is already tested against the real Alpine and Debian endpoints in
`tests/run.sh`. The risk is narrow but real: if either publisher reformats their file (reorders keys,
changes indentation, wraps a line), the parser fails closed with a thrown error (good: `docs/RESEARCH.md`
already calls this an accepted trade-off for Debian specifically) rather than silently extracting the
wrong artifact. Worth a one-line gap entry next to the code, same as the project's own convention asks.
### 2.4 [LOW] `Sandbox::join`'s ordering is a correctness invariant that is not stated in code
`src/sandbox.cpp:521-581`. Joining a running container does six `setns()` calls in a plain (not yet
seccomp-filtered) forked process, then forks again and *only there* calls `applyLimitsAndEnv`, which
installs the seccomp filter that denies `setns` (`src/hardening.cpp:184-186`). This is correct and
I could not find a bug in it, but the correctness depends entirely on hardening being applied after
the `setns` loop and in the right (inner) fork, a constraint that lives only in the order of statements,
not in a comment or an assertion. A future edit that moves `applyLimitsAndEnv` earlier "to simplify"
would silently break every second `attach`, and the e2e suite's "second attach joins" case would need
to be the thing that catches it rather than a comment telling the next editor not to do that.
### 2.5 [LOW] No CI, no license file
`find . -iname "*.yml"` turns up nothing; there is no `.github/workflows`, so the 88-check e2e suite,
`make audit` and `make valgrind` only run when someone remembers to run them by hand. There is also no
`LICENSE` file, which for a "commercial and scientific" project (the user's own standing rule) means
the legal default is "all rights reserved" by anyone who is not the author, worth a deliberate choice
rather than an omission.
### 2.6 [INFO] Everything else I checked was either already documented or held up
I specifically went looking for bugs in the capability-drop/seccomp-install ordering relative to
`execve`, in the overlayfs mount option construction, in the `--size`/`--memory`/`--pids`/`--cpus`
range validation (other than 2.1), in the container/image name validators against path traversal, in
`Store::remove`'s delete-outside-the-store guard, and in the symlink handling added for the PATH and
TOCTOU fixes (`src/util.cpp:329-354`, `src/sandbox.cpp:130-153`). All of it matched what the comments
claim, and the specific "mkdirs follows a symlink" class of bug this project itself found via
`audit/CVE-2021-30465` is closed the right way (per-component `openat(O_NOFOLLOW)`), not patched over.
## 3. Grades
| Category | Grade | Why |
|---|---|---|
| Architecture & code organization | 8.5/10 | One class per responsibility, matches `docs/ARCHITECTURE.md` exactly; every constant in one file; consistent error style throughout 3,558 lines. Loses points only for 2.4's unstated invariant. |
| Correctness & input validation | 7/10 | Careful everywhere except one real bug (2.1). CLI parsing, name validation, bounds checks on memory/pids/cpus are all solid; size parsing is the one spot that was not. |
| Isolation design (namespaces, overlayfs, mounts) | 9/10 | TOCTOU-safe symlink handling, `O_NOFOLLOW` directory creation, correct remount-flag preservation, correct setns/seccomp ordering. This is the part of the codebase that most clearly got the most attention, and it shows. |
| Resource controls (cgroup, size cap) | 8.5/10 | Adaptive scan-duty throttling, freeze/thaw hysteresis, whole-container OOM kill, hard caps on scan size and tree depth. Honestly scoped: documented as advisory without cgroup v2 delegation rather than pretending to enforce. |
| Supply chain (image verification) | 8/10 | Real signature verification against pinned fingerprints for two of three catalog sources, sha256 for all three, URL inputs refused without a hash. Loses points for the hand-rolled parsers (2.3) and for Debian having no publisher signature (disclosed, not doos's fault, but still the weakest link). |
| Hardening (capabilities, seccomp) | 8/10 | Matches Docker's default capability mask (measured, not assumed), denylist seccomp with the known evasions (x32, clone3, nested userns) explicitly closed. A denylist is inherently weaker than an allowlist by design, which the project says itself in `COMPARISON.md`; grade reflects execution of the chosen design, not the design choice. |
| Testing & verification tooling | 8.5/10 | 88 end-to-end checks through the real path (real overlayfs, real downloads, real cgroups), plus `audit/` (18 named CVEs with working reproducers) and `valgrind/` (6 checks, suppressions individually justified) are well above what a project this size usually has. One machine, one architecture, no CI (2.5) is the honest ceiling. |
| Documentation | 9/10 | Seven cross-linked documents that state what exists today, with gaps listed next to the code that closes them; generated numbers and charts instead of hand-typed ones; a changelog that actually matches releases. Among the best-documented small C++ projects I have reviewed. |
| Build & release automation | 8.5/10 | `scripts/release.sh` bumps, builds, tests, checks docs, commits and tags in one gated command and aborts on any failure; hardened compiler/linker flags. Loses half a point for no CI to run any of it automatically. |
| CLI/UX | 7.5/10 | Clear, narrow-terminal-friendly usage text; sensible fixed-vs-changeable option model; good error messages that say what to do next. `--i-understand` as a deliberate confirmation gate for reduced sandboxing is a good pattern. |
| Project hygiene | 6/10 | No LICENSE, no CI (2.5); otherwise clean (`.gitignore` is thorough, no stray build artifacts tracked, commit messages are consistent and attributed). |
**Overall: 8/10.** A small, honest, carefully built tool with one real bug found by testing it rather
than reading it, and a short, specific list of things worth fixing. Nothing found here changes the
conclusion already in `COMPARISON.md`: fine for your own tools, not a substitute for an audited,
multi-tenant-hardened runtime.
## 4. Recommended order of fixes
1. Fix the `parseSize` overflow (2.1): cheapest fix, only confirmed bug, trivial to test.
2. Add a one-line comment above `applyLimitsAndEnv`'s two call sites in `sandbox.cpp` stating the
ordering invariant from 2.4, so the next edit does not have to rediscover it the hard way.
3. Decide and document the helper-resolution split (2.2): either move the four pinned paths through
`resolveBinary()`, or add one line to `SPEC.md` saying why they are intentionally narrower.
4. Add a LICENSE file (2.5); pick whichever the user already uses elsewhere.
5. CI is the only item here that is genuinely more than a few lines of work (namespaces, cgroup v2
delegation and AppArmor all need to exist on the runner); worth a separate, explicit decision
rather than a drive-by addition.
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) ·
[RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) ·
[CHANGELOG](CHANGELOG.md) · [audit](audit/README.md) · [valgrind](valgrind/README.md) ·
[review.md](review.md) (earlier security-only review)
+94
View File
@@ -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
+6 -2
View File
@@ -4,7 +4,7 @@
set -u
cd "$(dirname "$0")/.."
DOCS=(README.md SPEC.md COMPARISON.md AGENTS.md CHANGELOG.md docs/ARCHITECTURE.md docs/RESEARCH.md)
DOCS=(README.md SPEC.md COMPARISON.md AGENTS.md CHANGELOG.md docs/ARCHITECTURE.md docs/RESEARCH.md docs/CI.md)
bad=0
fail() { echo "docs: $*"; bad=$((bad+1)); }
@@ -12,7 +12,11 @@ fail() { echo "docs: $*"; bad=$((bad+1)); }
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') # prompt.md: the original task statement, not documentation
done < <(find . -name '*.md' -not -path './build/*' -not -path './.git/*' -not -path './prompt.md' -not -path './audit/*' -not -path './review.md' -not -path './review2.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, review2.md: standalone one-off reviews, not part of the maintained doc set.
for f in "${DOCS[@]}"; do
[ -f "$f" ] || { fail "$f is missing"; continue; }
+2 -1
View File
@@ -22,6 +22,7 @@ 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
@@ -31,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"
+64
View File
@@ -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 ]
+23 -3
View File
@@ -23,6 +23,7 @@ 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;
};
@@ -59,6 +60,7 @@ Parsed parse(const std::vector<std::string>& rest, Allowed allow, std::size_t wa
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);
@@ -70,6 +72,21 @@ 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 + "'");
@@ -117,9 +134,10 @@ void Cli::usage(std::FILE* out) {
"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"
" --size SIZE writable-layer cap, 64m..1t, default 4g\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"
@@ -169,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;
}
@@ -178,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();
}
+4 -2
View File
@@ -225,10 +225,12 @@ std::uint64_t Container::usedBytes(std::size_t& skipped) const {
wr.reset();
if (rc != 0) throw std::runtime_error("size scan failed");
char buf[64] = {0};
if (::read(rd.get(), buf, sizeof buf - 1) <= 0) throw std::runtime_error("size scan returned nothing");
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;
std::sscanf(buf, "%llu %zu", &t, &sk);
if (std::sscanf(buf, "%llu %zu", &t, &sk) != 2) throw std::runtime_error("size scan returned malformed data");
skipped += sk;
return t;
}
+16 -14
View File
@@ -20,6 +20,16 @@ namespace doos {
namespace {
// Same trusted-path resolution as every other external helper (resolveBinary in util.cpp): the
// state dir's bin/ override, then a fixed list of system directories, never the caller's PATH.
std::string findBinary(const char* name) {
try {
return resolveBinary(name);
} catch (const std::exception&) {
return {};
}
}
void writeProcFile(pid_t pid, const char* file, const std::string& content) {
std::string path = "/proc/" + std::to_string(pid) + "/" + file;
int fd = ::open(path.c_str(), O_WRONLY | O_CLOEXEC);
@@ -30,16 +40,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 +71,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") && !findBinary("newuidmap").empty() &&
!findBinary("newgidmap").empty();
}
IdMode IdMap::best() { return subuidAvailable() ? IdMode::Subuid : IdMode::Single; }
@@ -86,15 +87,16 @@ 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)) {
std::string newuidmap = findBinary("newuidmap"), newgidmap = findBinary("newgidmap");
if (!u || !g || newuidmap.empty() || newgidmap.empty()) {
throw std::runtime_error("subuid mapping unavailable: need /etc/subuid, /etc/subgid, newuidmap and newgidmap "
"(install uidmap, or use --ids single)");
}
auto n = [](const SubRange& r) { return std::to_string(std::min<unsigned long>(r.count, spec::kMaxIdRange - 1)); };
std::string p = std::to_string(pid);
ProcResult a = runProcess({std::string(spec::kNewUidMap), p, "0", uid, "1", "1", std::to_string(u->start), n(*u)});
ProcResult a = runProcess({newuidmap, p, "0", uid, "1", "1", std::to_string(u->start), n(*u)});
if (a.code != 0) throw std::runtime_error("newuidmap failed (exit " + std::to_string(a.code) + ")");
ProcResult b = runProcess({std::string(spec::kNewGidMap), p, "0", gid, "1", "1", std::to_string(g->start), n(*g)});
ProcResult b = runProcess({newgidmap, p, "0", gid, "1", "1", std::to_string(g->start), n(*g)});
if (b.code != 0) throw std::runtime_error("newgidmap failed (exit " + std::to_string(b.code) + ")");
}
+37 -21
View File
@@ -31,17 +31,17 @@ 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({"curl", "--fail", "--silent", "--show-error", "--location", "--proto", "=https",
"--max-time", "60", "--max-filesize", std::to_string(spec::kMaxCaptureBytes),
"--output", out.string(), url});
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) + ")");
}
@@ -49,9 +49,19 @@ void fetchFile(const std::string& url, const fs::path& out) {
class TempDir {
public:
TempDir() {
std::string tmpl = (fs::temp_directory_path() / "doos-XXXXXX").string();
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;
@@ -74,7 +84,10 @@ std::string upper(std::string s) {
// 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) {
if (runProcess({"sh", "-c", "command -v gpg"}, true, true).code != 0) {
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;
}
@@ -83,14 +96,14 @@ bool verifySignature(const fs::path& data, const fs::path& sig, const std::strin
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) {
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);
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);
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);) {
@@ -289,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);
{
@@ -312,13 +328,13 @@ 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);
@@ -334,15 +350,15 @@ ImageInfo ImageStore::add(const ImageSource& src, IdMode ids) {
}
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) + ")");
+20 -4
View File
@@ -50,6 +50,12 @@ std::string Network::resolvConf(NetMode m) {
}
void SlirpSession::start(pid_t targetPid) {
std::string bin;
try {
bin = resolveBinary("slirp4netns");
} catch (const std::exception&) {
throw std::runtime_error("slirp4netns helper not found (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]);
@@ -58,14 +64,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);
}
@@ -92,14 +100,22 @@ void SlirpSession::stop() {
namespace doos {
void PastaSession::start(pid_t targetPid) {
std::string bin;
try {
bin = resolveBinary("pasta");
} catch (const std::exception&) {
throw std::runtime_error("pasta helper not found (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);
::execlp("pasta", "pasta", "--foreground", "--quiet", "--config-net", target.c_str(), static_cast<char*>(nullptr));
::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.
+30 -10
View File
@@ -130,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
@@ -269,6 +279,10 @@ int containerInit(void* arg) {
setupRoot(c, ctx->imageRoot);
if (::sethostname(c.name().c_str(), c.name().size()) != 0) throw SysError("sethostname");
if (Network::isolated(c.net())) bringLoopbackUp();
// Hardening (inside applyLimitsAndEnv) must be the last thing before the worker is forked
// and exec'd: everything this process still needs to do for itself (mounts, hostname,
// loopback) has to happen first, since its seccomp filter and dropped capabilities are
// inherited by every descendant from here on. See the matching note in Sandbox::join.
applyLimitsAndEnv(c);
pid_t worker = ::fork();
@@ -532,6 +546,11 @@ int Sandbox::join(pid_t initPid, FileLock& lock) {
if (cgroup) {
try { cgroup->addShell(::getpid()); } catch (const std::exception&) { /* limits only; keep going */ }
}
// Ordering invariant: this setns loop must run before Hardening::apply() (inside
// applyLimitsAndEnv below), never after. The seccomp filter it installs denies
// SYS_setns outright (hardening.cpp's deniedSyscalls), and a seccomp filter is
// inherited by every descendant for the rest of its life. Moving the hardening call
// earlier would make every second `attach` fail to join a running container.
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]);
@@ -539,7 +558,8 @@ int Sandbox::join(pid_t initPid, FileLock& lock) {
}
if (::fchdir(rootFd.get()) != 0 || ::chroot(".") != 0) throw SysError("chroot into container");
if (::chdir(std::string(spec::kHome).c_str()) != 0) throw SysError("chdir home");
// setns(pid) only affects children, so fork once more.
// setns(pid) only affects children, so fork once more. Hardening (which the setns
// loop above must predate) is applied only in this inner child, right before exec.
pid_t inner = ::fork();
if (inner < 0) throw SysError("fork");
if (inner == 0) {
+4 -4
View File
@@ -47,9 +47,9 @@ 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.
// newuidmap/newgidmap, like every other external helper, are found via resolveBinary() (util.cpp):
// the state dir's bin/ override, then a fixed list of system directories, never the caller's PATH.
constexpr std::uint32_t kMaxIdRange = 65536;
constexpr std::string_view kNewUidMap = "newuidmap";
constexpr std::string_view kNewGidMap = "newgidmap";
// Images
constexpr std::size_t kMaxImages = 16;
@@ -70,8 +70,8 @@ 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";
// Networking. slirp4netns and pasta are found via resolveBinary() (util.cpp), same as every other
// external helper: the state dir's bin/ override, then a fixed list of system directories.
constexpr std::string_view kSlirpDns = "10.0.2.3";
constexpr std::string_view kSlirpTapName = "tap0";
+78 -11
View File
@@ -12,6 +12,7 @@
#include <cstring>
#include <cctype>
#include <fstream>
#include <limits>
#include <unordered_set>
#include <sstream>
@@ -61,11 +62,19 @@ std::uint64_t parseSize(const std::string& text) {
if (unit == "b" || unit.empty()) {
} else {
if (unit.size() == 2 && unit[1] == 'b') unit.pop_back();
if (unit == "k") v <<= 10;
else if (unit == "m") v <<= 20;
else if (unit == "g") v <<= 30;
else if (unit == "t") v <<= 40;
int shift;
if (unit == "k") shift = 10;
else if (unit == "m") shift = 20;
else if (unit == "g") shift = 30;
else if (unit == "t") shift = 40;
else throw std::runtime_error("invalid size unit in '" + text + "' (use k, m, g or t)");
// A 64-bit shift wraps silently on a large enough value instead of producing a huge
// number; reject anything that would not survive the shift intact rather than let it
// land, by chance, inside whatever range the caller validates against next.
if (v > (std::numeric_limits<std::uint64_t>::max() >> shift)) {
throw std::runtime_error("size '" + text + "' is too large");
}
v <<= shift;
}
return v;
}
@@ -116,6 +125,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);
@@ -171,10 +182,11 @@ 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, 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();
@@ -188,9 +200,13 @@ void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_
++st.made;
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; }
@@ -205,6 +221,7 @@ void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_
++st.skipped;
}
}
if (dirfd >= 0) ::close(dirfd);
}
struct InodeHash {
@@ -262,9 +279,16 @@ SeedStats copyEntries(const fs::path& from, const fs::path& to, const std::vecto
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(fs::read_symlink(src), dst);
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;
@@ -312,9 +336,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);
}
@@ -358,4 +402,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
+4
View File
@@ -88,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
+12 -8
View File
@@ -50,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
@@ -71,6 +71,7 @@ outs() { "$DOOS" attach s -- sh -c "$1" 2>&1; }
check "ids fixed at creation" bash -c "! '$DOOS' get_or_create --ids subuid s1"
check "bad size rejected" bash -c "! '$DOOS' get_or_create --size 1x z1"
check "size below minimum rejected" bash -c "! '$DOOS' get_or_create --size 1m z2"
check "size overflow rejected, not silently wrapped" bash -c "! '$DOOS' get_or_create --size 16777217t z3"
"$DOOS" attach s -- 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 "host mode: apt install completes" || bad "host mode: apt install completes"
[ ! -e /usr/games/cowsay ] && ok "host untouched by apt install" || bad "host untouched by apt install"
"$DOOS" delete --yes s >/dev/null; "$DOOS" delete --yes s1 >/dev/null
@@ -150,9 +151,9 @@ h() { "$DOOS" attach lim -- python3 -c "$1" 2>&1; }
"$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 rx >/dev/null 2>&1
"$DOOS" attach rx -- 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 lim"
"$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
@@ -204,12 +205,15 @@ 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
PATH=$shim:$PATH "$DOOS" image add alpine >/dev/null 2>&1 && bad "signature by the wrong key refused" || ok "signature by the wrong key refused"
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
PATH=$shim:$PATH "$DOOS" image add alpine >/dev/null 2>&1 && bad "BADSIG refused" || ok "BADSIG refused"
rm -rf $shim
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
+62
View File
@@ -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).
+105
View File
@@ -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
}