diff --git a/AGENTS.md b/AGENTS.md index 9899f04..0829953 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,7 @@ Never hand-edit `VERSION` or create tags manually. Never skip the tests to relea | A measurement, or anything the charts show | `make bench` then `make charts` (rewrites `docs/charts/*.svg` and every `` 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` | @@ -86,9 +87,10 @@ installed once for the binary being tested (`doos apparmor`; needs sudo, ask the ## Make targets `make help` lists them: `build`, `test`, `audit` (named CVE regression checks against `build/doos`, -see [audit/README.md](audit/README.md)), `check-docs`, `bench` (re-measure into `docs/charts/bench.json`), +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/` and `docs/charts/`. +The Makefile is a thin wrapper; the logic lives in `CMakeLists.txt`, `scripts/`, `audit/`, `valgrind/` and `docs/charts/`. ## Environment notes diff --git a/CHANGELOG.md b/CHANGELOG.md index 912ab23..426b56e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ **Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) +## 1.3.0 - 2026-10-24 +- Security review applied ([review.md](review.md)): `slirp4netns`, `pasta`, `newuidmap` and `newgidmap` are never searched on `PATH`; they exec via absolute paths with availability checked up front, and the helpers run with a scrubbed `PATH`. +- Compat-directory mounts pin the parent through an `O_PATH` fd so a symlink swap between check and use cannot happen (TOCTOU); symlinks whose target cannot be pinned are not recreated, fail closed instead. +- `--i-understand` is now required for `--hardening relaxed` and `--net host`; reduced sandboxing is also reported loudly at attach time. +- Tests: the e2e suite covers all of the above (`passed=88 failed=0`). Docs: [RESEARCH](docs/RESEARCH.md), [SPEC](SPEC.md), [README](README.md) updated in the same change. + +## 1.2.1 - 2026-10-23 +- `scripts/release.sh`: commit trailer model name corrected (no functional change). + +## 1.2.0 - 2026-10-23 +- `audit/`: 10 more named, sourced CVEs (18 total): the overlayfs-in-unprivileged-userns family (`CVE-2021-3493`, `CVE-2023-2640`, `CVE-2023-32629`, `CVE-2015-1328`, directly doos's own mount on every attach), `CVE-2022-0492` (cgroup v1 release_agent; doos mounts no `/sys`), `CVE-2022-27650` (inheritable capabilities at container start), `CVE-2019-16884` and `CVE-2023-28642` (runc's `/proc` mount-target handling, tested against doos's own `/proc` mount), `CVE-2018-12020` (GnuPG SigSpoof, static check of the exact `gpg` invocation in `src/image.cpp`), `CVE-2020-7039` (libslirp memory-safety cluster, version check against `slirp4netns --version`). + ## 1.1.5 - 2026-10-23 - `audit/`: 8 named, sourced CVEs relevant to doos's own mechanisms (overlay writes, tar extraction, mount/directory creation, net namespace sharing, the mount-family seccomp denylist, kernel user-namespace exposure), each with its own `README.md`, `Makefile` and non-destructive `reproduce.sh` against `build/doos`. `make audit` runs them concurrently (`-j8`, recursive make); `make -C audit list|report` also available. Kernel memory-corruption CVEs (Dirty Pipe, Dirty COW, `legacy_parse_param`) are checked by kernel version only, no exploit code. - Found by the audit and disclosed: `fsutil::mkdirs` follows a symlink planted at a container's path, so a symlink under `$DOOS_HOME/containers/` 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. diff --git a/CMakeLists.txt b/CMakeLists.txt index 73e069c..6005451 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/Makefile b/Makefile index 88d0dab..cb82791 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ BUILD ?= build DOOS := $(BUILD)/doos -.PHONY: help build test audit check-docs bench charts docs release clean +.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')" @@ -19,6 +19,9 @@ test: build ## end-to-end suite (real downloads, needs network, pasta, gpg, cgro 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 diff --git a/README.md b/README.md index cce5b64..aade79c 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,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 | @@ -175,7 +176,7 @@ Details: [COMPARISON](COMPARISON.md) section 6.1. ## Development -`make help` lists the targets: `make build`, `make test` (end-to-end, needs network), `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/`. +`make help` lists the targets: `make build`, `make test` (end-to-end, needs network), `make audit` (named CVE regression checks, see [audit/README.md](audit/README.md)), `make valgrind` (memcheck against a fixed set of invocations, see [valgrind/README.md](valgrind/README.md)), `make bench` (re-measure startup, sizes and the cpu limit), `make charts` (rebuild the SVG charts and the generated numbers in the comparison), `make docs`, `make release KIND=patch MSG="..."`. Build and test details are in [AGENTS](AGENTS.md); the charts and how they are generated are in [COMPARISON](COMPARISON.md) section 3 and `docs/charts/`. ## Project docs @@ -188,5 +189,6 @@ Details: [COMPARISON](COMPARISON.md) section 6.1. | [`AGENTS.md`](AGENTS.md) | Rules for AI agents, release workflow | | [`CHANGELOG.md`](CHANGELOG.md) | Release history | | [`audit/README.md`](audit/README.md) | Named CVE regression checks run against `build/doos` (`make audit`) | +| [`valgrind/README.md`](valgrind/README.md) | Memcheck against a fixed set of invocations (`make valgrind`) | Not sure whether doos is the right tool? Read the conclusion of [COMPARISON](COMPARISON.md) first. diff --git a/SPEC.md b/SPEC.md index 2e607cc..5dc6076 100644 --- a/SPEC.md +++ b/SPEC.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 | diff --git a/VERSION b/VERSION index 6085e94..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.1 +1.3.0 diff --git a/audit/CVE-2015-1328/Makefile b/audit/CVE-2015-1328/Makefile new file mode 100644 index 0000000..1b25c77 --- /dev/null +++ b/audit/CVE-2015-1328/Makefile @@ -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)" diff --git a/audit/CVE-2015-1328/README.md b/audit/CVE-2015-1328/README.md new file mode 100644 index 0000000..99d7d01 --- /dev/null +++ b/audit/CVE-2015-1328/README.md @@ -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. diff --git a/audit/CVE-2015-1328/reproduce.sh b/audit/CVE-2015-1328/reproduce.sh new file mode 100755 index 0000000..ddb3ace --- /dev/null +++ b/audit/CVE-2015-1328/reproduce.sh @@ -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" diff --git a/audit/CVE-2018-12020/Makefile b/audit/CVE-2018-12020/Makefile new file mode 100644 index 0000000..f4cb5ff --- /dev/null +++ b/audit/CVE-2018-12020/Makefile @@ -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)" diff --git a/audit/CVE-2018-12020/README.md b/audit/CVE-2018-12020/README.md new file mode 100644 index 0000000..ddc31ec --- /dev/null +++ b/audit/CVE-2018-12020/README.md @@ -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. diff --git a/audit/CVE-2018-12020/reproduce.sh b/audit/CVE-2018-12020/reproduce.sh new file mode 100755 index 0000000..d245b61 --- /dev/null +++ b/audit/CVE-2018-12020/reproduce.sh @@ -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" diff --git a/audit/CVE-2019-16884/Makefile b/audit/CVE-2019-16884/Makefile new file mode 100644 index 0000000..95ee0f5 --- /dev/null +++ b/audit/CVE-2019-16884/Makefile @@ -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)" diff --git a/audit/CVE-2019-16884/README.md b/audit/CVE-2019-16884/README.md new file mode 100644 index 0000000..a48810a --- /dev/null +++ b/audit/CVE-2019-16884/README.md @@ -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. diff --git a/audit/CVE-2019-16884/reproduce.sh b/audit/CVE-2019-16884/reproduce.sh new file mode 100755 index 0000000..b4cbe7a --- /dev/null +++ b/audit/CVE-2019-16884/reproduce.sh @@ -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" diff --git a/audit/CVE-2020-7039/Makefile b/audit/CVE-2020-7039/Makefile new file mode 100644 index 0000000..10a8f0d --- /dev/null +++ b/audit/CVE-2020-7039/Makefile @@ -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)" diff --git a/audit/CVE-2020-7039/README.md b/audit/CVE-2020-7039/README.md new file mode 100644 index 0000000..22d2c60 --- /dev/null +++ b/audit/CVE-2020-7039/README.md @@ -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. diff --git a/audit/CVE-2020-7039/reproduce.sh b/audit/CVE-2020-7039/reproduce.sh new file mode 100755 index 0000000..6284743 --- /dev/null +++ b/audit/CVE-2020-7039/reproduce.sh @@ -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)" diff --git a/audit/CVE-2021-3493/Makefile b/audit/CVE-2021-3493/Makefile new file mode 100644 index 0000000..e0a0213 --- /dev/null +++ b/audit/CVE-2021-3493/Makefile @@ -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)" diff --git a/audit/CVE-2021-3493/README.md b/audit/CVE-2021-3493/README.md new file mode 100644 index 0000000..1e3171d --- /dev/null +++ b/audit/CVE-2021-3493/README.md @@ -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. diff --git a/audit/CVE-2021-3493/reproduce.sh b/audit/CVE-2021-3493/reproduce.sh new file mode 100755 index 0000000..9d5025f --- /dev/null +++ b/audit/CVE-2021-3493/reproduce.sh @@ -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})" diff --git a/audit/CVE-2022-0492/Makefile b/audit/CVE-2022-0492/Makefile new file mode 100644 index 0000000..0a12937 --- /dev/null +++ b/audit/CVE-2022-0492/Makefile @@ -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)" diff --git a/audit/CVE-2022-0492/README.md b/audit/CVE-2022-0492/README.md new file mode 100644 index 0000000..a29f14e --- /dev/null +++ b/audit/CVE-2022-0492/README.md @@ -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. diff --git a/audit/CVE-2022-0492/reproduce.sh b/audit/CVE-2022-0492/reproduce.sh new file mode 100755 index 0000000..5e1a425 --- /dev/null +++ b/audit/CVE-2022-0492/reproduce.sh @@ -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" diff --git a/audit/CVE-2022-27650/Makefile b/audit/CVE-2022-27650/Makefile new file mode 100644 index 0000000..12ee623 --- /dev/null +++ b/audit/CVE-2022-27650/Makefile @@ -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)" diff --git a/audit/CVE-2022-27650/README.md b/audit/CVE-2022-27650/README.md new file mode 100644 index 0000000..ff6d435 --- /dev/null +++ b/audit/CVE-2022-27650/README.md @@ -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. diff --git a/audit/CVE-2022-27650/reproduce.sh b/audit/CVE-2022-27650/reproduce.sh new file mode 100755 index 0000000..a18c995 --- /dev/null +++ b/audit/CVE-2022-27650/reproduce.sh @@ -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" diff --git a/audit/CVE-2023-2640/Makefile b/audit/CVE-2023-2640/Makefile new file mode 100644 index 0000000..2378db0 --- /dev/null +++ b/audit/CVE-2023-2640/Makefile @@ -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)" diff --git a/audit/CVE-2023-2640/README.md b/audit/CVE-2023-2640/README.md new file mode 100644 index 0000000..2561c06 --- /dev/null +++ b/audit/CVE-2023-2640/README.md @@ -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. diff --git a/audit/CVE-2023-2640/reproduce.sh b/audit/CVE-2023-2640/reproduce.sh new file mode 100755 index 0000000..7aa54e6 --- /dev/null +++ b/audit/CVE-2023-2640/reproduce.sh @@ -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 diff --git a/audit/CVE-2023-28642/Makefile b/audit/CVE-2023-28642/Makefile new file mode 100644 index 0000000..234f3b9 --- /dev/null +++ b/audit/CVE-2023-28642/Makefile @@ -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)" diff --git a/audit/CVE-2023-28642/README.md b/audit/CVE-2023-28642/README.md new file mode 100644 index 0000000..87266cc --- /dev/null +++ b/audit/CVE-2023-28642/README.md @@ -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. diff --git a/audit/CVE-2023-28642/reproduce.sh b/audit/CVE-2023-28642/reproduce.sh new file mode 100755 index 0000000..e7b3ad6 --- /dev/null +++ b/audit/CVE-2023-28642/reproduce.sh @@ -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" diff --git a/audit/CVE-2023-32629/Makefile b/audit/CVE-2023-32629/Makefile new file mode 100644 index 0000000..4f12678 --- /dev/null +++ b/audit/CVE-2023-32629/Makefile @@ -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)" diff --git a/audit/CVE-2023-32629/README.md b/audit/CVE-2023-32629/README.md new file mode 100644 index 0000000..8a2c0b7 --- /dev/null +++ b/audit/CVE-2023-32629/README.md @@ -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. diff --git a/audit/CVE-2023-32629/reproduce.sh b/audit/CVE-2023-32629/reproduce.sh new file mode 100755 index 0000000..4649374 --- /dev/null +++ b/audit/CVE-2023-32629/reproduce.sh @@ -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 diff --git a/audit/README.md b/audit/README.md index 25b632f..e77ac1a 100644 --- a/audit/README.md +++ b/audit/README.md @@ -2,7 +2,7 @@ **Scope:** [README](../README.md) · [AGENTS](../AGENTS.md) · [COMPARISON](../COMPARISON.md) · [docs/RESEARCH](../docs/RESEARCH.md) -A fixed, named set of 8 known CVEs relevant to the mechanisms doos uses (user namespaces, +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 @@ -57,5 +57,15 @@ here is a weaponized exploit for an unrelated target. | [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). diff --git a/docs/RESEARCH.md b/docs/RESEARCH.md index 24dec00..f87fa77 100644 --- a/docs/RESEARCH.md +++ b/docs/RESEARCH.md @@ -4,6 +4,11 @@ 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. diff --git a/scripts/check-docs.sh b/scripts/check-docs.sh index 9f9ef64..6bd2145 100755 --- a/scripts/check-docs.sh +++ b/scripts/check-docs.sh @@ -12,10 +12,10 @@ 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' -not -path './audit/*' -not -path './review.md') +done < <(find . -name '*.md' -not -path './build/*' -not -path './.git/*' -not -path './prompt.md' -not -path './audit/*' -not -path './review.md' -not -path './valgrind/*') # prompt.md: the original task statement, not documentation. -# audit/*: a separate, self-contained doc set (one README per CVE check); audit/README.md is -# linked from the main docs but is not forced into their full cross-link mesh. +# audit/*, valgrind/*: separate, self-contained doc sets (one README per CVE check, one for the +# valgrind setup); linked from the main docs but not forced into their full cross-link mesh. # review.md: a standalone external security review, not part of the maintained doc set. for f in "${DOCS[@]}"; do diff --git a/scripts/valgrind.sh b/scripts/valgrind.sh new file mode 100755 index 0000000..5930047 --- /dev/null +++ b/scripts/valgrind.sh @@ -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 ] diff --git a/src/cli.cpp b/src/cli.cpp index ed5f36d..68d0887 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -23,6 +23,7 @@ struct Parsed { Options opts; std::optional sha256; bool yes = false; + bool iUnderstand = false; // --i-understand, required for relaxed / --net host std::vector positional; // up to maxPositional, the rest lands in tail std::vector tail; }; @@ -59,6 +60,7 @@ Parsed parse(const std::vector& 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& 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 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 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(); } diff --git a/src/container.cpp b/src/container.cpp index 36a1445..1d5c28e 100644 --- a/src/container.cpp +++ b/src/container.cpp @@ -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; } diff --git a/src/idmap.cpp b/src/idmap.cpp index 3eff5fd..c1ea749 100644 --- a/src/idmap.cpp +++ b/src/idmap.cpp @@ -30,16 +30,6 @@ void writeProcFile(pid_t pid, const char* file, const std::string& content) { } } -bool onPath(std::string_view bin) { - const char* path = std::getenv("PATH"); - std::stringstream ss(path ? path : "/usr/bin:/bin"); - for (std::string dir; std::getline(ss, dir, ':');) { - std::string full = dir + "/" + std::string(bin); - if (::access(full.c_str(), X_OK) == 0) return true; - } - return false; -} - } // namespace IdMode IdMap::parse(const std::string& s) { @@ -71,7 +61,8 @@ std::optional IdMap::range(const char* file) { } bool IdMap::subuidAvailable() { - return range("/etc/subuid") && range("/etc/subgid") && onPath(spec::kNewUidMap) && onPath(spec::kNewGidMap); + return range("/etc/subuid") && range("/etc/subgid") && ::access(std::string(spec::kNewUidMap).c_str(), X_OK) == 0 && + ::access(std::string(spec::kNewGidMap).c_str(), X_OK) == 0; } IdMode IdMap::best() { return subuidAvailable() ? IdMode::Subuid : IdMode::Single; } @@ -86,7 +77,7 @@ void IdMap::apply(pid_t pid, IdMode mode) { } auto u = range("/etc/subuid"); auto g = range("/etc/subgid"); - if (!u || !g || !onPath(spec::kNewUidMap) || !onPath(spec::kNewGidMap)) { + if (!u || !g || ::access(std::string(spec::kNewUidMap).c_str(), X_OK) != 0 || ::access(std::string(spec::kNewGidMap).c_str(), X_OK) != 0) { throw std::runtime_error("subuid mapping unavailable: need /etc/subuid, /etc/subgid, newuidmap and newgidmap " "(install uidmap, or use --ids single)"); } diff --git a/src/image.cpp b/src/image.cpp index 52f6260..f1c64c0 100644 --- a/src/image.cpp +++ b/src/image.cpp @@ -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 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 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 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) + ")"); diff --git a/src/network.cpp b/src/network.cpp index 77609df..6c8ba2a 100644 --- a/src/network.cpp +++ b/src/network.cpp @@ -50,6 +50,9 @@ std::string Network::resolvConf(NetMode m) { } void SlirpSession::start(pid_t targetPid) { + const std::string bin(spec::kSlirpBinary); + if (::access(bin.c_str(), X_OK) != 0) throw std::runtime_error("slirp4netns helper not found at " + bin + + " (install slirp4netns or use --net none)"); int ready[2]; if (::pipe2(ready, O_CLOEXEC) != 0) throw SysError("pipe"); Fd readEnd(ready[0]), writeEnd(ready[1]); @@ -58,14 +61,16 @@ void SlirpSession::start(pid_t targetPid) { if (pid_ < 0) throw SysError("fork slirp4netns"); if (pid_ == 0) { ::prctl(PR_SET_PDEATHSIG, SIGKILL); + // Do not trust the caller's PATH: only system directories may be searched by anything the helper runs. + ::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1); int nul = ::open("/dev/null", O_WRONLY); if (nul >= 0) { ::dup2(nul, 1); ::dup2(nul, 2); } int wfd = ::dup(writeEnd.get()); // dup clears CLOEXEC std::string readyArg = "--ready-fd=" + std::to_string(wfd); std::string target = std::to_string(targetPid); - std::string bin(spec::kSlirpBinary), tap(spec::kSlirpTapName); - ::execlp(bin.c_str(), bin.c_str(), "--configure", "--mtu=65520", "--disable-host-loopback", - readyArg.c_str(), target.c_str(), tap.c_str(), static_cast(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(nullptr)); ::dprintf(2, "doos: cannot exec %s: %s\n", bin.c_str(), std::strerror(errno)); ::_exit(127); } @@ -92,14 +97,19 @@ void SlirpSession::stop() { namespace doos { void PastaSession::start(pid_t targetPid) { + const std::string bin(spec::kPastaBinary); + if (::access(bin.c_str(), X_OK) != 0) throw std::runtime_error("pasta helper not found at " + bin + + " (install pasta or use --net none)"); pid_ = ::fork(); if (pid_ < 0) throw SysError("fork pasta"); if (pid_ == 0) { ::prctl(PR_SET_PDEATHSIG, SIGKILL); + ::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1); int nul = ::open("/dev/null", O_WRONLY); if (nul >= 0) { ::dup2(nul, 1); ::dup2(nul, 2); } std::string target = std::to_string(targetPid); - ::execlp("pasta", "pasta", "--foreground", "--quiet", "--config-net", target.c_str(), static_cast(nullptr)); + ::execl(bin.c_str(), bin.c_str(), "--foreground", "--quiet", "--config-net", target.c_str(), + static_cast(nullptr)); ::_exit(127); } // pasta has no ready fd: wait until the target namespace has an interface besides lo. diff --git a/src/sandbox.cpp b/src/sandbox.cpp index a97fbd9..cda1fb8 100644 --- a/src/sandbox.cpp +++ b/src/sandbox.cpp @@ -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 diff --git a/src/spec.hpp b/src/spec.hpp index 4dcce7e..f40659f 100644 --- a/src/spec.hpp +++ b/src/spec.hpp @@ -47,9 +47,10 @@ constexpr std::size_t kMaxEtcCopyFiles = 50000; constexpr std::uint64_t kMaxEtcCopyBytes = 256ULL << 20; // Id mapping: container ids 0..kMaxIdRange. 0 maps to the invoking user, the rest to the subuid range. +// Helpers are invoked by absolute path (no PATH lookup) to avoid RCE via a poisoned PATH. constexpr std::uint32_t kMaxIdRange = 65536; -constexpr std::string_view kNewUidMap = "newuidmap"; -constexpr std::string_view kNewGidMap = "newgidmap"; +constexpr std::string_view kNewUidMap = "/usr/bin/newuidmap"; +constexpr std::string_view kNewGidMap = "/usr/bin/newgidmap"; // Images constexpr std::size_t kMaxImages = 16; @@ -71,7 +72,8 @@ constexpr std::string_view kUbuntuKeyUrl = constexpr std::string_view kUbuntuKeyFpr = "843938DF228D22F7B3742BC0D94AA3F0EFE21092"; // Networking -constexpr std::string_view kSlirpBinary = "slirp4netns"; +constexpr std::string_view kSlirpBinary = "/usr/bin/slirp4netns"; +constexpr std::string_view kPastaBinary = "/usr/bin/pasta"; constexpr std::string_view kSlirpDns = "10.0.2.3"; constexpr std::string_view kSlirpTapName = "tap0"; diff --git a/src/util.cpp b/src/util.cpp index d8e004d..23cf229 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -116,6 +116,8 @@ ProcResult runProcess(const std::vector& 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 a; for (const auto& x : argv) a.push_back(const_cast(x.c_str())); a.push_back(nullptr); @@ -171,10 +173,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 +191,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(n)), dst); ++st.made; } else if (S_ISREG(sb.st_mode)) { if (::access(src.c_str(), R_OK) != 0) { ++st.skipped; continue; } @@ -205,6 +212,7 @@ void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_ ++st.skipped; } } + if (dirfd >= 0) ::close(dirfd); } struct InodeHash { @@ -262,9 +270,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(n)), dst); ++total.made; } else if (S_ISREG(sb.st_mode) && ::access(src.c_str(), R_OK) == 0) { std::error_code ec; @@ -312,9 +327,29 @@ std::uint64_t diskUsage(const fs::path& p, std::size_t& skipped) { } void mkdirs(const fs::path& p, mode_t mode) { - std::error_code ec; - fs::create_directories(p, ec); - if (ec) throw std::runtime_error("mkdir " + p.string() + ": " + ec.message()); + if (p.empty()) return; + std::vector 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 +393,27 @@ std::size_t countEntries(const fs::path& p, std::size_t cap) { } } // namespace fsutil + +std::string resolveBinary(const std::string& name) { + if (name.find('/') != std::string::npos) return name; + // The state dir's bin/ wins: it is the trusted configuration location (0700, like /etc) and lets tests + // or a user override a helper without touching system directories. Never the caller's PATH. + std::string base; + if (const char* h = ::getenv("DOOS_HOME"); h && *h) base = h; + else if (const char* x = ::getenv("XDG_DATA_HOME"); x && *x) base = std::string(x) + "/doos"; + else if (const char* home = ::getenv("HOME"); home && *home) base = std::string(home) + "/.local/share/doos"; + if (!base.empty()) { + std::string p = base + "/bin/" + name; + struct stat st; + if (::stat(p.c_str(), &st) == 0 && S_ISREG(st.st_mode) && ::access(p.c_str(), X_OK) == 0) return p; + } + for (const char* dir : {"/usr/local/bin", "/usr/bin", "/usr/local/sbin", "/usr/sbin", "/bin", "/sbin"}) { + std::string p = std::string(dir) + "/" + name; + struct stat st; + if (::stat(p.c_str(), &st) == 0 && S_ISREG(st.st_mode) && ::access(p.c_str(), X_OK) == 0) return p; + } + throw std::runtime_error("required helper '" + name + + "' not found in the state dir's bin/, /usr/local/bin, /usr/bin, /usr/sbin, /bin or /sbin (install it)"); +} + } // namespace doos diff --git a/src/util.hpp b/src/util.hpp index 9c385ae..e5dd71b 100644 --- a/src/util.hpp +++ b/src/util.hpp @@ -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 diff --git a/tests/run.sh b/tests/run.sh index f4a9a1a..aaf943e 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -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 @@ -150,9 +150,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 +204,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 diff --git a/valgrind/README.md b/valgrind/README.md new file mode 100644 index 0000000..87a831a --- /dev/null +++ b/valgrind/README.md @@ -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/.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). diff --git a/valgrind/doos.supp b/valgrind/doos.supp new file mode 100644 index 0000000..90e0e64 --- /dev/null +++ b/valgrind/doos.supp @@ -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 +}