Skip to content

Commit 86df60a

Browse files
authored
Merge pull request #66 from sysprog21/oci-image
Import rootless OCI image
2 parents 239dbba + 00008c5 commit 86df60a

7 files changed

Lines changed: 1336 additions & 26 deletions

File tree

.github/workflows/build-kbox.yml

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
# Build kbox and run full test suite.
22
# Zero root required -- everything runs as an unprivileged user.
33
#
4-
# Parallelism (4 independent jobs, 1 sequential):
5-
# commit-hygiene -- Change-Id + subject format (needs full history)
6-
# lint -- clang-format, newline, security, cppcheck (one apt install)
7-
# unit-tests -- no LKL dependency, ASAN/UBSAN
8-
# build-kbox -- fetches LKL, compiles kbox + guest/stress bins, builds rootfs
9-
# integration -- needs build-kbox artifacts, runs integration + stress tests
4+
# Parallelism (5 independent jobs, 1 sequential):
5+
# commit-hygiene -- Change-Id + subject format (needs full history)
6+
# lint -- clang-format, newline, security, cppcheck (one apt install)
7+
# unit-tests -- no LKL dependency, ASAN/UBSAN
8+
# build-kbox -- fetches LKL, compiles kbox + guest/stress bins, builds rootfs
9+
# oci-image-import -- pulls nginx:alpine via mkrootfs.sh --image, validates the
10+
# libext2fs-based ownership rewrite
11+
# integration -- needs build-kbox artifacts, runs integration + stress tests
1012
#
11-
# commit-hygiene, lint, unit-tests, and build-kbox run in parallel.
12-
# integration-tests waits for build-kbox only.
13+
# All independent jobs run in parallel. integration-tests waits for build-kbox only.
1314
name: Build and Test
1415

1516
on:
@@ -146,6 +147,77 @@ jobs:
146147
tests/stress/*
147148
!tests/stress/*.c
148149
150+
# ---- OCI image import: pull nginx:alpine, validate ownership rewrite ----
151+
# Exercises scripts/oci-pull.py + tools/oci-chown end-to-end. nginx:alpine
152+
# has multi-layer pulls and a /etc/passwd entry for "nginx" (uid=101) even
153+
# though its tar headers are 0:0; the rewrite must restore the on-disk
154+
# owner to 0 (vs the invoking user's UID that mke2fs -d would inherit).
155+
oci-image-import:
156+
runs-on: ubuntu-24.04
157+
timeout-minutes: 5
158+
steps:
159+
- name: Checkout
160+
uses: actions/checkout@v6
161+
162+
- name: Cache apt packages
163+
uses: actions/cache@v5
164+
with:
165+
path: ~/apt-cache
166+
key: apt-oci-${{ runner.os }}-${{ hashFiles('.github/workflows/build-kbox.yml') }}
167+
- name: Install dependencies
168+
run: |
169+
mkdir -p ~/apt-cache
170+
sudo apt-get update
171+
sudo apt-get install -y -o Dir::Cache::Archives=$HOME/apt-cache \
172+
e2fsprogs libext2fs-dev
173+
174+
- name: Build oci-chown helper
175+
run: make -C tools/oci-chown
176+
177+
- name: Pull nginx:alpine with --rewrite-uid
178+
run: |
179+
# pipefail: don't let `tee` mask a mkrootfs.sh failure.
180+
# nounset: catch any typo'd $VAR before it silently expands to "".
181+
set -euo pipefail
182+
ROOTFS=/tmp/nginx-oci.ext4 ./scripts/mkrootfs.sh \
183+
--image=docker://nginx:alpine \
184+
--rewrite-uid \
185+
256 2>&1 | tee /tmp/mkrootfs.log
186+
187+
# Helper must report at least one inode rewrite; without it the
188+
# mke2fs-inherited invoking-user UID would silently leak through.
189+
if ! grep -qE "rewrote [1-9][0-9]* inode" /tmp/mkrootfs.log; then
190+
echo "::error::oci-chown reported no inode rewrites"
191+
exit 1
192+
fi
193+
194+
- name: Verify ownership round-trip
195+
run: |
196+
# /etc/nginx/nginx.conf is a stable file in nginx:alpine. Its tar
197+
# header is uid=0/gid=0, so a successful rewrite ends at User=0.
198+
# Without --rewrite-uid the inode would carry the runner's UID.
199+
# debugfs format: "User: N Group: M Project: P ..."
200+
STAT=$(printf "stat /etc/nginx/nginx.conf\n" \
201+
| debugfs /tmp/nginx-oci.ext4 2>/dev/null \
202+
| awk '/^User:/ {print $2 " " $4; exit}')
203+
OWNER=$(echo "$STAT" | awk '{print $1}')
204+
GROUP=$(echo "$STAT" | awk '{print $2}')
205+
echo "/etc/nginx/nginx.conf User=$OWNER Group=$GROUP"
206+
if [ "$OWNER" != "0" ] || [ "$GROUP" != "0" ]; then
207+
echo "::error::expected User=0/Group=0, got User=$OWNER/Group=$GROUP"
208+
exit 1
209+
fi
210+
211+
# Mode bits must survive the rewrite. /usr/sbin/nginx is +x (mode 0755).
212+
MODE=$(printf "stat /usr/sbin/nginx\n" \
213+
| debugfs /tmp/nginx-oci.ext4 2>/dev/null \
214+
| awk '/^Inode:/ {for (i=1;i<=NF;i++) if ($i=="Mode:") print $(i+1); exit}')
215+
echo "/usr/sbin/nginx Mode=$MODE"
216+
if [ "$MODE" != "0755" ]; then
217+
echo "::error::expected /usr/sbin/nginx mode 0755, got $MODE"
218+
exit 1
219+
fi
220+
149221
# ---- Integration + stress tests: needs kbox binary + rootfs ----
150222
integration-tests:
151223
needs: build-kbox

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,24 @@ make rootfs # host arch
182182
make ARCH=aarch64 CC=aarch64-linux-gnu-gcc rootfs # cross
183183
```
184184

185+
To pull from a Docker [v2 registry](https://distribution.github.io/distribution/spec/api/)
186+
instead, pass `--image=docker://...` to the rootfs script (no
187+
[`docker`](https://www.docker.com/) daemon required, `python3` stdlib
188+
only):
189+
190+
```bash
191+
ROOTFS=alpine.ext4 ./scripts/mkrootfs.sh --image=docker://alpine:3.21
192+
ROOTFS=node.ext4 ./scripts/mkrootfs.sh --image=docker://node:alpine \
193+
--rewrite-uid --size=512
194+
```
195+
196+
`--rewrite-uid` restores OCI tar-header ownership into the ext4 inodes
197+
via [`tools/oci-chown`](tools/oci-chown/) (built on demand, links
198+
against [`libext2fs`](https://e2fsprogs.sourceforge.net/)) and is
199+
required for [`--root-id`](#selecting-an-interception-mode) guests.
200+
See [docs/oci-image-import.md](docs/oci-image-import.md) for the full
201+
pipeline, layer cache, and threat model.
202+
185203
Run a guest binary:
186204

187205
```bash
@@ -229,6 +247,7 @@ Run `./kbox --help` for the full option list.
229247
| Three syscall interception tiers and auto selection | [docs/interception-tiers.md](docs/interception-tiers.md) |
230248
| Internal design: dispatch routing, FD table, shadow FDs, ABI translation | [docs/architecture.md](docs/architecture.md) |
231249
| Threat model and deployment tiers | [docs/security-model.md](docs/security-model.md) |
250+
| Building rootfs images from OCI registries | [docs/oci-image-import.md](docs/oci-image-import.md) |
232251
| Using kbox as an AI agent execution layer | [docs/ai-agents.md](docs/ai-agents.md) |
233252
| Web dashboard and telemetry endpoints | [docs/web-observatory.md](docs/web-observatory.md) |
234253
| GDB workflow and helper commands | [docs/gdb-workflow.md](docs/gdb-workflow.md) |

docs/oci-image-import.md

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# OCI image import
2+
3+
`kbox` can build a rootfs from any OCI image hosted on a Docker
4+
[v2 registry](https://distribution.github.io/distribution/spec/api/),
5+
not just the bundled Alpine minirootfs. Pass `--image=docker://...` to
6+
[`scripts/mkrootfs.sh`](../scripts/mkrootfs.sh) and the script pulls the
7+
image's manifest and layer blobs, applies them to a staging directory,
8+
and feeds the result into `mke2fs -d` exactly like the Alpine path.
9+
10+
The implementation is rootless and depends only on `python3` (stdlib
11+
only) and `e2fsprogs` (already required for `mke2fs`). The optional
12+
`--rewrite-uid` flag adds an in-tree libext2fs helper
13+
([`tools/oci-chown`](../tools/oci-chown/)) for restoring OCI tar-header
14+
uid/gid/mode into ext4 inodes; it is built on demand and only required
15+
when the rootfs will be used with `kbox --root-id`.
16+
17+
## Quick start
18+
19+
```bash
20+
# Pull and build a rootfs from Docker Hub.
21+
ROOTFS=alpine.ext4 ./scripts/mkrootfs.sh --image=docker://alpine:3.21
22+
23+
# Pin to a digest for reproducibility.
24+
ROOTFS=alpine.ext4 ./scripts/mkrootfs.sh \
25+
--image=docker://alpine@sha256:1832327faf04...
26+
27+
# Other registries (host:port supported).
28+
ROOTFS=node.ext4 ./scripts/mkrootfs.sh \
29+
--image=docker://node:alpine --size=512
30+
31+
# Restore OCI tar-header uid/gid/mode (required for --root-id).
32+
ROOTFS=node.ext4 ./scripts/mkrootfs.sh \
33+
--image=docker://node:alpine --rewrite-uid
34+
35+
# Run with kbox.
36+
./kbox -S node.ext4 --root-id -- /usr/bin/node --version
37+
38+
# Manage the layer cache.
39+
python3 ./scripts/oci-pull.py prune # wipe
40+
python3 ./scripts/oci-pull.py prune --keep-bytes=2G # keep newest 2 GB
41+
```
42+
43+
`--image` accepts:
44+
45+
- `docker://NAME[:TAG]``library/` prefix is implied for unscoped
46+
Docker Hub names. Default tag is `latest`.
47+
- `docker://REGISTRY/REPO[:TAG]` — non-Docker-Hub registries
48+
(`quay.io/...`, `ghcr.io/...`, host:port).
49+
- `docker://REPO@sha256:DIGEST` — pin to a content digest for
50+
reproducibility. The digest may name either a single-arch image
51+
manifest (in which case arch selection is a no-op) or an OCI index /
52+
manifest list (in which case `oci-pull.py` still resolves it to the
53+
matching `linux/<arch>` entry, just like a tag).
54+
55+
## Pipeline
56+
57+
```
58+
--image=docker://...
59+
60+
61+
┌─────────────────────────┐
62+
│ scripts/oci-pull.py │ registry pull (urllib + bearer-token)
63+
│ └ manifest list resolve│
64+
│ └ layer fetch │ → cache: $XDG_CACHE_HOME/kbox/oci-layers
65+
│ └ apply (whiteouts, │
66+
│ hardlinks, symlinks)│
67+
└────────────┬────────────┘
68+
│ staging/ (+ optional manifest)
69+
70+
┌─────────────────────────┐
71+
│ mke2fs -d staging │ rootless ext4 build
72+
└────────────┬────────────┘
73+
│ rootfs.ext4
74+
▼ (with --rewrite-uid only)
75+
┌─────────────────────────┐
76+
│ tools/oci-chown │ libext2fs inode rewrite
77+
│ └ ext2fs_namei │ → uid/gid/mode from OCI tar header
78+
│ └ ext2fs_write_inode │
79+
└─────────────────────────┘
80+
```
81+
82+
## Layer cache
83+
84+
Layer blobs are content-addressed (sha256). The cache lives at
85+
`$XDG_CACHE_HOME/kbox/oci-layers/<sha256>` (default
86+
`~/.cache/kbox/oci-layers/`). Writes are atomic
87+
(`tempfile.mkstemp` + `os.replace`); reads re-hash the file and drop
88+
the entry on mismatch, so a corrupted cache is self-healing on the next
89+
pull. Pass `--no-cache` to bypass entirely; use `oci-pull.py prune` to
90+
clear or trim the cache.
91+
92+
## Rootless ownership rewrite (`--rewrite-uid`)
93+
94+
`mke2fs -d` inherits the invoking user's UID into ext4 inodes. Without
95+
intervention, the guest sees its own files owned by a non-root UID,
96+
which breaks setuid binaries and `apk add` install scripts when the
97+
guest is launched with `kbox --root-id` (forces guest uid=0).
98+
99+
The fix runs in three steps:
100+
101+
1. `oci-pull.py --manifest=PATH` records `(uid, gid, mode)` per file,
102+
directory, and hardlink during layer apply. Symlinks are excluded
103+
(`lchown` semantics aren't load-bearing for the kbox guest); device
104+
nodes are excluded (rootless cannot `mknod`; `/dev` is mounted at
105+
guest runtime). Records are NUL-separated:
106+
`<uid>\t<gid>\t<mode_octal>\t<path>\0`.
107+
2. `mke2fs -d` builds the ext4 image with invoking-user ownership.
108+
3. `tools/oci-chown <image> <manifest>` opens the image read-write via
109+
libext2fs, resolves each path through `ext2fs_namei`, and rewrites
110+
`i_uid` (16-bit lo + hi), `i_gid` (16-bit lo + hi), and `i_mode`
111+
permission bits (preserving type bits like `S_IFREG`/`S_IFDIR`).
112+
`ext2fs_close` flushes.
113+
114+
The helper is build-time-only: `tools/oci-chown/Makefile` links against
115+
`-lext2fs -lcom_err` from `e2fsprogs`. The kbox supervisor build is
116+
unchanged. `mkrootfs.sh --rewrite-uid` builds the helper on demand if
117+
the binary isn't present.
118+
119+
When you don't pass `--root-id`, you don't need `--rewrite-uid`: the
120+
guest runs as the host user, host UID matches inode UID, and ownership
121+
is consistent.
122+
123+
## Hardening
124+
125+
The layer-apply path is the main attack surface (a malicious image
126+
could try to write outside the staging directory). Defenses:
127+
128+
- **Path traversal.** `safe_join` strips leading `./` and `/` from tar
129+
member names and rejects any `..` component before joining onto the
130+
staging root.
131+
- **Symlink Zip Slip.** Each member's parent directory is realpath-checked
132+
against the staging root before any write, unlink, or `chmod`. A
133+
malicious layer that creates `staging/etc -> /etc` and then writes
134+
`etc/passwd` is rejected because `realpath(staging/etc) = /etc`
135+
doesn't sit under `realpath(staging)`. File writes additionally pass
136+
`O_NOFOLLOW`, and pre-existing symlinks at the destination are
137+
unlinked before `os.makedirs`/`os.chmod`.
138+
- **Hardlink source confinement.** Hardlink targets resolve through
139+
`safe_join` (or its parent-relative variant for ustar-format
140+
tarballs, which also runs through `safe_join` to reject `..` after
141+
normalization). Absolute linknames are rejected. The resolved source
142+
is realpath-checked against the staging root, and `os.link` is called
143+
with `follow_symlinks=False` so a staging-resident symlink cannot
144+
redirect the link to a host file.
145+
- **DoS caps.** `MAX_MANIFEST_BYTES=4 MB`, `MAX_BLOB_BYTES=8 GB`,
146+
`MAX_TAR_MEMBERS=500_000`. Layer descriptors with declared size over
147+
the blob cap are rejected before download; the streaming loop also
148+
caps actual bytes received.
149+
- **Auth handling.** Bearer tokens are stripped on cross-host redirects
150+
(compared by `netloc`, not just hostname, so a port-change redirect
151+
on the same host also drops the token).
152+
- **Digest verification.** Every blob is sha256-verified in flight;
153+
cache reads re-verify before use. Corrupted entries are removed and
154+
re-fetched.
155+
- **Manifest validation in `oci-chown`.** Every record's uid/gid is
156+
range-checked (`0 <= v <= UINT32_MAX`); leading sign or whitespace is
157+
rejected. Mode bits outside `0o7777` are rejected. A manifest tail
158+
missing the trailing NUL fails loudly with byte offset and record
159+
index.
160+
161+
The supervisor itself never reads the OCI image at runtime (`kbox`
162+
treats the resulting `.ext4` as opaque LKL filesystem state), so a
163+
mis-applied layer cannot escalate beyond the staging directory.
164+
165+
## Limitations
166+
167+
- Only `docker://` URIs are accepted. No `oci://` (local OCI layout
168+
directories), no `containers-storage://`. Adding a local OCI layout
169+
reader is a small follow-up if needed.
170+
- Mutable tags (e.g. `alpine:3.21`) are resolved on every pull. Pin to
171+
a digest for reproducibility.
172+
- Cosign / notation signature verification is not implemented; treat
173+
this as a development tool, not a supply-chain control.
174+
- Synthetic parent directories (created when a tarball omits explicit
175+
parent dir entries) are recorded as `0:0:0755`. Well-formed OCI
176+
layers always include explicit parent entries, so this only fires on
177+
malformed input.
178+
- zstd-compressed layer support depends on Python's `tarfile`
179+
capability (Python 3.14+ on most distros).
180+
181+
## Acceptance
182+
183+
Verified end-to-end on x86_64 (`node1`) and aarch64 (`arm`) hosts:
184+
185+
| Scenario | Result |
186+
|---|---|
187+
| `alpine:3.21` round-trip | 185 inodes; busybox `User=0` after rewrite |
188+
| `node:alpine` round-trip | ~2880 inodes; `/home/node` `User=1000` (uid round-trip) |
189+
| Integration suite vs OCI rootfs | parity with baseline tarball rootfs |
190+
191+
A CI job
192+
([`.github/workflows/build-kbox.yml`](../.github/workflows/build-kbox.yml))
193+
runs the full pipeline against `nginx:alpine` on every PR, verifying
194+
the helper reports a non-zero rewrite count and that
195+
`/etc/nginx/nginx.conf` ends up `User=0/Group=0` and `/usr/sbin/nginx`
196+
mode is `0755`.

0 commit comments

Comments
 (0)