Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion .github/workflows/freebsd-build-lite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,54 @@ jobs:
# (daemon/*.cpp #include <httplib.h>); ssl/crypto ship in
# FreeBSD base. Added 1.1.21 so the daemon link is covered
# in PR CI — see the "Link daemon binaries" step below.
sudo pkg install -y pkgconf yaml-cpp rang kyua gmake cpp-httplib
#
# Hard build deps — these MUST install or the job is dead.
sudo pkg install -y pkgconf yaml-cpp rang gmake cpp-httplib

# ATF link libraries. The Makefile links every unit test with
# `-L/usr/local/lib -latf-c++ -latf-c`. FreeBSD 14.x base ships
# the HEADER (/usr/include/atf-c++.hpp) and kyua, but its ATF
# libraries are PRIVATE (/usr/lib/private/libprivateatf-*) and
# cannot be resolved as -latf-c++ — run 34233361523 compiled
# everything and then died at exactly that link step. So
# devel/atf (alive: 0.23 on quarterly) is a HARD dep, on its
# OWN line: bundling it with the dead kyua package made pkg
# abort the whole transaction and atf silently never installed.
sudo pkg install -y atf

# kyua: the devel/kyua port was DELETED 2026-05-07 ("part of
# the base in all supported versions — Kyua's evolution
# happens in the base", D47473), which is what first broke
# this job (PR #228: "No packages available to install
# matching 'kyua'"). FreeBSD 14.x ships /usr/bin/kyua — verified
# present in this image. pkg is tried only for older images.
sudo pkg install -y kyua 2>/dev/null \
|| echo "kyua pkg gone (deleted 2026-05, lives in base) — using /usr/bin/kyua"
echo "--- kyua/ATF preflight ---"
command -v kyua || true
ls -la /usr/bin/kyua /usr/local/bin/kyua 2>/dev/null || true
ls -la /usr/include/atf-c++.hpp /usr/local/include/atf-c++.hpp 2>/dev/null || true
ls -la /usr/lib/libatf-c++* /usr/local/lib/libatf-c++* 2>/dev/null || true
# If neither base nor pkg has them, show what pkg DOES offer
# (a rename shows up here) and fail with a clear error.
if ! command -v kyua >/dev/null 2>&1; then
echo "pkg search for kyua/atf (to catch a package rename):"
pkg search -q kyua 2>/dev/null || true
pkg search -q atf 2>/dev/null || true
echo "::error::kyua not found in base or pkg — see search output above"
exit 1
fi
if [ ! -f /usr/include/atf-c++.hpp ] && [ ! -f /usr/local/include/atf-c++.hpp ]; then
echo "::error::atf-c++.hpp not found in base or pkg"
exit 1
fi
# The check that would have caught run 34233361523 up front:
# a header alone is not enough, the LINKABLE lib must exist.
ls -la /usr/local/lib/libatf-c++.so /usr/lib/libatf-c++.so 2>/dev/null || true
if [ ! -e /usr/local/lib/libatf-c++.so ] && [ ! -e /usr/lib/libatf-c++.so ]; then
echo "::error::linkable libatf-c++.so not found — devel/atf must be installed (base only has private copies)"
exit 1
fi
echo "::endgroup::"

echo "::group::System info"
Expand Down
56 changes: 50 additions & 6 deletions .github/workflows/freebsd-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,56 @@ jobs:
set -ex

echo "::group::Install dependencies"
# `atf` ships /usr/local/bin/atf-sh, the interpreter for our
# functional test scripts (#!/usr/bin/env atf-sh). It is NOT
# pulled in as a kyua dependency, so an explicit install
# avoids the "Invalid header for test case list; got ''"
# broken result.
sudo pkg install -y pkgconf yaml-cpp rang kyua atf gmake cpp-httplib
# Hard build deps — these MUST install or the job is dead.
sudo pkg install -y pkgconf yaml-cpp rang gmake cpp-httplib

# ATF: HARD dep, on its own line. It provides (a) the LINKABLE
# /usr/local/lib/libatf-c{,++}.so the Makefile's `-latf-c++
# -latf-c` needs — base only ships PRIVATE copies
# (/usr/lib/private/libprivateatf-*) the linker cannot resolve,
# which is exactly where run 34233361523 died after compiling
# everything — and (b) atf-sh, the interpreter for our
# functional test scripts (#!/usr/bin/env atf-sh); without it
# kyua reports "Invalid header for test case list; got ''".
# devel/atf is alive (0.23, quarterly). Separate line on
# purpose: bundling it with the dead kyua package made pkg
# abort the whole transaction and atf silently never installed.
sudo pkg install -y atf

# kyua: the devel/kyua port was DELETED 2026-05-07 ("part of
# the base in all supported versions — Kyua's evolution
# happens in the base", D47473) — the original PR #228 break
# ("No packages available to install matching 'kyua'").
# FreeBSD 14.x ships /usr/bin/kyua. pkg tried only for older
# images; base is the real source.
sudo pkg install -y kyua 2>/dev/null \
|| echo "kyua pkg gone (deleted 2026-05, lives in base) — using /usr/bin/kyua"
echo "--- kyua/ATF preflight ---"
command -v kyua || true
command -v atf-sh || true
ls -la /usr/include/atf-c++.hpp /usr/local/include/atf-c++.hpp 2>/dev/null || true
if ! command -v kyua >/dev/null 2>&1; then
echo "pkg search for kyua/atf (to catch a package rename):"
pkg search -q kyua 2>/dev/null || true
pkg search -q atf 2>/dev/null || true
echo "::error::kyua not found in base or pkg — see search output above"
exit 1
fi
if ! command -v atf-sh >/dev/null 2>&1; then
echo "::error::atf-sh not found in base or pkg (functional tests need it)"
exit 1
fi
if [ ! -f /usr/include/atf-c++.hpp ] && [ ! -f /usr/local/include/atf-c++.hpp ]; then
echo "::error::atf-c++.hpp not found in base or pkg"
exit 1
fi
# A header alone is not enough — the LINKABLE lib must exist
# (the check that would have caught run 34233361523 up front).
ls -la /usr/local/lib/libatf-c++.so /usr/lib/libatf-c++.so 2>/dev/null || true
if [ ! -e /usr/local/lib/libatf-c++.so ] && [ ! -e /usr/lib/libatf-c++.so ]; then
echo "::error::linkable libatf-c++.so not found — devel/atf must be installed (base only has private copies)"
exit 1
fi
echo "::endgroup::"

echo "::group::System info"
Expand Down
78 changes: 78 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,84 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## [1.1.25] — 2026-07-11

**Security & robustness: five fixes from a third-pass audit of the
modules no earlier pass had covered (GUI/session, VM stack, lifecycle/
runtime, audit/util/parsers).**

- **Infinite loop in `getAllJails(crateOnly=true)` — `lib/jail_query.cpp`
(HIGH, DoS).** The `lastjid` cursor advance sat *after* the crateOnly
filter, so a non-crate jail hit `continue` without moving the cursor
and the next `jailparam_get` returned the very same jail forever —
100% CPU the moment any foreign jail (bastille/pot/plain `jail(8)`)
coexisted with crate. That wedged every crateOnly caller — `crate
top/clean/doctor/info/list/console/stack` — **and the crated
control-socket jail listing**. No attacker input needed. The cursor
now advances before the filter.

- **Command injection into a root `sh -c` via stack-file fields —
`lib/stack.cpp` (HIGH).** The container name, its static IP, and a
network's `gateway` were interpolated unescaped into `printf '…' >>
/etc/hosts` / `printf 'nameserver …' > /etc/resolv.conf` shell
fragments stored in `run:before-start-services` and executed by
`/bin/sh -c` as root. A container keyed `x';touch /tmp/pwned;'` ran
arbitrary commands as root on `crate stack up`. New
`StackPure::validateStackName` (`[A-Za-z0-9._-]`, ≤64, no leading `-`)
and `validateStackIp` (charset-gated `inet_pton`, CIDR tolerated) are
applied at parse time **and** re-applied as a sink-guard right where
each value enters the shell string.

- **Path traversal via stack network name → root write + delete —
`lib/stack.cpp` (HIGH).** `confDir = dnsBaseDir()/dns-<network.name>`
was `create_directories`'d, written (`unbound.conf`), passed to
`unbound -c`, and `remove_all`'d as root with an unvalidated YAML key.
A name of `../../../etc/cron.d` let root write a config under an
attacker-chosen path and recursively delete an attacker-chosen tree.
Closed by the same `validateStackName` (no `/`, no `..`).

- **Predictable `/tmp` screenshot files → symlink attack —
`lib/gui.cpp` (HIGH when run as root).**
`/tmp/crate-screenshot-<displayNum>.{ppm,xwd}` (displayNum guessable,
allocation starts at 10) was opened via `fopen`/`xwd -out` with no
`O_EXCL`/`O_NOFOLLOW` in world-writable `/tmp`; `gui screenshot` runs
with root's EUID (the registry is root-only), so a local user could
pre-plant a symlink and have root truncate/overwrite an arbitrary
file (CWE-59). Scratch files now live in a private `mkdtemp(3)`
directory (0700, random name) removed on every exit path.

- **`Util::safePath` over-rejected every path when the prefix ends in
`/` — `lib/util_pure.cpp` (MED, correctness).** The separator check
demanded `canonical[prefix.size()] == '/'`, but a prefix that already
ends in `/` (the root prefix `"/"` being the degenerate case) has
consumed that separator, so the index points at a filename char and
the check always failed. Only the separator is now demanded when the
prefix does not supply it. This silently made `socketProxy.share`
abort on every real socket path.

- **`socketProxy` jail-side confinement — `lib/run_services.cpp`.** With
`safePath` fixed, the `share` loop's `safePath(sock, "/", …)` would
have passed everything (prefix `/` cannot confine, and its return was
discarded while the raw `..`-bearing path still reached `J()`), and
the `proxy` loop had no guard at all. Both now validate the
**concatenated** jail-side path stays under `jailPath` — the same
guard `run.cpp` applies to `dirsShare`. This closes the socket_proxy
item deferred in 1.1.22. (`proxy.host` is the operator's host-side
connect target and is deliberately not jail-confined.)

New `stack_test` cases (name/IP injection + traversal rejection, clean
values accepted) and `util_security_test` cases (root prefix accepts,
trailing-slash prefix still rejects siblings). The `jail_query`,
`gui`, and `run_services` changes are runtime-only, compile-gated by
the FreeBSD build.

Also noted, not changed: `lib/vm_spec.cpp` / `vm_run.cpp` / `vm_stack.cpp`
carry libvirt-XML injection and path-traversal sinks (`vmName`,
`vol.tag`, `disk`, `sharedBridge`) that would be HIGH if reachable —
but `createVm`/`parseVmOptions`/`generateDomainXml` have no callers
today. Recorded in `TODO` so they are hardened before that code is
ever wired up.

## [1.1.24] — 2026-07-07

**Robustness: two low-severity fixes from the second-pass audit that
Expand Down
47 changes: 33 additions & 14 deletions TODO
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ unit suite can't provide, so they are NOT being fixed blind):
full FreeBSD workflow (or a self-hosted runner) to validate.

Deferred from the 2026-07 second-pass audit (its four clear, testable
findings shipped as 1.1.22; the auth-locality finding was fixed in
1.1.23 — see below; these two remain because they touch the
concurrency/confinement paths and want on-hardware validation before
shipping — do NOT fix blind):
findings shipped as 1.1.22; auth-locality fixed in 1.1.23 and
socket_proxy confinement in 1.1.25 — see below; ONE item remains
because it is a concurrency race the pure unit suite cannot exercise
and wants a real multi-process test on a FreeBSD host — do NOT fix
blind):

[FIXED in 1.1.23] daemon/auth.cpp connection-locality decided from a
client-supplyable REMOTE_ADDR header. Replaced with a non-spoofable
Expand All @@ -70,16 +71,34 @@ shipping — do NOT fix blind):
not exercised by the pure unit suite — validate with a real
multi-process race on a FreeBSD host.

* (security, MED) lib/run_services.cpp socket_proxy: the `share` loop
runs each socket path through Util::safePath, but the `proxy` loop
does not — `entry.jail` flows into `create_directories(J(jailParent))`
and a `UNIX-LISTEN:J(entry.jail)` socat bind, and `entry.host` into
`UNIX-CONNECT:`. A `..`-bearing jail path escapes the jail tree for a
root-owned dir/socket. The correct guard needs care: safePath("/") as
used by the share loop is itself weak (it canonicalizes but the prefix
check never rejects, and the return value is discarded), so this wants
an explicit `..`/traversal rejection reviewed against how J() prefixes
the path — not a blind copy of the share-loop call.
[FIXED in 1.1.25] lib/run_services.cpp socket_proxy confinement. The
earlier note here mis-diagnosed safePath("/"): it did not "never
reject" — a trailing-slash boundary bug in util_pure.cpp made it
reject EVERY real path (so `share` was unusable), while its canonical
return value was discarded anyway (so it could not have confined the
raw ".." path that still reached J()). Both loops now validate the
concatenated jail-side path stays under jailPath, exactly as run.cpp
does for dirsShare; safePath's trailing-slash bug is fixed and
unit-tested.

Recorded from the 2026-07 third-pass audit — LATENT, no caller today,
harden BEFORE wiring this code up:

* (security, HIGH-if-reachable) lib/vm_spec.cpp:97-184
generateDomainXml embeds `name`, `opts.disk`, `opts.sharedBridge`,
`vol.hostPath`, `vol.tag` unescaped into the libvirt domain XML
handed to virDomainCreateXML as root (vm_run.cpp:89, bhyve:///system).
A value containing `'/>…` injects arbitrary <disk>/<filesystem>
devices (e.g. mount host `/` into the guest). vm_run.cpp:250-388
(configureVmDns / generateCloudInitFor9p) and vm_stack.cpp:88
(registerVmInStack) likewise build root-written paths and an
/etc/hosts line from `vmName` / `vol.tag` with no traversal or
newline check. `createVm` / `parseVmOptions` / `generateDomainXml` /
`configureVmDns` / `registerVmInStack` have NO callers in the tree —
dead code — so not exploitable today. XML-escape every field (or
validate to a tight charset) and reuse StackPure::validateStackName
for vmName/vol.tag before any of this gets a caller. memoryToKiB
(vm_spec.cpp:84) also has an unchecked `val*1024*1024` overflow.

=== High priority — blocking production use ===

Expand Down
4 changes: 2 additions & 2 deletions cli/args.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -753,7 +753,7 @@ Args parseArguments(int argc, char** argv, unsigned &processed) {
args.noColor = true;
break;
} else if (strEq(argv[a], "--version")) {
std::cout << "crate 1.1.24" << std::endl;
std::cout << "crate 1.1.25" << std::endl;
exit(0);
} else if (auto argShort = isShort(argv[a])) {
switch (argShort) {
Expand All @@ -764,7 +764,7 @@ Args parseArguments(int argc, char** argv, unsigned &processed) {
args.logProgress = true;
break;
case 'V':
std::cout << "crate 1.1.24" << std::endl;
std::cout << "crate 1.1.25" << std::endl;
exit(0);
default:
err("unsupported short option '%s'", argv[a]);
Expand Down
4 changes: 2 additions & 2 deletions docs/trust-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
operators on one machine) and contributors extending the privileged
surface.

**Applies to:** 1.1.24 (rootless model + per-tenant authz series 1.1.12 →
**Applies to:** 1.1.25 (rootless model + per-tenant authz series 1.1.12 →
1.1.17 covering every privops verb that carries an operator-controlled
ownership signal). For the ≤ 0.9.x setuid model and the migration, see
[`rootless-migration.md`](rootless-migration.md).
Expand Down Expand Up @@ -62,7 +62,7 @@ surface; 1.0.0 removed the setuid bit (`Makefile`, comment at the
operator and delegates privileged operations to crated(8)"*).

The single-trust-domain property did **not** disappear — it relocated.
Reasoning about isolation on 1.1.24 means reasoning about who can reach
Reasoning about isolation on 1.1.25 means reasoning about who can reach
**privops**, not who can run `crate(1)`.

---
Expand Down
4 changes: 2 additions & 2 deletions docs/trust-model.uk.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
(кілька операторів на одній машині), і контрибʼютори, які розширюють
привілейовану поверхню.

**Стосується:** 1.1.24 (rootless-модель + серія per-tenant authz 1.1.12 →
**Стосується:** 1.1.25 (rootless-модель + серія per-tenant authz 1.1.12 →
1.1.17 покриває кожен privops-верб з operator-controlled ownership-
сигналом). Про ≤ 0.9.x setuid-модель і міграцію див.
[`rootless-migration.md`](rootless-migration.md).
Expand Down Expand Up @@ -62,7 +62,7 @@ privops-сокета, ніколи admin-токен і ніколи Unix-сок
privileged operations to crated(8)»*).

Властивість «єдиний домен довіри» **не зникла** — вона переїхала.
Міркувати про ізоляцію на 1.1.24 — це міркувати про те, хто має доступ
Міркувати про ізоляцію на 1.1.25 — це міркувати про те, хто має доступ
до **privops**, а не хто може запустити `crate(1)`.

---
Expand Down
27 changes: 24 additions & 3 deletions lib/gui.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
#include <unistd.h>

#include <algorithm>
#include <cerrno>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <iomanip>
#include <iostream>
#include <sstream>
Expand Down Expand Up @@ -337,9 +341,26 @@ static bool guiScreenshot(const Args &args) {
isPnmOutput = true;
}

// 1.1.25: scratch files go into a private mkdtemp(3) directory — mode
// 0700, random name — instead of the old predictable
// /tmp/crate-screenshot-<displayNum>.{ppm,xwd}. displayNum is small
// and guessable (allocation starts at 10) and this command runs with
// root's EUID (the GUI registry is root-only), so a local user could
// pre-plant a symlink at that name and have root truncate/overwrite an
// arbitrary file via fopen/`xwd -out` (CWE-59). Nobody else can write
// into our 0700 dir, so no link can be planted inside it. The dir and
// whatever is left in it are removed on every exit path.
char tmpDirTemplate[] = "/tmp/crate-screenshot-XXXXXX";
if (::mkdtemp(tmpDirTemplate) == nullptr)
ERR("screenshot: cannot create scratch directory: " << std::strerror(errno))
const std::string tmpDir = tmpDirTemplate;
RunAtEnd removeTmpDir([tmpDir]() {
std::error_code ec;
std::filesystem::remove_all(tmpDir, ec);
});

if (X11Ops::available()) {
auto pnmTmp = isPnmOutput ? outFile :
STR("/tmp/crate-screenshot-" << e.displayNum << ".ppm");
auto pnmTmp = isPnmOutput ? outFile : (tmpDir + "/screenshot.ppm");
if (!X11Ops::screenshot(dispStr, pnmTmp)) {
// Fall through to the xwd pipeline below; libX11 is linked
// but the display may not be reachable from this process
Expand Down Expand Up @@ -369,7 +390,7 @@ static bool guiScreenshot(const Args &args) {
}

// Fallback: xwd + xwdtopnm + pnmtopng pipeline (pre-0.8.36 path).
auto xwdFile = STR("/tmp/crate-screenshot-" << e.displayNum << ".xwd");
auto xwdFile = tmpDir + "/screenshot.xwd";
try {
Util::execCommand(
{CRATE_PATH_XWD, "-root", "-display", dispStr, "-out", xwdFile},
Expand Down
Loading
Loading