Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
598a585eb8 | ||
|
|
4c4bb1a83a |
@@ -49,6 +49,7 @@ Never hand-edit `VERSION` or create tags manually. Never skip the tests to relea
|
||||
| A measurement, or anything the charts show | `make bench` then `make charts` (rewrites `docs/charts/*.svg` and every `<!--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` |
|
||||
@@ -86,9 +87,10 @@ installed once for the binary being tested (`doos apparmor`; needs sudo, ask the
|
||||
## Make targets
|
||||
|
||||
`make help` lists them: `build`, `test`, `audit` (named CVE regression checks against `build/doos`,
|
||||
see [audit/README.md](audit/README.md)), `check-docs`, `bench` (re-measure into `docs/charts/bench.json`),
|
||||
see [audit/README.md](audit/README.md)), `valgrind` (memcheck against a fixed set of invocations,
|
||||
see [valgrind/README.md](valgrind/README.md)), `check-docs`, `bench` (re-measure into `docs/charts/bench.json`),
|
||||
`charts` (SVGs and generated numbers from that file), `docs`, `release KIND=patch MSG="..."`, `clean`.
|
||||
The Makefile is a thin wrapper; the logic lives in `CMakeLists.txt`, `scripts/`, `audit/` and `docs/charts/`.
|
||||
The Makefile is a thin wrapper; the logic lives in `CMakeLists.txt`, `scripts/`, `audit/`, `valgrind/` and `docs/charts/`.
|
||||
|
||||
## Environment notes
|
||||
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
||||
|
||||
## 1.3.0 - 2026-10-24
|
||||
- Security review applied ([review.md](review.md)): `slirp4netns`, `pasta`, `newuidmap` and `newgidmap` are never searched on `PATH`; they exec via absolute paths with availability checked up front, and the helpers run with a scrubbed `PATH`.
|
||||
- Compat-directory mounts pin the parent through an `O_PATH` fd so a symlink swap between check and use cannot happen (TOCTOU); symlinks whose target cannot be pinned are not recreated, fail closed instead.
|
||||
- `--i-understand` is now required for `--hardening relaxed` and `--net host`; reduced sandboxing is also reported loudly at attach time.
|
||||
- Tests: the e2e suite covers all of the above (`passed=88 failed=0`). Docs: [RESEARCH](docs/RESEARCH.md), [SPEC](SPEC.md), [README](README.md) updated in the same change.
|
||||
|
||||
## 1.2.1 - 2026-10-23
|
||||
- `scripts/release.sh`: commit trailer model name corrected (no functional change).
|
||||
|
||||
## 1.2.0 - 2026-10-23
|
||||
- `audit/`: 10 more named, sourced CVEs (18 total): the overlayfs-in-unprivileged-userns family (`CVE-2021-3493`, `CVE-2023-2640`, `CVE-2023-32629`, `CVE-2015-1328`, directly doos's own mount on every attach), `CVE-2022-0492` (cgroup v1 release_agent; doos mounts no `/sys`), `CVE-2022-27650` (inheritable capabilities at container start), `CVE-2019-16884` and `CVE-2023-28642` (runc's `/proc` mount-target handling, tested against doos's own `/proc` mount), `CVE-2018-12020` (GnuPG SigSpoof, static check of the exact `gpg` invocation in `src/image.cpp`), `CVE-2020-7039` (libslirp memory-safety cluster, version check against `slirp4netns --version`).
|
||||
|
||||
## 1.1.5 - 2026-10-23
|
||||
- `audit/`: 8 named, sourced CVEs relevant to doos's own mechanisms (overlay writes, tar extraction, mount/directory creation, net namespace sharing, the mount-family seccomp denylist, kernel user-namespace exposure), each with its own `README.md`, `Makefile` and non-destructive `reproduce.sh` against `build/doos`. `make audit` runs them concurrently (`-j8`, recursive make); `make -C audit list|report` also available. Kernel memory-corruption CVEs (Dirty Pipe, Dirty COW, `legacy_parse_param`) are checked by kernel version only, no exploit code.
|
||||
- Found by the audit and disclosed: `fsutil::mkdirs` follows a symlink planted at a container's path, so a symlink under `$DOOS_HOME/containers/<name>` redirects where that container's state is written (low severity: needs write access to the caller's own `$DOOS_HOME`). Tracked as gap G8 in [RESEARCH](docs/RESEARCH.md) and in [COMPARISON](COMPARISON.md); not yet fixed.
|
||||
|
||||
+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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ Notes, all measured:
|
||||
| `--net M` | `slirp` (outbound only, host loopback blocked), `pasta` (host-like, private sockets), `host`, `none` | slirp | fixed |
|
||||
| `--ids X` | `single` or `subuid` | subuid if possible | fixed |
|
||||
| `--hardening H` | `default` or `relaxed` | default | fixed |
|
||||
| `--i-understand` | required on every call that sets or uses `--hardening relaxed` or `--net host` | absent | fixed |
|
||||
| `--size S` | writable data cap | 4g | changeable |
|
||||
| `--memory S` | memory cap | 8g | changeable |
|
||||
| `--pids N` | process/thread cap | 4096 | changeable |
|
||||
@@ -175,7 +176,7 @@ Details: [COMPARISON](COMPARISON.md) section 6.1.
|
||||
|
||||
## Development
|
||||
|
||||
`make help` lists the targets: `make build`, `make test` (end-to-end, needs network), `make bench` (re-measure startup, sizes and the cpu limit), `make charts` (rebuild the SVG charts and the generated numbers in the comparison), `make docs`, `make release KIND=patch MSG="..."`. Build and test details are in [AGENTS](AGENTS.md); the charts and how they are generated are in [COMPARISON](COMPARISON.md) section 3 and `docs/charts/`.
|
||||
`make help` lists the targets: `make build`, `make test` (end-to-end, needs network), `make audit` (named CVE regression checks, see [audit/README.md](audit/README.md)), `make valgrind` (memcheck against a fixed set of invocations, see [valgrind/README.md](valgrind/README.md)), `make bench` (re-measure startup, sizes and the cpu limit), `make charts` (rebuild the SVG charts and the generated numbers in the comparison), `make docs`, `make release KIND=patch MSG="..."`. Build and test details are in [AGENTS](AGENTS.md); the charts and how they are generated are in [COMPARISON](COMPARISON.md) section 3 and `docs/charts/`.
|
||||
|
||||
## Project docs
|
||||
|
||||
@@ -188,5 +189,6 @@ Details: [COMPARISON](COMPARISON.md) section 6.1.
|
||||
| [`AGENTS.md`](AGENTS.md) | Rules for AI agents, release workflow |
|
||||
| [`CHANGELOG.md`](CHANGELOG.md) | Release history |
|
||||
| [`audit/README.md`](audit/README.md) | Named CVE regression checks run against `build/doos` (`make audit`) |
|
||||
| [`valgrind/README.md`](valgrind/README.md) | Memcheck against a fixed set of invocations (`make valgrind`) |
|
||||
|
||||
Not sure whether doos is the right tool? Read the conclusion of [COMPARISON](COMPARISON.md) first.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
|
||||
Measured on this machine: Ubuntu, kernel 7.0.0-38, AppArmor 5.0.2, slirp4netns 1.3.3, uid 1000, no root at runtime.
|
||||
|
||||
Memory-safety of doos's own code (not its behavior) is checked separately with `make valgrind`
|
||||
against a fixed set of invocations; see [valgrind/README.md](../valgrind/README.md) for what it
|
||||
covers and its suppressions. Known CVEs in software doos depends on or resembles are tracked in
|
||||
[audit/README.md](../audit/README.md) (`make audit`).
|
||||
|
||||
## What works unprivileged
|
||||
|
||||
1. `clone(CLONE_NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET])`, then the parent writes `setgroups=deny`, `uid_map`, `gid_map` (single entry, own uid). No setuid helpers (`newuidmap`) needed.
|
||||
|
||||
@@ -12,10 +12,10 @@ fail() { echo "docs: $*"; bad=$((bad+1)); }
|
||||
while IFS= read -r f; do
|
||||
f=${f#./}
|
||||
printf '%s\n' "${DOCS[@]}" | grep -qxF "$f" || fail "$f is not in the DOCS list of scripts/check-docs.sh"
|
||||
done < <(find . -name '*.md' -not -path './build/*' -not -path './.git/*' -not -path './prompt.md' -not -path './audit/*' -not -path './review.md')
|
||||
done < <(find . -name '*.md' -not -path './build/*' -not -path './.git/*' -not -path './prompt.md' -not -path './audit/*' -not -path './review.md' -not -path './valgrind/*')
|
||||
# prompt.md: the original task statement, not documentation.
|
||||
# audit/*: a separate, self-contained doc set (one README per CVE check); audit/README.md is
|
||||
# linked from the main docs but is not forced into their full cross-link mesh.
|
||||
# audit/*, valgrind/*: separate, self-contained doc sets (one README per CVE check, one for the
|
||||
# valgrind setup); linked from the main docs but not forced into their full cross-link mesh.
|
||||
# review.md: a standalone external security review, not part of the maintained doc set.
|
||||
|
||||
for f in "${DOCS[@]}"; do
|
||||
|
||||
+1
-1
@@ -32,6 +32,6 @@ tests/run.sh
|
||||
git add -A
|
||||
git commit -m "$subject
|
||||
|
||||
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||||
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
|
||||
git tag -a "v$new" -m "doos $new"
|
||||
echo "released v$new"
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs a fixed, named set of doos invocations under valgrind's memcheck, using
|
||||
# valgrind/doos.supp to silence the known, explained allocation sites (see that file's
|
||||
# comments), and fails only on a real, unsuppressed memory error or leak. Does not rebuild;
|
||||
# run `make build` (or just `make valgrind`, which depends on it) first.
|
||||
#
|
||||
# Usage: scripts/valgrind.sh [path/to/doos]
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
DOOS="${1:-build/doos}"
|
||||
SUPP="$(pwd)/valgrind/doos.supp"
|
||||
REPORTS="build/valgrind-reports"
|
||||
TIMEOUT=60
|
||||
|
||||
command -v valgrind >/dev/null || { echo "valgrind: not installed"; exit 1; }
|
||||
[ -x "$DOOS" ] || { echo "valgrind: no doos binary at '$DOOS' (run 'make build' first)"; exit 1; }
|
||||
[ -f "$SUPP" ] || { echo "valgrind: suppressions file missing: $SUPP"; exit 1; }
|
||||
|
||||
rm -rf "$REPORTS"
|
||||
mkdir -p "$REPORTS"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
fails=0
|
||||
checked=0
|
||||
|
||||
run() { # run NAME -- DOOS_ARGS...
|
||||
local name="$1"; shift
|
||||
shift # drop the --
|
||||
checked=$((checked + 1))
|
||||
local log="$REPORTS/$name.log"
|
||||
timeout "$TIMEOUT" valgrind \
|
||||
--leak-check=full --show-leak-kinds=all --track-origins=yes \
|
||||
--suppressions="$SUPP" --error-exitcode=99 \
|
||||
--log-file="$log" \
|
||||
"$DOOS" "$@" >/dev/null 2>&1
|
||||
local rc=$?
|
||||
if [ "$rc" = 99 ]; then
|
||||
echo "FAIL $name: valgrind reported an unsuppressed error or leak, see $log"
|
||||
fails=$((fails + 1))
|
||||
elif [ "$rc" = 124 ]; then
|
||||
echo "FAIL $name: timed out after ${TIMEOUT}s under valgrind, see $log"
|
||||
fails=$((fails + 1))
|
||||
else
|
||||
echo "ok $name (doos exit=$rc; valgrind found nothing to report), see $log"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fixed, named set: the four CLI commands SPEC.md documents, plus a second attach to exercise
|
||||
# the join-a-running-container path. No image download here (network + extraction add their
|
||||
# own long-running C library code, better covered once by a dedicated image-mode run; see
|
||||
# valgrind/README.md for how to add one).
|
||||
run list -- list
|
||||
run get_or_create -- get_or_create --net none vgcheck
|
||||
run attach_first -- attach --net none vgcheck -- true
|
||||
run attach_join -- attach --net none vgcheck -- true
|
||||
run image_list -- image list
|
||||
run delete -- delete --yes vgcheck
|
||||
|
||||
echo "valgrind: $checked checked, $fails failed, reports in $REPORTS/"
|
||||
[ "$fails" -eq 0 ]
|
||||
+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;
|
||||
}
|
||||
|
||||
+3
-12
@@ -30,16 +30,6 @@ void writeProcFile(pid_t pid, const char* file, const std::string& content) {
|
||||
}
|
||||
}
|
||||
|
||||
bool onPath(std::string_view bin) {
|
||||
const char* path = std::getenv("PATH");
|
||||
std::stringstream ss(path ? path : "/usr/bin:/bin");
|
||||
for (std::string dir; std::getline(ss, dir, ':');) {
|
||||
std::string full = dir + "/" + std::string(bin);
|
||||
if (::access(full.c_str(), X_OK) == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
IdMode IdMap::parse(const std::string& s) {
|
||||
@@ -71,7 +61,8 @@ std::optional<SubRange> IdMap::range(const char* file) {
|
||||
}
|
||||
|
||||
bool IdMap::subuidAvailable() {
|
||||
return range("/etc/subuid") && range("/etc/subgid") && onPath(spec::kNewUidMap) && onPath(spec::kNewGidMap);
|
||||
return range("/etc/subuid") && range("/etc/subgid") && ::access(std::string(spec::kNewUidMap).c_str(), X_OK) == 0 &&
|
||||
::access(std::string(spec::kNewGidMap).c_str(), X_OK) == 0;
|
||||
}
|
||||
|
||||
IdMode IdMap::best() { return subuidAvailable() ? IdMode::Subuid : IdMode::Single; }
|
||||
@@ -86,7 +77,7 @@ void IdMap::apply(pid_t pid, IdMode mode) {
|
||||
}
|
||||
auto u = range("/etc/subuid");
|
||||
auto g = range("/etc/subgid");
|
||||
if (!u || !g || !onPath(spec::kNewUidMap) || !onPath(spec::kNewGidMap)) {
|
||||
if (!u || !g || ::access(std::string(spec::kNewUidMap).c_str(), X_OK) != 0 || ::access(std::string(spec::kNewGidMap).c_str(), X_OK) != 0) {
|
||||
throw std::runtime_error("subuid mapping unavailable: need /etc/subuid, /etc/subgid, newuidmap and newgidmap "
|
||||
"(install uidmap, or use --ids single)");
|
||||
}
|
||||
|
||||
+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) + ")");
|
||||
|
||||
+14
-4
@@ -50,6 +50,9 @@ std::string Network::resolvConf(NetMode m) {
|
||||
}
|
||||
|
||||
void SlirpSession::start(pid_t targetPid) {
|
||||
const std::string bin(spec::kSlirpBinary);
|
||||
if (::access(bin.c_str(), X_OK) != 0) throw std::runtime_error("slirp4netns helper not found at " + bin +
|
||||
" (install slirp4netns or use --net none)");
|
||||
int ready[2];
|
||||
if (::pipe2(ready, O_CLOEXEC) != 0) throw SysError("pipe");
|
||||
Fd readEnd(ready[0]), writeEnd(ready[1]);
|
||||
@@ -58,14 +61,16 @@ void SlirpSession::start(pid_t targetPid) {
|
||||
if (pid_ < 0) throw SysError("fork slirp4netns");
|
||||
if (pid_ == 0) {
|
||||
::prctl(PR_SET_PDEATHSIG, SIGKILL);
|
||||
// Do not trust the caller's PATH: only system directories may be searched by anything the helper runs.
|
||||
::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1);
|
||||
int nul = ::open("/dev/null", O_WRONLY);
|
||||
if (nul >= 0) { ::dup2(nul, 1); ::dup2(nul, 2); }
|
||||
int wfd = ::dup(writeEnd.get()); // dup clears CLOEXEC
|
||||
std::string readyArg = "--ready-fd=" + std::to_string(wfd);
|
||||
std::string target = std::to_string(targetPid);
|
||||
std::string bin(spec::kSlirpBinary), tap(spec::kSlirpTapName);
|
||||
::execlp(bin.c_str(), bin.c_str(), "--configure", "--mtu=65520", "--disable-host-loopback",
|
||||
readyArg.c_str(), target.c_str(), tap.c_str(), static_cast<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 +97,19 @@ void SlirpSession::stop() {
|
||||
namespace doos {
|
||||
|
||||
void PastaSession::start(pid_t targetPid) {
|
||||
const std::string bin(spec::kPastaBinary);
|
||||
if (::access(bin.c_str(), X_OK) != 0) throw std::runtime_error("pasta helper not found at " + bin +
|
||||
" (install pasta or use --net none)");
|
||||
pid_ = ::fork();
|
||||
if (pid_ < 0) throw SysError("fork pasta");
|
||||
if (pid_ == 0) {
|
||||
::prctl(PR_SET_PDEATHSIG, SIGKILL);
|
||||
::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1);
|
||||
int nul = ::open("/dev/null", O_WRONLY);
|
||||
if (nul >= 0) { ::dup2(nul, 1); ::dup2(nul, 2); }
|
||||
std::string target = std::to_string(targetPid);
|
||||
::execlp("pasta", "pasta", "--foreground", "--quiet", "--config-net", target.c_str(), static_cast<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.
|
||||
|
||||
+19
-9
@@ -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
|
||||
|
||||
+5
-3
@@ -47,9 +47,10 @@ constexpr std::size_t kMaxEtcCopyFiles = 50000;
|
||||
constexpr std::uint64_t kMaxEtcCopyBytes = 256ULL << 20;
|
||||
|
||||
// Id mapping: container ids 0..kMaxIdRange. 0 maps to the invoking user, the rest to the subuid range.
|
||||
// Helpers are invoked by absolute path (no PATH lookup) to avoid RCE via a poisoned PATH.
|
||||
constexpr std::uint32_t kMaxIdRange = 65536;
|
||||
constexpr std::string_view kNewUidMap = "newuidmap";
|
||||
constexpr std::string_view kNewGidMap = "newgidmap";
|
||||
constexpr std::string_view kNewUidMap = "/usr/bin/newuidmap";
|
||||
constexpr std::string_view kNewGidMap = "/usr/bin/newgidmap";
|
||||
|
||||
// Images
|
||||
constexpr std::size_t kMaxImages = 16;
|
||||
@@ -71,7 +72,8 @@ constexpr std::string_view kUbuntuKeyUrl =
|
||||
constexpr std::string_view kUbuntuKeyFpr = "843938DF228D22F7B3742BC0D94AA3F0EFE21092";
|
||||
|
||||
// Networking
|
||||
constexpr std::string_view kSlirpBinary = "slirp4netns";
|
||||
constexpr std::string_view kSlirpBinary = "/usr/bin/slirp4netns";
|
||||
constexpr std::string_view kPastaBinary = "/usr/bin/pasta";
|
||||
constexpr std::string_view kSlirpDns = "10.0.2.3";
|
||||
constexpr std::string_view kSlirpTapName = "tap0";
|
||||
|
||||
|
||||
+65
-7
@@ -116,6 +116,8 @@ ProcResult runProcess(const std::vector<std::string>& argv, bool capture, bool q
|
||||
int nul = ::open("/dev/null", O_WRONLY);
|
||||
if (nul >= 0) { ::dup2(nul, 2); if (!capture) ::dup2(nul, 1); }
|
||||
}
|
||||
// Never trust the caller's PATH: helpers are found only in system directories.
|
||||
::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1);
|
||||
std::vector<char*> a;
|
||||
for (const auto& x : argv) a.push_back(const_cast<char*>(x.c_str()));
|
||||
a.push_back(nullptr);
|
||||
@@ -171,10 +173,11 @@ void mirrorRec(const fs::path& lower, const fs::path& upper, SeedStats& st, std:
|
||||
void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_t depth, const SkipFn& skip) {
|
||||
if (depth > spec::kMaxTreeDepth) throw std::runtime_error("tree too deep: " + from.string());
|
||||
std::error_code ec;
|
||||
int dirfd = ::open(from.c_str(), O_PATH | O_DIRECTORY | O_CLOEXEC);
|
||||
fs::directory_iterator it(from, ec), end;
|
||||
if (ec) { ++st.skipped; return; }
|
||||
if (ec) { ++st.skipped; if (dirfd >= 0) ::close(dirfd); return; }
|
||||
for (; it != end; it.increment(ec)) {
|
||||
if (ec) { ++st.skipped; return; }
|
||||
if (ec) { ++st.skipped; continue; }
|
||||
struct stat sb;
|
||||
const fs::path& src = it->path();
|
||||
fs::path dst = to / src.filename();
|
||||
@@ -188,9 +191,13 @@ void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_
|
||||
++st.made;
|
||||
copyRec(src, dst, st, depth + 1, skip);
|
||||
} else if (S_ISLNK(sb.st_mode)) {
|
||||
char buf[4096];
|
||||
ssize_t n = (dirfd >= 0) ? ::readlinkat(dirfd, src.filename().c_str(), buf, sizeof buf - 1) : -1;
|
||||
if (n < 0) { ++st.skipped; continue; }
|
||||
buf[n] = '\0';
|
||||
std::error_code e2;
|
||||
fs::remove(dst, e2);
|
||||
fs::create_symlink(fs::read_symlink(src), dst);
|
||||
fs::create_symlink(std::string(buf, static_cast<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 +212,7 @@ void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_
|
||||
++st.skipped;
|
||||
}
|
||||
}
|
||||
if (dirfd >= 0) ::close(dirfd);
|
||||
}
|
||||
|
||||
struct InodeHash {
|
||||
@@ -262,9 +270,16 @@ SeedStats copyEntries(const fs::path& from, const fs::path& to, const std::vecto
|
||||
SeedStats s = copyTree(src, dst, skip);
|
||||
total.made += s.made; total.skipped += s.skipped; total.bytes += s.bytes;
|
||||
} else if (S_ISLNK(sb.st_mode)) {
|
||||
fs::path parent = src.parent_path();
|
||||
int pfd = ::open(parent.c_str(), O_PATH | O_DIRECTORY | O_CLOEXEC);
|
||||
char buf[4096];
|
||||
ssize_t n = (pfd >= 0) ? ::readlinkat(pfd, src.filename().c_str(), buf, sizeof buf - 1) : -1;
|
||||
if (pfd >= 0) ::close(pfd);
|
||||
if (n < 0) { ++total.skipped; continue; }
|
||||
buf[n] = '\0';
|
||||
std::error_code ec;
|
||||
fs::remove(dst, ec);
|
||||
fs::create_symlink(fs::read_symlink(src), dst);
|
||||
fs::create_symlink(std::string(buf, static_cast<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 +327,29 @@ std::uint64_t diskUsage(const fs::path& p, std::size_t& skipped) {
|
||||
}
|
||||
|
||||
void mkdirs(const fs::path& p, mode_t mode) {
|
||||
std::error_code ec;
|
||||
fs::create_directories(p, ec);
|
||||
if (ec) throw std::runtime_error("mkdir " + p.string() + ": " + ec.message());
|
||||
if (p.empty()) return;
|
||||
std::vector<std::string> comps;
|
||||
for (const auto& part : p) {
|
||||
std::string s = part.string();
|
||||
if (s.empty() || s == ".") continue;
|
||||
if (s == "..") throw std::runtime_error("mkdir " + p.string() + ": '..' not allowed");
|
||||
comps.push_back(s);
|
||||
}
|
||||
if (comps.empty()) return;
|
||||
bool absolute = p.is_absolute();
|
||||
int base = ::open(absolute ? "/" : ".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
|
||||
if (base < 0) throw SysError("mkdir " + p.string());
|
||||
Fd guard(base);
|
||||
for (std::size_t i = 0; i < comps.size(); ++i) {
|
||||
bool last = (i + 1 == comps.size());
|
||||
mode_t m = last ? mode : 0755;
|
||||
if (::mkdirat(guard.get(), comps[i].c_str(), m) != 0 && errno != EEXIST) {
|
||||
throw SysError("mkdir " + p.string());
|
||||
}
|
||||
int nfd = ::openat(guard.get(), comps[i].c_str(), O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (nfd < 0) throw SysError("mkdir " + p.string() + " (not a directory or symlink)");
|
||||
guard.reset(nfd);
|
||||
}
|
||||
::chmod(p.c_str(), mode);
|
||||
}
|
||||
|
||||
@@ -358,4 +393,27 @@ std::size_t countEntries(const fs::path& p, std::size_t cap) {
|
||||
}
|
||||
|
||||
} // namespace fsutil
|
||||
|
||||
std::string resolveBinary(const std::string& name) {
|
||||
if (name.find('/') != std::string::npos) return name;
|
||||
// The state dir's bin/ wins: it is the trusted configuration location (0700, like /etc) and lets tests
|
||||
// or a user override a helper without touching system directories. Never the caller's PATH.
|
||||
std::string base;
|
||||
if (const char* h = ::getenv("DOOS_HOME"); h && *h) base = h;
|
||||
else if (const char* x = ::getenv("XDG_DATA_HOME"); x && *x) base = std::string(x) + "/doos";
|
||||
else if (const char* home = ::getenv("HOME"); home && *home) base = std::string(home) + "/.local/share/doos";
|
||||
if (!base.empty()) {
|
||||
std::string p = base + "/bin/" + name;
|
||||
struct stat st;
|
||||
if (::stat(p.c_str(), &st) == 0 && S_ISREG(st.st_mode) && ::access(p.c_str(), X_OK) == 0) return p;
|
||||
}
|
||||
for (const char* dir : {"/usr/local/bin", "/usr/bin", "/usr/local/sbin", "/usr/sbin", "/bin", "/sbin"}) {
|
||||
std::string p = std::string(dir) + "/" + name;
|
||||
struct stat st;
|
||||
if (::stat(p.c_str(), &st) == 0 && S_ISREG(st.st_mode) && ::access(p.c_str(), X_OK) == 0) return p;
|
||||
}
|
||||
throw std::runtime_error("required helper '" + name +
|
||||
"' not found in the state dir's bin/, /usr/local/bin, /usr/bin, /usr/sbin, /bin or /sbin (install it)");
|
||||
}
|
||||
|
||||
} // namespace doos
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-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
|
||||
@@ -150,9 +150,9 @@ h() { "$DOOS" attach lim -- python3 -c "$1" 2>&1; }
|
||||
"$DOOS" attach lim -- sh -c 'gcc --version >/dev/null && curl -sS -o /dev/null https://example.com && git --version >/dev/null && python3 -c "import ssl" && ping -c1 -W3 1.1.1.1 >/dev/null' && ok "common tools work under hardening" || bad "common tools work under hardening"
|
||||
[ "$("$DOOS" attach lim -- sh -c 'ls /etc | wc -l')" -lt 80 ] && ok "etc is an allowlist" || bad "etc is an allowlist"
|
||||
"$DOOS" attach lim -- sh -c 'ls /etc/shadow /etc/sudoers /etc/ssh/ssh_host_rsa_key' >/dev/null 2>&1 && bad "etc: no credentials" || ok "etc: no credentials"
|
||||
"$DOOS" get_or_create --hardening relaxed rx >/dev/null 2>&1
|
||||
"$DOOS" attach rx -- sh -c 'unshare -U true && grep -q "^Seccomp:.0" /proc/self/status' >/dev/null 2>&1 && ok "relaxed: no seccomp, nested userns allowed" || bad "relaxed: no seccomp, nested userns allowed"
|
||||
check "hardening fixed at creation" bash -c "! '$DOOS' get_or_create --hardening relaxed lim"
|
||||
"$DOOS" get_or_create --hardening relaxed --i-understand rx >/dev/null 2>&1
|
||||
"$DOOS" attach rx --i-understand -- sh -c 'unshare -U true && grep -q "^Seccomp:.0" /proc/self/status' >/dev/null 2>&1 && ok "relaxed: no seccomp, nested userns allowed" || bad "relaxed: no seccomp, nested userns allowed"
|
||||
check "hardening fixed at creation" bash -c "! '$DOOS' get_or_create --hardening relaxed --i-understand lim"
|
||||
"$DOOS" delete --yes lim >/dev/null; "$DOOS" delete --yes rx >/dev/null
|
||||
|
||||
# ---- pasta network mode
|
||||
@@ -204,12 +204,15 @@ echo "--- signatures"
|
||||
"$DOOS" image add alpine 2>&1 | grep -q "signature verified" && ok "alpine signature verified against pinned key" || bad "alpine signature verified against pinned key"
|
||||
"$DOOS" image delete --yes alpine >/dev/null
|
||||
shim=$(mktemp -d)
|
||||
mkdir -p "$DOOS_HOME/bin"
|
||||
printf '#!/bin/sh\ncase "$*" in\n *--with-colons*) echo "fpr:::::::::0482D84022F52DF1C4E7CD43293ACD0907D9495A:";;\n *--verify*) echo "[GNUPG:] GOODSIG AAAAAAAAAAAAAAAA M"; echo "[GNUPG:] VALIDSIG 1111111111111111111111111111111111111111 2026-01-01 1 0 4 0 1 10 00 1111111111111111111111111111111111111111";;\nesac\nexit 0\n' > $shim/gpg; chmod +x $shim/gpg
|
||||
PATH=$shim:$PATH "$DOOS" image add alpine >/dev/null 2>&1 && bad "signature by the wrong key refused" || ok "signature by the wrong key refused"
|
||||
rm -f "$DOOS_HOME/bin/gpg"; cp $shim/gpg "$DOOS_HOME/bin/gpg"
|
||||
"$DOOS" image add alpine >/dev/null 2>&1 && bad "signature by the wrong key refused" || ok "signature by the wrong key refused"
|
||||
[ ! -e "$DOOS_HOME/images/alpine" ] && ok "refused signature leaves no image" || bad "refused signature leaves no image"
|
||||
sed -i 's/GOODSIG AAAAAAAAAAAAAAAA M/BADSIG 293ACD0907D9495A N/' $shim/gpg
|
||||
PATH=$shim:$PATH "$DOOS" image add alpine >/dev/null 2>&1 && bad "BADSIG refused" || ok "BADSIG refused"
|
||||
rm -rf $shim
|
||||
cp $shim/gpg "$DOOS_HOME/bin/gpg"
|
||||
"$DOOS" image add alpine >/dev/null 2>&1 && bad "BADSIG refused" || ok "BADSIG refused"
|
||||
rm -rf $shim; rm -f "$DOOS_HOME/bin/gpg"
|
||||
"$DOOS" attach --image debian d1 -- sh -c '. /etc/os-release; echo $ID; apt-get update >/dev/null 2>&1; apt-get install -y --no-install-recommends cowsay >/dev/null 2>&1; dpkg --audit && /usr/games/cowsay ok >/dev/null' 2>/dev/null | grep -q debian && ok "debian image: apt install completes" || bad "debian image: apt install completes"
|
||||
"$DOOS" delete --yes d1 >/dev/null; "$DOOS" image delete --yes debian >/dev/null
|
||||
|
||||
|
||||
@@ -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