Skip to content

Measure the privileged kernel surface, fix what it found, align the repo - #2

Merged
umars28 merged 3 commits into
mainfrom
bench/attack-surface
Sep 7, 2026
Merged

Measure the privileged kernel surface, fix what it found, align the repo#2
umars28 merged 3 commits into
mainfrom
bench/attack-surface

Conversation

@umars28

@umars28 umars28 commented Sep 7, 2026

Copy link
Copy Markdown
Member

Three things, in the order they happened.

Measure the creation path

scripts/hap-bench.sh counts the distinct host kernel functions a runtime traverses while it holds
root — the window where every privileged operation happens, and the one axis published runtime
comparisons do not cover. docs/attack-surface.md carries the method and the results.

runtime run vol exec tty
mars 1542 1542 494 1545
crun 1.14.1 2030 2030 889 1984
runc 1.5.1 2361 2355 1134 2370

Not because mars skips work: namespaces, pivot_root, cgroup setup and capabilities all appear at
parity. Of the 1000 functions runc reaches and mars does not, 10 are thread or scheduler
functions and 301 are file and /proc traversal.

Three methodology faults each produced a plausible wrong answer before the assertions caught them —
per-PID trace filters lose crun's child, global tracing charges a runtime for its neighbours, and a
runtime that exits non-zero still yields a number. The script asserts rather than trusts.

Fix what it found

Widening the workload to cover seccomp exposed a blocker: mars refused the profile Podman and
CRI-O ship, needing 59 syscall names removed first where runc and crun removed none.

The failing names all look absent on aarch64, so the first reading was an unresolvable-syscall
problem. It was not. libseccomp returns EACCES for a rule whose action equals the filter's
default action, and these profiles deny by default and then spell out denials — every such rule is
redundant by construction. mars now tolerates EACCES only when the rule's verdict equals the
default, which cannot change what the filter permits. A deny rule under an allow-by-default filter
differs from the default and never takes that path; a test asserts it.

Align the repo after the rename

Description, topics, homepage and a Pages usage guide, matching marstack-cloud, -secrets and
-access. The theme is vendored rather than lifted by make site, because this runtime is a CLI
with no console and no Makefile — so a workflow step diffs both stylesheets against
marstack-access to catch the drift that vendoring invites.

Still missing next to the other three: CHANGELOG.md, CONTRIBUTING.md, SECURITY.md,
CODE_OF_CONDUCT.md, CODEOWNERS, Makefile, release.yml.

Verified

cargo test --lib 106 passed, cargo clippy --all-targets with -D warnings clean, cargo fmt --check clean, shellcheck -S warning clean, and mars accepting the unmodified 442-name profile.
All on aarch64 — this PR is the first time CI sees any of it.

The rename to marstack-container left this repo without what the other three
carry: a description in the house form, topics, a homepage, and a usage guide
on GitHub Pages.

The theme is vendored rather than copied by a build step. marstack-cloud,
-secrets and -access all run `make site` to lift meridian.css out of
internal/console/assets, but this runtime is a CLI with no console and no
Makefile, so site/meridian.css and site/site.css are committed byte-identical
to the copies those repos ship. A workflow step diffs them against
marstack-access on every run, because vendoring trades a build-time guarantee
for drift that nothing would otherwise catch.

pages.yml keeps the three guards the other repos use — every referenced asset
present, every anchor pointing at a section that exists, every theme token the
stylesheet uses actually defined — since a broken link on a page nobody
rebuilds is invisible until someone clicks it.
The integration suite has never passed in CI. 96 of 118 assertions failed
with the same line:

  ./tests/run-integration.sh: line 84: target/debug/mars: No such file

run_in() enters the bundle directory before invoking the runtime:

  (cd "$dir" && "$MARS" run "$@")

CI passes MARS=target/debug/mars, and a relative path cannot survive that cd.
The auto-detection above it has the same defect, since every candidate it
tries is relative too, so a local run only worked when the caller happened to
pass an absolute path.

Resolve MARS to an absolute path once, and refuse up front if it is not
executable rather than reporting the same missing-file error ninety-six times.

This is what has been hiding the seccomp fix from CI: the suite covers seccomp
errno mapping and no_new_privs, and none of those assertions could run.
With the runtime path fixed the suite finally ran in CI, and hung for nineteen
minutes on "memory.max and OOM kill" until the job was cancelled.

The test sets memory.limit but not memory.swap, so memory.swap.max keeps its
default of max. The awk loop writes each page once and never touches it again,
so the kernel satisfies memory.max by pushing those cold pages to swap instead
of killing anything. Caught in the act on a host with swap:

  memory.current  33296384
  memory.max      33554432
  memory.swap.max max
  memory.events   oom 0  oom_kill 0
  SwapFree        fell by ~353MB and kept falling

The awk process had been running for over ten minutes at that point. GitHub
runners have swap; the Lima dev VM has none, which is why this only ever hung
in CI.

Setting memory.swap equal to memory.limit makes mars write memory.swap.max=0,
so the cgroup has nowhere to spill and the kill is deterministic. It also means
the second assertion — that the runtime names memory.max as the cause from
memory.events — is exercised for the first time.

A first attempt to reproduce this with a C malloc loop was misleading: it
rewrites every page, so the pages stay hot, and it OOMs before swap is ever
reached. The allocation pattern is the whole mechanism.

timeout-minutes on the integration job so the next hang costs twenty minutes
rather than the six-hour default.

Full suite on a host with swap: 128 passed, 0 failed.
@umars28
umars28 merged commit c8576b1 into main Sep 7, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant