Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c754b20da | ||
|
|
ded4e7f1a1 | ||
|
|
598a585eb8 |
@@ -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"
|
||||
@@ -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
|
||||
@@ -49,11 +49,13 @@ 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 `<!--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.
|
||||
@@ -83,12 +85,20 @@ 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)), `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
|
||||
|
||||
|
||||
+24
-1
@@ -1,6 +1,29 @@
|
||||
# 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.
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+1
-2
@@ -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:
|
||||
|
||||
@@ -183,7 +183,6 @@ These are the gaps that matter when comparing, each next to where it lives. They
|
||||
| Linux x86_64 tested only | `src/hardening.cpp` guards aarch64, never run there |
|
||||
| seccomp is a denylist | `deniedSyscalls` in `src/hardening.cpp` |
|
||||
| Test coverage: one machine | `tests/run.sh` |
|
||||
| `fsutil::mkdirs` follows a planted symlink at a container's path, so a symlink at `$DOOS_HOME/containers/<name>` redirects where that container's state is written (low severity on a single-user `$DOOS_HOME`; found by [`audit/CVE-2021-30465`](audit/CVE-2021-30465/README.md)) | `fsutil::mkdirs` in `src/util.cpp` |
|
||||
|
||||
## 8. Conclusion
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.3). Release history: `CHANGELOG.md`.
|
||||
|
||||
User-level containers in C++20: overlayfs over the host's `/usr`, `/etc`, `/opt`, user/mount/pid namespaces, networking through slirp4netns. No root at runtime.
|
||||
|
||||
```
|
||||
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 |
|
||||
@@ -175,7 +167,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/`. 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
|
||||
|
||||
@@ -188,5 +180,7 @@ 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`) |
|
||||
| [`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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2015-1328: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,33 @@
|
||||
# CVE-2015-1328: overlayfs missing permission check on file creation in the upper directory (unprivileged userns)
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
Exploited in the wild (CISA KEV). Fixed: Ubuntu 14.10 3.16.0-41.55, 15.04 3.19.0-21.21, 14.04 3.13.0-55.92.
|
||||
|
||||
## Source
|
||||
|
||||
- [Ubuntu advisory](https://ubuntu.com/security/CVE-2015-1328)
|
||||
- [oss-security disclosure thread](https://openwall.com/lists/oss-security/2015/06/16/7)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
The oldest of the overlayfs-in-userns bugs in this audit: overlayfs did not check
|
||||
permissions when creating files in the upper directory, reachable from CONFIG_USER_NS=y
|
||||
kernels where overlayfs carries FS_USERNS_MOUNT, the default on the Ubuntu releases of
|
||||
that era and the same precondition doos depends on today. Kept in the list to complete
|
||||
the history of this bug class, not because current kernels are expected to be exposed.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version check against the three listed fixed builds; on any host from the last
|
||||
several years this is expected to PASS trivially, included to complete the category.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Same distro-backport blind spot as the newer overlayfs entries: a version string alone
|
||||
cannot prove a specific vendor backport is present.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2015-1328: kernel version check against the three listed fixed builds. No exploit code.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
full=$(uname -r)
|
||||
major=$(echo "$full" | grep -oE '^[0-9]+')
|
||||
[ -n "$major" ] || skip "could not parse kernel version from '$full'"
|
||||
|
||||
if [ "$major" -ge 4 ]; then
|
||||
pass "kernel $full, major version 4 or newer, long past this 3.x-era fix"
|
||||
fi
|
||||
skip "kernel $full is on the 3.x branch this bug targeted; this check cannot tell a patched 3.13.0-55.92/3.16.0-41.55/3.19.0-21.21 point release from an unpatched one, check the Ubuntu advisory for the exact build"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2018-12020: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,41 @@
|
||||
# CVE-2018-12020: GnuPG "SigSpoof": signature/decryption status spoofed via unescaped filename on --status-fd 2
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
Disclosed June 2018. Fixed in GnuPG 2.2.8 / 1.4.23.
|
||||
|
||||
## Source
|
||||
|
||||
- [Help Net Security writeup](https://www.helpnetsecurity.com/2018/06/15/cve-2018-12020-digital-signature-spoofing/)
|
||||
- [CERT-EU advisory](https://cert.europa.eu/publications/security-advisories/2018-016/markdown)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
Applications that ran GnuPG with `--status-fd 2` (merging machine-readable status lines
|
||||
into stderr) and GnuPG's verbose mode (which echoes the input file's embedded name to
|
||||
stderr without escaping newlines) could be fed a crafted message whose filename field
|
||||
injected fake status lines, spoofing a GOODSIG result for an unsigned or badly signed
|
||||
input. doos calls `gpg` to verify Alpine's and Ubuntu's release signatures
|
||||
(`verifySignature` in `src/image.cpp`), so getting this invocation right is the one place
|
||||
a bug in this class would matter: a spoofed GOODSIG here would make doos trust and
|
||||
extract an unverified image.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Reads the exact `gpg` invocations in `src/image.cpp` for the two properties that defeat
|
||||
this bug class: `--status-fd` must be a dedicated fd (not `2`, which would merge it with
|
||||
stderr), and `--verbose` must not be passed. Source inspection, not a dynamic exploit
|
||||
attempt: crafting a message that reproduces GnuPG's own newline-escaping bug would be
|
||||
testing GnuPG, not doos, and depends on an unpatched GnuPG the audit does not control.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
A static check: it verifies the invocation in the source tree the check runs against,
|
||||
not the behavior of whatever `gpg` binary happens to be installed. It also does not
|
||||
prove there is no other way to smuggle data into the fd doos does read; it confirms the
|
||||
one specific, named defeat (`--status-fd 2` plus `--verbose`) is not present.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2018-12020 (SigSpoof): src/image.cpp's gpg invocations must not combine --status-fd 2
|
||||
# (merged with stderr) with --verbose (which echoes the unescaped filename to stderr).
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
SRC="$(cd "$(dirname "$0")/../.." && pwd)/src/image.cpp"
|
||||
[ -f "$SRC" ] || skip "src/image.cpp not found at $SRC"
|
||||
|
||||
if grep -E 'gpg' "$SRC" | grep -qE -- '--status-fd["'"'"']?,?\s*["'"'"']?2\b'; then
|
||||
fail "$SRC passes --status-fd 2 to gpg (merges status lines with stderr, the precondition for SigSpoof)"
|
||||
fi
|
||||
if grep -E 'gpg' "$SRC" | grep -q -- '--verbose'; then
|
||||
fail "$SRC passes --verbose to gpg (echoes the unescaped input filename to stderr)"
|
||||
fi
|
||||
pass "$SRC does not combine --status-fd 2 with --verbose in any gpg invocation"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2019-16884: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,39 @@
|
||||
# CVE-2019-16884: runc AppArmor confinement weakened by incorrect mount-target validation for /proc
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 6.5-7.5. Fixed in runc 1.0.0-rc9.
|
||||
|
||||
## Source
|
||||
|
||||
- [Rapid7 vulnerability record](https://www.rapid7.com/db/vulnerabilities/redhat-openshift-cve-2019-16884)
|
||||
- [Debian security tracker](https://security-tracker.debian.org/tracker/CVE-2019-16884)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
runc's root filesystem setup validated a mount target incorrectly, letting a crafted
|
||||
image mount something over `/proc` in a way that weakened the AppArmor profile runc
|
||||
otherwise applies. doos has no AppArmor profile that constrains the container's own
|
||||
filesystem view the way `docker-default` does (COMPARISON.md section 6 already states
|
||||
this as a design difference, not a bug), so the specific confinement this CVE weakens
|
||||
does not exist in doos to begin with. What is still worth checking directly is the thing
|
||||
runc's bug actually let an attacker do: substitute a non-`proc` mount at the container's
|
||||
`/proc`.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Attaches a container and checks, from inside, that `/proc`'s filesystem type (via
|
||||
`/proc/self/mountinfo` on the real `/proc` doos mounted, read before the check's own
|
||||
command could remount it) is `proc`, matching `doMount("proc", ..., "proc", ...)` in
|
||||
`src/sandbox.cpp`.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Confirms doos's own `/proc` mount is what it claims to be at container start; it does
|
||||
not attempt the image-controlled mount-target substitution runc's bug needed, since doos
|
||||
has no AppArmor profile for that substitution to bypass in the first place.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2019-16884-class: doos's own /proc mount must actually be of type proc.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
out=$("$DOOS" attach --net none audit16884 -- sh -c '
|
||||
awk "\$5==\"/proc\" { for (i=1;i<=NF;i++) if (\$i==\"-\") { print \$(i+1); exit } }" /proc/self/mountinfo
|
||||
' 2>&1)
|
||||
"$DOOS" delete --yes audit16884 >/dev/null 2>&1
|
||||
|
||||
fstype=$(echo "$out" | tail -1)
|
||||
if [ "$fstype" != "proc" ]; then
|
||||
fail "/proc inside the container is not a 'proc' mount: saw '$fstype' (raw: $out)"
|
||||
fi
|
||||
pass "/proc inside the container is a genuine proc mount"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2020-7039: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,40 @@
|
||||
# CVE-2020-7039: libslirp/Slirp memory-safety bugs (heap overflow, use-after-free, OOB access) in the user-mode network stack
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
Red Hat RHSA-2020:0889 (Important), bundling CVE-2019-14378, CVE-2019-15890, CVE-2020-7039, CVE-2020-8608; related CVE-2020-10756 and CVE-2020-29130 fixed later in libslirp.
|
||||
|
||||
## Source
|
||||
|
||||
- [Red Hat RHSA-2020:0889](https://access.redhat.com/errata/RHSA-2020:0889)
|
||||
- [SUSE/openSUSE advisory for CVE-2020-10756 and CVE-2020-29130](https://tenable.com/plugins/nessus/159219)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
slirp4netns, doos's default network mode, links libslirp to provide user-mode TCP/IP.
|
||||
Several memory-safety bugs were found and fixed in that code between 2019 and 2022: a
|
||||
heap overflow, a use-after-free, and multiple out-of-bounds accesses while parsing
|
||||
network traffic the sandboxed process generates or receives. Since slirp4netns runs as
|
||||
the invoking user's own process (not root, not setuid), a bug here is a crash or, in the
|
||||
worst case, code execution as that same user, not a privilege escalation, but it is the
|
||||
one piece of doos's default path written in C and parsing attacker-reachable network
|
||||
data, so it belongs in this audit's named list rather than being assumed fine by default.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Runs `slirp4netns --version` and reads its embedded `libslirp:` version line, compared
|
||||
against the fixed releases for this CVE cluster (4.3.1 for the 2020 OOB/UAF bugs; the
|
||||
2019 heap overflow was fixed earlier still). No exploit code: these are memory-corruption
|
||||
bugs in a dependency, not something to trigger against the host running the check.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
`slirp4netns --version` reports the libslirp version it was built against; a
|
||||
distribution that backports fixes without bumping that string would not be visible to
|
||||
this check. Covers only `slirp4netns`/libslirp; `pasta` (passt) is a separate codebase
|
||||
and is not covered by this entry.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2020-7039 (and the libslirp memory-safety cluster it shipped with): libslirp version
|
||||
# against its fixed release (4.3.1). No exploit code.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
command -v slirp4netns >/dev/null || skip "slirp4netns not installed"
|
||||
out=$(slirp4netns --version 2>&1) || skip "'slirp4netns --version' failed: $out"
|
||||
|
||||
ver=$(echo "$out" | grep -oE 'libslirp: [0-9]+\.[0-9]+(\.[0-9]+)?' | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?')
|
||||
[ -n "$ver" ] || skip "could not find a libslirp version in: $out"
|
||||
|
||||
major=$(echo "$ver" | cut -d. -f1); minor=$(echo "$ver" | cut -d. -f2); patch=$(echo "$ver" | cut -d. -f3); patch=${patch:-0}
|
||||
|
||||
if [ "$major" -gt 4 ]; then pass "libslirp $ver, past the 4.3.1 fix point"; fi
|
||||
if [ "$major" -eq 4 ] && [ "$minor" -gt 3 ]; then pass "libslirp $ver, past the 4.3.1 fix point"; fi
|
||||
if [ "$major" -eq 4 ] && [ "$minor" -eq 3 ] && [ "$patch" -ge 1 ]; then pass "libslirp $ver is at the fixed 4.3.1"; fi
|
||||
fail "libslirp $ver is below the fixed 4.3.1 (CVE-2020-10756, CVE-2020-29130, and this cluster)"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2021-3493: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,41 @@
|
||||
# CVE-2021-3493: Ubuntu OverlayFS privilege escalation via missing user-namespace fscap check
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.8 (High). CISA KEV. Fixed in Ubuntu kernels 5.8.0-50.56 / 5.4.0-72.80 / 4.15.0-142.146 / 4.4.0-209.241.
|
||||
|
||||
## Source
|
||||
|
||||
- [Wiz vulnerability database](https://www.wiz.io/vulnerability-database/cve/cve-2021-3493)
|
||||
- [CircleCI CVE record](https://cve.circl.lu/vuln/CVE-2021-3493)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
Ubuntu's kernel carries a patch allowing unprivileged overlayfs mounts, which is exactly
|
||||
what doos relies on for every attach (host mode overlays `/usr` and `/opt`, image mode
|
||||
overlays an extracted rootfs). The kernel's overlayfs did not convert file capabilities
|
||||
against the mounting user namespace before copy-up, so a crafted lower-layer file with a
|
||||
capability xattr could grant that capability once copied into the upper layer and read
|
||||
back outside the namespace, root-equivalent privileges from an ordinary unprivileged
|
||||
overlay mount. This is the single most on-point kernel bug for doos's core design: it is
|
||||
not "a container escape through some unrelated mechanism", it is the exact mount doos
|
||||
issues on every `attach`.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version against the fixed builds (branch-aware: 5.8/5.4/4.15/4.4 have published
|
||||
fixed point releases; anything on an unlisted branch between 4.4 and 5.11 is treated as
|
||||
unproven and reported, not assumed safe). No exploit code: this is a kernel capability-set
|
||||
bug, not something safe to trigger for real on the host running the check.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
This is the first of three overlayfs-in-userns kernel bugs in this audit (see also
|
||||
`CVE-2023-2640`, `CVE-2023-32629`, `CVE-2015-1328`); together they are the strongest
|
||||
argument for keeping the host kernel current, since doos's entire model depends on this
|
||||
exact combination (overlayfs + unprivileged user namespace) staying safe.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2021-3493: kernel version against Ubuntu's fixed builds. No exploit code (kernel capability
|
||||
# bug, not safe to trigger for real).
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
full=$(uname -r)
|
||||
ver=$(echo "$full" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+')
|
||||
[ -n "$ver" ] || skip "could not parse kernel version from '$full'"
|
||||
major=$(echo "$ver" | cut -d. -f1); minor=$(echo "$ver" | cut -d. -f2); patch=$(echo "$ver" | cut -d. -f3)
|
||||
abi=$(echo "$full" | grep -oE '\-[0-9]+' | head -1 | tr -d '-')
|
||||
|
||||
verge() { # verge a b c: true if running version (major.minor.patch) >= a.b.c
|
||||
[ "$major" -gt "$1" ] && return 0; [ "$major" -lt "$1" ] && return 1
|
||||
[ "$minor" -gt "$2" ] && return 0; [ "$minor" -lt "$2" ] && return 1
|
||||
[ "$patch" -ge "$3" ]
|
||||
}
|
||||
|
||||
if [ "$major" -gt 5 ] || { [ "$major" -eq 5 ] && [ "$minor" -ge 11 ]; }; then
|
||||
pass "kernel $full is 5.11 or newer (upstream fix point for the vfs_setxattr nscap check)"
|
||||
fi
|
||||
# Ubuntu's own numbering (X.Y.Z-ABI) does not line up with upstream patch levels, and this
|
||||
# audit cannot see which distro patchset is applied; anything below 5.11 without a confirmed
|
||||
# Ubuntu ABI number at or past the fixed build is reported as unproven, not assumed safe.
|
||||
fail "kernel $full is below the upstream fix point (5.11) and this check cannot confirm an Ubuntu backport is applied; verify against USN-4917-1 for this exact build (ABI ${abi:-unknown})"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2022-0492: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,39 @@
|
||||
# CVE-2022-0492: cgroup v1 release_agent write check missing, root-equivalent container escape
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.0 (High). Fixed around Linux 5.17-rc3. Exploited in the wild before disclosure.
|
||||
|
||||
## Source
|
||||
|
||||
- [The Hacker News coverage](https://thehackernews.com/2022/03/new-linux-kernel-cgroups-vulnerability.html)
|
||||
- [Sysdig detection writeup](https://sysdig.com/blog/detecting-mitigating-cve-2021-0492-sysdig)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
`cgroup_release_agent_write` did not check that the writer held administrative privilege
|
||||
over the cgroup hierarchy, so a process inside a container (even without CAP_SYS_ADMIN
|
||||
at the host level, using a user-namespace-mounted cgroupfs) could set a cgroup v1
|
||||
`release_agent` path and get it executed with host privileges the next time the cgroup
|
||||
emptied. This is a cgroup v1 feature; doos targets cgroup v2 exclusively
|
||||
(`src/cgroup.cpp`, `Cgroup::create`/`open` build paths under the unified hierarchy only)
|
||||
and, more directly, never mounts any `/sys` or cgroupfs into the container at all, so the
|
||||
file this bug needs to write would not exist inside a doos container regardless of
|
||||
kernel version.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Attaches a container and checks `/sys/fs/cgroup` is absent (doos mounts no `/sys` inside
|
||||
the container) and, if for some reason it is present, that no `release_agent` file exists
|
||||
under it. FAIL only if a writable `release_agent` is actually reachable from inside.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
This verifies doos's own mount list, not the kernel: on a kernel still vulnerable to
|
||||
CVE-2022-0492 but reached through some other route (a bind mount doos does not create),
|
||||
this check says nothing. It is scoped to what `doos attach` itself can expose.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2022-0492: a doos container must not have cgroupfs (and therefore no release_agent file)
|
||||
# mounted inside it at all, since doos mounts no /sys.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
out=$("$DOOS" attach --net none audit0492 -- sh -c '
|
||||
if [ -d /sys/fs/cgroup ]; then
|
||||
echo "SYSFS_PRESENT"
|
||||
[ -e /sys/fs/cgroup/release_agent ] && echo "RELEASE_AGENT_PRESENT:$( [ -w /sys/fs/cgroup/release_agent ] && echo writable || echo readonly)"
|
||||
else
|
||||
echo "NO_SYSFS"
|
||||
fi
|
||||
' 2>&1)
|
||||
"$DOOS" delete --yes audit0492 >/dev/null 2>&1
|
||||
|
||||
if echo "$out" | grep -q 'RELEASE_AGENT_PRESENT:writable'; then
|
||||
fail "a writable release_agent file is reachable inside the container: $out"
|
||||
fi
|
||||
if echo "$out" | grep -q NO_SYSFS; then
|
||||
pass "no /sys/fs/cgroup inside the container at all (doos mounts no /sys): $out"
|
||||
fi
|
||||
pass "/sys/fs/cgroup present but no writable release_agent reachable: $out"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2022-27650: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,38 @@
|
||||
# CVE-2022-27650: crun/Moby started containers with a non-empty inheritable capability set
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.5. Fixed in crun 1.4.4; related Moby advisory GHSA-wr4f-w546-m398.
|
||||
|
||||
## Source
|
||||
|
||||
- [Alpine security tracker](https://security.alpinelinux.org/vuln/CVE-2022-27650)
|
||||
- [Debian security tracker](https://security-tracker.debian.org/CVE-2022-27650)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
crun (and, separately, Moby) could start a container process with inheritable
|
||||
capabilities still set in its capability set, rather than cleared. A program inside the
|
||||
container carrying file capabilities (`cap_setuid+ep` and similar) can raise an
|
||||
inheritable capability into its permitted set across `execve`, turning a leftover
|
||||
inheritable bit into a real privilege gain. doos drops to a fixed capability bounding
|
||||
set in `Hardening::apply()` before exec; the inheritable set specifically is the thing
|
||||
this bug class is about, and it is worth confirming directly rather than only checking
|
||||
the effective set (which is what the README and `docs/RESEARCH.md` already measure via
|
||||
`CapEff`).
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Attaches a container with default hardening and reads `/proc/self/status`'s `CapInh`
|
||||
line. Anything other than all-zero is FAIL.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Only checks default hardening; `--hardening relaxed` is documented as removing capability
|
||||
restrictions entirely and is out of scope here, same as the other hardening-dependent
|
||||
checks in this audit.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2022-27650-class: the exec'd process's inheritable capability set must be empty under
|
||||
# default hardening.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
out=$("$DOOS" attach --net none audit27650 -- grep CapInh /proc/self/status 2>&1)
|
||||
"$DOOS" delete --yes audit27650 >/dev/null 2>&1
|
||||
|
||||
capinh=$(echo "$out" | sed -n 's/^CapInh:[[:space:]]*//p' | tr -d '\t' | head -n1)
|
||||
if [ -z "$capinh" ]; then
|
||||
skip "could not read CapInh from the container: $out"
|
||||
fi
|
||||
if [ "$capinh" != "0000000000000000" ]; then
|
||||
fail "CapInh is non-zero under default hardening: $out"
|
||||
fi
|
||||
pass "CapInh is all-zero under default hardening: $out"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2023-2640: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,38 @@
|
||||
# CVE-2023-2640: GameOver(lay) part 1: Ubuntu OverlayFS skips capability conversion on copy-up
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
High severity. Wiz/ProjectDiscovery, 2023. Reported to affect ~40% of Ubuntu cloud workloads.
|
||||
|
||||
## Source
|
||||
|
||||
- [Wiz blog: Ubuntu OverlayFS vulnerability](https://www.wiz.io/blog/ubuntu-overlayfs-vulnerability)
|
||||
- [ProjectDiscovery writeup](https://blog.projectdiscovery.io/gameover-lay-local-privilege-escalation-in-ubuntu-kernel/)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
A second, later regression of the same bug class as `CVE-2021-3493` in Ubuntu's overlayfs
|
||||
patch set: file security capabilities were not converted before copy-up, so a user
|
||||
namespace with its own (locally scoped) admin-like capabilities could create a file that
|
||||
carries root-equivalent capabilities once it reached a context without that namespace
|
||||
restriction. Same relevance to doos as `CVE-2021-3493`: this is doos's core mount, not an
|
||||
unrelated code path.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version/build check against the affected matrix Wiz published (Ubuntu 22.04 and
|
||||
20.04 on kernel 5.15.0, Ubuntu 18.04 on kernel 5.4.0, as a work-in-progress matrix at
|
||||
disclosure time). Reports FAIL for an exact match on the affected major.minor with no
|
||||
newer point release info available to this script, PASS otherwise. No exploit code.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Wiz's own affected-version matrix was explicitly marked a work in progress at
|
||||
publication, and this check has no way to tell a patched point release of 5.15.0 from an
|
||||
unpatched one from `uname -r` alone; treat FAIL here as "go check the Ubuntu security
|
||||
notice for this exact build", not as a confirmed exploit.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2023-2640 (GameOver(lay) part 1): flags kernel major.minor versions Wiz listed as affected
|
||||
# on Ubuntu. No exploit code.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
full=$(uname -r)
|
||||
mm=$(echo "$full" | grep -oE '^[0-9]+\.[0-9]+')
|
||||
[ -n "$mm" ] || skip "could not parse kernel version from '$full'"
|
||||
|
||||
is_ubuntu=0
|
||||
[ -r /etc/os-release ] && grep -qi ubuntu /etc/os-release && is_ubuntu=1
|
||||
|
||||
case "$mm" in
|
||||
5.15|5.4)
|
||||
if [ "$is_ubuntu" = 1 ]; then
|
||||
fail "kernel $full matches a major.minor Wiz listed as affected on Ubuntu (5.15.0/5.4.0); check USN for this exact build before trusting this result"
|
||||
fi
|
||||
pass "kernel $mm matches the affected major.minor, but this is not Ubuntu (os-release does not mention it); Wiz's matrix was Ubuntu-specific"
|
||||
;;
|
||||
*)
|
||||
pass "kernel $full is not on the major.minor versions Wiz listed as affected ($full)"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2023-28642: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,38 @@
|
||||
# CVE-2023-28642: runc AppArmor/SELinux confinement bypass via a symlinked or non-directory /proc inside the container
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
Fixed in runc 1.1.5 (USN-6088-1), same release as CVE-2023-27561.
|
||||
|
||||
## Source
|
||||
|
||||
- [Ubuntu Security Notice USN-6088-1](https://usn.ubuntu.com/6088-1)
|
||||
- [LWN coverage](https://lwn.net/Articles/932370/)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
runc let `/proc` inside the container be a symlink, or let something be mounted over a
|
||||
non-directory in its place, which bypassed its AppArmor and (on the right distro)
|
||||
SELinux restrictions tied to the real `/proc`. doos has no comparable confinement layered
|
||||
on top of `/proc` to bypass (same caveat as `CVE-2019-16884`: no per-container AppArmor
|
||||
profile), but the underlying property, that `/proc` is a genuine, fresh directory mount
|
||||
and not a symlink an image could have planted, is exactly the same `/proc` doos sets up
|
||||
in `src/sandbox.cpp` and worth checking directly rather than only by analogy.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
From inside a container, confirms `/proc` is a directory and not a symlink
|
||||
(`[ -L /proc ]` must be false), on top of the mount-type check `CVE-2019-16884` already
|
||||
runs.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Checks the filesystem property the runc bug abused (a symlinked `/proc`), not the
|
||||
AppArmor/SELinux bypass itself, since doos's hardening model (seccomp + capability drop,
|
||||
not AppArmor confinement of the container's own filesystem) is a different mechanism;
|
||||
see COMPARISON.md section 6 for that design difference.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2023-28642-class: /proc inside the container must be a real directory, not a symlink.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
out=$("$DOOS" attach --net none audit28642 -- sh -c '[ -L /proc ] && echo SYMLINK || echo DIRECTORY' 2>&1)
|
||||
"$DOOS" delete --yes audit28642 >/dev/null 2>&1
|
||||
|
||||
if echo "$out" | grep -q SYMLINK; then
|
||||
fail "/proc inside the container is a symlink: $out"
|
||||
fi
|
||||
if echo "$out" | grep -q DIRECTORY; then
|
||||
pass "/proc inside the container is a real directory, not a symlink"
|
||||
fi
|
||||
skip "could not determine /proc's type from the container: $out"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2023-32629: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,33 @@
|
||||
# CVE-2023-32629: GameOver(lay) part 2: Ubuntu OverlayFS skips permission checks on copy-up xattr
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
High severity. Wiz/ProjectDiscovery, 2023.
|
||||
|
||||
## Source
|
||||
|
||||
- [Wiz blog: Ubuntu OverlayFS vulnerability](https://www.wiz.io/blog/ubuntu-overlayfs-vulnerability)
|
||||
- [ProjectDiscovery writeup](https://blog.projectdiscovery.io/gameover-lay-local-privilege-escalation-in-ubuntu-kernel/)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
The second GameOver(lay) flaw: a separate skip of permission checks in overlayfs's
|
||||
copy-up path when setting extended attributes, affecting a slightly different kernel
|
||||
version range than `CVE-2023-2640` but the same end result. Same relevance to doos as the
|
||||
other overlayfs entries: doos's attach path is exactly this mount.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version check against the versions ProjectDiscovery listed for this CVE on
|
||||
Ubuntu. No exploit code.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Same limits as `CVE-2023-2640`: the published affected matrix is a snapshot, not a
|
||||
definitive oracle for every point release; treat FAIL as a prompt to check the Ubuntu
|
||||
security notice for the exact running build.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2023-32629 (GameOver(lay) part 2): flags kernel major.minor versions reported affected
|
||||
# on Ubuntu. No exploit code.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
full=$(uname -r)
|
||||
mm=$(echo "$full" | grep -oE '^[0-9]+\.[0-9]+')
|
||||
[ -n "$mm" ] || skip "could not parse kernel version from '$full'"
|
||||
|
||||
is_ubuntu=0
|
||||
[ -r /etc/os-release ] && grep -qi ubuntu /etc/os-release && is_ubuntu=1
|
||||
|
||||
case "$mm" in
|
||||
5.15|5.4|5.19)
|
||||
if [ "$is_ubuntu" = 1 ]; then
|
||||
fail "kernel $full matches a major.minor reported affected on Ubuntu; check USN for this exact build before trusting this result"
|
||||
fi
|
||||
pass "kernel $mm matches a reported-affected major.minor, but this is not Ubuntu"
|
||||
;;
|
||||
*)
|
||||
pass "kernel $full is not on a major.minor reported affected"
|
||||
;;
|
||||
esac
|
||||
+11
-1
@@ -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).
|
||||
|
||||
@@ -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
@@ -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).
|
||||
+10
-2
@@ -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,7 +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` follows a symlink planted at a container's path under `$DOOS_HOME/containers/<name>`, same bug class as [`audit/CVE-2021-30465`](../audit/CVE-2021-30465/README.md) | refuse or unlink-and-recreate when a path component is a pre-existing symlink | **unmeasured fix, confirmed gap** (found by `make audit`, 2026-10-23) | `fsutil::mkdirs` in `src/util.cpp` |
|
||||
| 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)
|
||||
|
||||
|
||||
+150
@@ -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)
|
||||
@@ -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,11 +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' -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 './review2.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.
|
||||
# review.md: a standalone external security review, not part of the maintained doc set.
|
||||
# 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; }
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs a fixed, named set of doos invocations under valgrind's memcheck, using
|
||||
# valgrind/doos.supp to silence the known, explained allocation sites (see that file's
|
||||
# comments), and fails only on a real, unsuppressed memory error or leak. Does not rebuild;
|
||||
# run `make build` (or just `make valgrind`, which depends on it) first.
|
||||
#
|
||||
# Usage: scripts/valgrind.sh [path/to/doos]
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
DOOS="${1:-build/doos}"
|
||||
SUPP="$(pwd)/valgrind/doos.supp"
|
||||
REPORTS="build/valgrind-reports"
|
||||
TIMEOUT=60
|
||||
|
||||
command -v valgrind >/dev/null || { echo "valgrind: not installed"; exit 1; }
|
||||
[ -x "$DOOS" ] || { echo "valgrind: no doos binary at '$DOOS' (run 'make build' first)"; exit 1; }
|
||||
[ -f "$SUPP" ] || { echo "valgrind: suppressions file missing: $SUPP"; exit 1; }
|
||||
|
||||
rm -rf "$REPORTS"
|
||||
mkdir -p "$REPORTS"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
fails=0
|
||||
checked=0
|
||||
|
||||
run() { # run NAME -- DOOS_ARGS...
|
||||
local name="$1"; shift
|
||||
shift # drop the --
|
||||
checked=$((checked + 1))
|
||||
local log="$REPORTS/$name.log"
|
||||
timeout "$TIMEOUT" valgrind \
|
||||
--leak-check=full --show-leak-kinds=all --track-origins=yes \
|
||||
--suppressions="$SUPP" --error-exitcode=99 \
|
||||
--log-file="$log" \
|
||||
"$DOOS" "$@" >/dev/null 2>&1
|
||||
local rc=$?
|
||||
if [ "$rc" = 99 ]; then
|
||||
echo "FAIL $name: valgrind reported an unsuppressed error or leak, see $log"
|
||||
fails=$((fails + 1))
|
||||
elif [ "$rc" = 124 ]; then
|
||||
echo "FAIL $name: timed out after ${TIMEOUT}s under valgrind, see $log"
|
||||
fails=$((fails + 1))
|
||||
else
|
||||
echo "ok $name (doos exit=$rc; valgrind found nothing to report), see $log"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fixed, named set: the four CLI commands SPEC.md documents, plus a second attach to exercise
|
||||
# the join-a-running-container path. No image download here (network + extraction add their
|
||||
# own long-running C library code, better covered once by a dedicated image-mode run; see
|
||||
# valgrind/README.md for how to add one).
|
||||
run list -- list
|
||||
run get_or_create -- get_or_create --net none vgcheck
|
||||
run attach_first -- attach --net none vgcheck -- true
|
||||
run attach_join -- attach --net none vgcheck -- true
|
||||
run image_list -- image list
|
||||
run delete -- delete --yes vgcheck
|
||||
|
||||
echo "valgrind: $checked checked, $fails failed, reports in $REPORTS/"
|
||||
[ "$fails" -eq 0 ]
|
||||
+23
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# valgrind
|
||||
|
||||
**Scope:** [README](../README.md) · [AGENTS](../AGENTS.md) · [docs/RESEARCH](../docs/RESEARCH.md)
|
||||
|
||||
Memcheck run against a fixed, named set of `doos` invocations, to catch real memory bugs
|
||||
(invalid reads/writes, use-after-free, definite leaks) in doos's own code: namespace setup,
|
||||
mount handling, the CLI, the image store. Separate from `audit/` (named CVEs against other
|
||||
software) and from `tests/run.sh` (behavior, not memory safety).
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
make valgrind # builds build/doos first, then runs scripts/valgrind.sh against it
|
||||
scripts/valgrind.sh PATH # run directly against a specific binary, e.g. a copy with an
|
||||
# installed AppArmor profile (see README's apparmor section)
|
||||
```
|
||||
|
||||
Reports land in `build/valgrind-reports/<step>.log`, one per invocation, kept (not deleted)
|
||||
after the run so a failure can be inspected. Each step prints `ok` or `FAIL`; the run exits
|
||||
non-zero only if valgrind itself flagged something, never because `doos` exited non-zero for
|
||||
its own reasons (an expected `delete` dry-run notice, an AppArmor block on a binary with no
|
||||
profile installed, and so on, all exit non-zero as designed and are not memory bugs).
|
||||
|
||||
## What it checks
|
||||
|
||||
A fixed, capped list (`scripts/valgrind.sh`): `list`, `get_or_create`, `attach` (twice, to
|
||||
exercise both the create path and joining an already-running container), `image list`,
|
||||
`delete --yes`. All run with `--net none` and no image, so the check needs no network and no
|
||||
download; it is about doos's own allocation code, not about what happens after it execs the
|
||||
contained command.
|
||||
|
||||
## Suppressions (`doos.supp`)
|
||||
|
||||
Six specific, named, commented allocation sites, each captured with
|
||||
`valgrind --gen-suppressions=all` against a real run and explained in the file itself: the
|
||||
clone(2) child's 1 MiB stack buffer (lifetime tied to the child process, not a scope), and five
|
||||
"an error path constructed an exception or opened a stream, then the namespace child exited
|
||||
right after" patterns (`doMount`'s thrown `SysError`'s message string and the exception object
|
||||
itself, once for a mount failure inside `setupRoot` and once for the mount that runs directly in
|
||||
`containerInit` before it, such as an AppArmor-blocked `mount bind /`; plus an `ifstream` opened
|
||||
in `containerInit`'s cold path). None of these are "definitely lost"; valgrind calls all of them
|
||||
"possibly lost" because the process ends before the normal C++ unwind would run their
|
||||
destructors, which is expected for a namespace child that is about to disappear regardless. A
|
||||
genuinely new leak anywhere else is not covered by these exact stacks and still fails the check.
|
||||
|
||||
To add a suppression for a new, understood, one-time allocation: reproduce it, add
|
||||
`--gen-suppressions=all` to the `valgrind` invocation in `scripts/valgrind.sh` temporarily, copy
|
||||
the exact block `doos.supp` already prints into the file with a name and a comment explaining
|
||||
why it is not a real leak, then remove the temporary flag. Never add a suppression you cannot
|
||||
explain in one sentence.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- Anything that happens after doos execs the user's command: valgrind traces doos's own process
|
||||
tree, not an arbitrary program run inside the container.
|
||||
- Image download and extraction (`--image`): not in the fixed list, since it needs network and
|
||||
adds `curl`/`tar`/`gpg` subprocess noise that is not doos's own memory. Add a step to
|
||||
`scripts/valgrind.sh` following the same pattern if that code path needs a dedicated run.
|
||||
- `slirp4netns`/`pasta`: separate processes, not doos; see `audit/CVE-2020-7039` for how their
|
||||
memory safety is tracked instead (upstream version, not valgrind).
|
||||
|
||||
Back to [README](../README.md), [AGENTS](../AGENTS.md), [docs/RESEARCH](../docs/RESEARCH.md).
|
||||
@@ -0,0 +1,105 @@
|
||||
# Suppressions for `make valgrind` (see scripts/valgrind.sh and valgrind/README.md).
|
||||
#
|
||||
# Each entry here is a specific, named allocation site, not a blanket "ignore leaks" rule, and
|
||||
# each one is explained. They were captured with `valgrind --gen-suppressions=all` against a
|
||||
# real `doos attach` run and trimmed to the exact stack valgrind printed; a new, different leak
|
||||
# anywhere else still fails the check. Regenerate a candidate block with
|
||||
# `valgrind --gen-suppressions=all` if doos's internals change and one of these stops matching.
|
||||
|
||||
{
|
||||
doos-clone-child-stack
|
||||
# Sandbox::start allocates the 1 MiB stack clone(2) needs for the namespace child. Its
|
||||
# lifetime is tied to that child process, not to a scope in the parent, so it is never
|
||||
# `delete[]`d: freeing it after clone() returns would be safe only once the child has copied
|
||||
# what it needs, and doing that bookkeeping would add a second code path purely to silence
|
||||
# a tool, not to fix a real leak. Confirmed "possibly lost", never "definitely lost".
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:_Znwm
|
||||
fun:_ZN4doos7Sandbox5startERNS_8FileLockE
|
||||
fun:_ZN4doos7Sandbox3runEv
|
||||
fun:_ZN4doos3Cli9cmdAttachESt6vectorINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEESaIS7_EE
|
||||
fun:_ZN4doos3Cli3runEv
|
||||
fun:main
|
||||
}
|
||||
|
||||
{
|
||||
doos-mount-error-exception-object
|
||||
# doMount's cold (error) path throws a SysError built from a std::string; when the namespace
|
||||
# child terminates right after printing that error, the in-flight exception object itself
|
||||
# (allocated by __cxa_allocate_exception) has not yet been destroyed by the unwinder. This is
|
||||
# the standard valgrind artifact for a C++ exception that is still alive when the process
|
||||
# exits, not a missing `delete`.
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:malloc
|
||||
fun:__cxa_allocate_exception
|
||||
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||
fun:_ZN4doos12_GLOBAL__N_1L9setupRootERKNS_9ContainerERKNSt10filesystem7__cxx114pathE
|
||||
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||
fun:clone
|
||||
}
|
||||
|
||||
{
|
||||
doos-mount-error-message-string
|
||||
# Same doMount error path: the message string's internal buffer, built while constructing
|
||||
# the SysError/runtime_error that is about to be thrown. Same explanation as
|
||||
# doos-mount-error-exception-object, one frame earlier in the chain.
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:_Znwm
|
||||
fun:_ZNSs4_Rep9_S_createEmmRKSaIcE
|
||||
fun:_ZNSt12__cow_stringC2EPKcm
|
||||
fun:_ZNSt13runtime_errorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
|
||||
fun:_ZN4doos8SysErrorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEi
|
||||
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||
fun:_ZN4doos12_GLOBAL__N_1L9setupRootERKNS_9ContainerERKNSt10filesystem7__cxx114pathE
|
||||
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||
fun:clone
|
||||
}
|
||||
|
||||
{
|
||||
doos-mount-error-exception-object-top-level
|
||||
# Same explanation as doos-mount-error-exception-object, for the mount call that runs
|
||||
# directly in containerInit before setupRoot is reached (the very first "mount bind /",
|
||||
# which is what fails when AppArmor blocks user namespaces for this binary's path).
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:malloc
|
||||
fun:__cxa_allocate_exception
|
||||
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||
fun:clone
|
||||
}
|
||||
|
||||
{
|
||||
doos-mount-error-message-string-top-level
|
||||
# Same explanation as doos-mount-error-message-string, one level up: the mount call that
|
||||
# runs directly in containerInit before setupRoot is reached.
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:_Znwm
|
||||
fun:_ZNSs4_Rep9_S_createEmmRKSaIcE
|
||||
fun:_ZNSt12__cow_stringC2EPKcm
|
||||
fun:_ZNSt13runtime_errorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
|
||||
fun:_ZN4doos8SysErrorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEi
|
||||
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||
fun:clone
|
||||
}
|
||||
|
||||
{
|
||||
doos-containerinit-ifstream-buffer
|
||||
# containerInit's cold (error) path opens an ifstream (a /proc file, for a diagnostic) whose
|
||||
# libstdc++ filebuf buffer is still open when the namespace child exits right after. Same
|
||||
# "process exited before an RAII object on an about-to-unwind error path ran its destructor"
|
||||
# pattern as the two entries above, for a stream instead of an exception.
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:_Znam
|
||||
fun:_ZNSt13basic_filebufIcSt11char_traitsIcEE27_M_allocate_internal_bufferEv
|
||||
fun:_ZNSt13basic_filebufIcSt11char_traitsIcEE4openEPKcSt13_Ios_Openmode
|
||||
fun:_ZNSt14basic_ifstreamIcSt11char_traitsIcEEC1EPKcSt13_Ios_Openmode
|
||||
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv.cold
|
||||
fun:clone
|
||||
}
|
||||
Reference in New Issue
Block a user