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
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Beetroot build context allowlist.
#
# `beetroot build` resolves the build context to the repo root (compose.yaml's
# `context: ${BEETROOT_BUILD_CONTEXT:-.}`), but docker/Dockerfile only COPYs
# docker/*.sh + docker/stealth.rc. Without this file Docker would tar the whole
# root (.venv, .git, instance dirs) into the build context. Exclude everything,
# then re-include only the docker/ tree the build actually consumes.
*
!docker/
!docker/**
11 changes: 10 additions & 1 deletion .github/workflows/beetroot-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ jobs:
working-directory: .beetroot
env:
KERNEL_VERSION: "6.12.9"
# sha256 of linux-${KERNEL_VERSION}.tar.xz; verified before extract so
# a tampered CDN tarball can't be compiled. Keep in sync with
# KERNEL_SOURCE_SHA256 in src/beetroot/builder.py (issue #184).
KERNEL_SOURCE_SHA256: "87be0360df0931b340d2bac35161a548070fbc3a8c352c49e21e96666c26aeb4"
VM_OUT: ${{ github.workspace }}/.beetroot-vm
run: |
set -euxo pipefail
Expand All @@ -251,9 +255,14 @@ jobs:
mkdir -p "$VM_OUT"
work="$RUNNER_TEMP/linux"
mkdir -p "$work"
# Download to a file and verify the pinned source digest BEFORE extract
# (split the curl | tar pipe so the bytes can be hashed first) (#184).
tarball="$RUNNER_TEMP/linux-${KERNEL_VERSION}.tar.xz"
curl -fsSL \
"https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${KERNEL_VERSION}.tar.xz" \
| tar -xJ -C "$work" --strip-components=1
-o "$tarball"
echo "${KERNEL_SOURCE_SHA256} ${tarball}" | sha256sum -c -
tar -xJ -C "$work" --strip-components=1 -f "$tarball"
cd "$work"
make defconfig
./scripts/kconfig/merge_config.sh -m .config \
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,10 @@ jobs:
env:
# The binder-enabled guest kernel pinned by docs/design/vm-rnd-log.md.
KERNEL_VERSION: "6.12.9"
# sha256 of linux-${KERNEL_VERSION}.tar.xz; verified before extract so a
# tampered CDN tarball can't be compiled. Keep in sync with
# KERNEL_SOURCE_SHA256 in src/beetroot/builder.py (issue #184).
KERNEL_SOURCE_SHA256: "87be0360df0931b340d2bac35161a548070fbc3a8c352c49e21e96666c26aeb4"
VM_OUT: ${{ github.workspace }}/vm-artifacts
BEETROOT_VM_KERNEL: ${{ github.workspace }}/vm-artifacts/bzImage
BEETROOT_VM_ROOTFS: ${{ github.workspace }}/vm-artifacts/rootdisk.img
Expand Down Expand Up @@ -377,6 +381,8 @@ jobs:
mkdir -p "$VM_OUT"
cd "$RUNNER_TEMP"
curl -fsSLO "https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${KERNEL_VERSION}.tar.xz"
# Verify the pinned source digest BEFORE extract (issue #184).
echo "${KERNEL_SOURCE_SHA256} linux-${KERNEL_VERSION}.tar.xz" | sha256sum -c -
tar -xf "linux-${KERNEL_VERSION}.tar.xz"
cd "linux-${KERNEL_VERSION}"
make defconfig
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/vm-kernel-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ jobs:
env:
# Keep in sync with KERNEL_VERSION in src/beetroot/builder.py and e2e.yml.
KERNEL_VERSION: "6.12.9"
# sha256 of linux-${KERNEL_VERSION}.tar.xz from cdn.kernel.org's signed
# sha256sums.asc; verified before extract so a tampered CDN tarball can't
# be compiled into a published bzImage. Keep in sync with
# KERNEL_SOURCE_SHA256 in src/beetroot/builder.py (issue #184).
KERNEL_SOURCE_SHA256: "87be0360df0931b340d2bac35161a548070fbc3a8c352c49e21e96666c26aeb4"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
Expand Down Expand Up @@ -62,6 +67,8 @@ jobs:
asset="bzImage-${KERNEL_VERSION}-${fp}"
cd "$RUNNER_TEMP"
curl -fsSLO "https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${KERNEL_VERSION}.tar.xz"
# Verify the pinned source digest BEFORE extract (issue #184).
echo "${KERNEL_SOURCE_SHA256} linux-${KERNEL_VERSION}.tar.xz" | sha256sum -c -
tar -xf "linux-${KERNEL_VERSION}.tar.xz"
cd "linux-${KERNEL_VERSION}"
make defconfig
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Security

- **The guest-kernel source tarball is verified against a pinned sha256 before compiling (#184).** The `binder: vm` kernel build (and the release/CI lanes) now hash the downloaded `cdn.kernel.org` tarball and fold the digest into the published prebuilt fingerprint, so a tampered tarball can't be compiled into a trusted `bzImage`.
- **`frida-server` downloads decompress incrementally with a bounded output ceiling (#228).** A corrupt or zip-bomb `.xz` now raises `FridaFetchError` past the ceiling instead of OOM-ing the host.
- **Relative `path:` module entries are now contained to the instance directory (#152).** A relative `path:` that resolves outside the instance dir (e.g. `path: ../../../etc/shadow`) is rejected at staging time — the path-traversal analogue of the existing `file://` URL block. **Absolute** `path:` entries (e.g. `path: /tmp/mod.zip`) remain permitted and are read as-is (an unchanged, tested feature). No schema/api_version change.

### Breaking changes
Expand Down Expand Up @@ -500,6 +502,11 @@
pre-abort rows in its `results` attribute).

### Quality & internals
- **`frida-server` and module downloads stream chunk-by-chunk to disk (#227).** Frida decompresses incrementally instead of buffering the whole compressed and decompressed payload in RAM — matching the rootfs streaming idiom and easing the memory posture on constrained TCG/CI hosts.
- **Added a repo-root `.dockerignore` (#207).** `beetroot build` no longer uploads `.venv/`, `.git/`, or instance dirs as build context — only `docker/` is sent.
- **Warn when a pinned `gapps_vendor` overrides the `minimal`/`full` intent (#220).** The silently-collapsed image/flag is no longer a surprise.
- **Removed the dead, never-read `_MIGRATION_REQUIRED_VERSIONS` constant (#242).** Its comment falsely claimed to enforce the 3→4 stealth migration, which is actually handled by `_reject_stealth_key`.
- **`frida.sha256` is validated as 64-char hex at config-load time (#194).** A fat-fingered digest now fails immediately with a clear error instead of late with a misleading hostile-mirror message. (`module.sha256` validation is tracked as a follow-up — bogus-digest test fixtures need updating first.)
- **Fixed self-contradicting `vm.py` comments that claimed `adb connect` succeeds
at QEMU `hostfwd`-bind (#241).** It only attaches once the post-boot in-guest
relay is listening; the comments and the `_wait_for_boot_completed` docstring
Expand Down Expand Up @@ -610,6 +617,29 @@
are absent), so shell regressions are caught locally before the push.

### Bug fixes
- **Module URLs carrying a query string or fragment now stage a clean `.zip` basename (#168).** The redroid flash glob (`*.zip`) matches it, so a `m.zip?v=2` URL's module is actually flashed instead of silently skipped.
- **Frida, module, and kernel downloads stage into a process-unique temp file before the atomic rename (#185).** Concurrent fetches of the same artifact can no longer poison the shared user-global cache with a torn file.
- **`beetroot build --vm-kernel [--check]` validates that a set `REDROID_TAR` points at an existing file (#186).** A typo'd tarball fails preflight instead of aborting mid-bake at `docker load`.
- **The rootfs `.android-version` marker records the major version of the actually-baked `REDROID_IMAGE` (#187).** The `binder: vm` skew check no longer trusts a marker that lies when `REDROID_IMAGE` overrides the version.
- **`beetroot build` runs a Docker-daemon preflight (#193).** A daemonless host gets a friendly "start the daemon" message instead of a bare `command failed (exit 1)`.
- **Shell-quote the build-context-derived kernel paths in the `--vm-kernel --from-source` compile (#208).** A checkout under a path with spaces/metacharacters no longer breaks the `merge_config`/`cp` step.
- **Force BuildKit when `beetroot build` runs `docker compose build` (#229).** The BuildKit-only `COPY --chmod` in the Dockerfile no longer aborts on a BuildKit-disabled host.
- **`beetroot build --vm-kernel` fails fast with a root-privilege preflight (#231).** An unprivileged local rootfs bake no longer masquerades as a generic ~60s `staging dockerd did not become ready` timeout.
- **`beetroot build` serializes concurrent runs with an `fcntl.flock` around the shared clone dir (#232).** Two builds can no longer corrupt each other's `rm -rf`/`git clone`/patch tree.
- **The rootfs `.android-version` marker is written before the image is renamed into place (#234).** An interrupted download can't leave a marker-less image that silently skips the skew check.
- **`beetroot ls`/`modes` tables render losslessly when stdout is not a TTY (#204).** No cell truncation or box-drawing borders off-TTY; the overclaiming console docstrings are corrected.
- **`docker compose logs` raises `ComposeError` on a non-zero exit in non-follow mode (#218).** It no longer silently swallows the failure, while still tolerating the Ctrl-C exit under `--follow`.
- **`user_config_dir`/`user_registry_file`/`user_cache_dir` ignore a relative `$XDG_*_HOME` and fall back to `~/.config`/`~/.cache` (#225).** This honors their absolute-path contract instead of fragmenting the registry across working directories.
- **Bundled compose / vm-asset cache materialisation writes via a temp file + `os.replace` (#226).** Concurrent wheel-installed invocations can no longer hand `docker compose` a truncated file.
- **`beetroot restore` rejects a snapshot whose archived `beetroot.yaml` sets `binder: vm` instead of silently restoring it as a redroid instance (#171).**
- **snapshot restore runs the cross-instance directory-overlap guard even when the target doesn't yet exist (#172).** It refuses a restore into a non-existent descendant/ancestor of a registered instance instead of registering a nested one.
- **`beetroot snapshot` excludes its own output archive by resolved path at any directory depth (#173).** Running it from a subdirectory of the instance no longer packs the partially-written archive into itself.
- **Reject a well-known `ports:` mapping whose guest port isn't canonical, and require `frida_control` alongside `frida` (#195).** A mistyped guest port no longer publishes a host port forwarding to a dead guest port.
- **Dedup the legacy ports-mapping migration note by resolved path (#202).** It no longer re-prints on every `load_yaml` across a fleet scan.
- **Accept Docker's documented `memswap_limit: -1` (unlimited swap) (#215).** It's no longer rejected as an invalid size, while `-1` stays rejected for the other size fields.
- **The `binder: vm` inert-config advisory also flags a non-empty `modules:` list (#200).** The Magisk-less guest can never flash them.
- **The mid-batch offline-abort module row is stage-neutral and retains the underlying adb error (#223).** It no longer hardcodes a "mid-install" detail or drops the adb error text.
- **Restructure the magisk-config.sh zygisk read so the `magisk --sqlite` exit status is observed under `set -eu` (#239).** A post-liveness daemon flap no longer triggers a needless zygote restart or a misleading boot abort.
- **`binder: vm` `down`/`restart`/`destroy` verify the recorded PID still names
this instance's QEMU before signalling (#162).** A stale or reused pidfile can
no longer SIGTERM/SIGKILL an unrelated process; `is_running()` and the
Expand Down
20 changes: 17 additions & 3 deletions docker/magisk-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,16 @@ echo "[*] Enabling Zygisk + denylist"
# is the one that flips it on. Zygisk only injects zygote at zygote start, so a
# 0/missing → 1 transition this boot means the running zygote predates Zygisk
# and needs a one-shot restart to activate it (and any flashed Zygisk module).
PREV_ZYGISK="$(magisk --sqlite "SELECT value FROM settings WHERE key='zygisk';" | awk -F'=' '{print $NF}')"
# ``magisk --sqlite`` prints each row as ``column=value``; we want the value.
# The prior ``magisk ... | awk -F= '{print $NF}'`` did the right extraction but
# put magisk *inside a pipeline*, so under ``set -eu`` the substitution's exit
# status was awk's (always 0) — a magisk failure after the liveness probe was
# silently masked, leaving PREV_ZYGISK empty and spuriously flagging Zygisk as
# newly enabled. Capture magisk's raw output in its own command substitution
# (so a magisk failure aborts the script), then strip the ``value=`` prefix
# with shell parameter expansion — no pipe (issue #239).
PREV_ZYGISK_ROW="$(magisk --sqlite "SELECT value FROM settings WHERE key='zygisk';")"
PREV_ZYGISK="${PREV_ZYGISK_ROW##*=}"
magisk --sqlite "REPLACE INTO settings (key, value) VALUES ('zygisk', 1);"
magisk --sqlite "REPLACE INTO settings (key, value) VALUES ('denylist', 1);"
if [ "$PREV_ZYGISK" != "1" ]; then
Expand All @@ -65,9 +74,14 @@ fi
# container with denylist=1 but zygisk=0 and no root hiding — a
# user-visible behaviour change that v0.3 had no detection for.
# (T2 Agent 1 / Agent 2 F-9 / Agent 3 1.2.)
ZYGISK_VALUE="$(magisk --sqlite "SELECT value FROM settings WHERE key='zygisk';" | awk -F'=' '{print $NF}')"
# Same structure as PREV_ZYGISK above: read into its own command substitution
# (so a magisk failure aborts under ``set -eu`` rather than being masked into
# an empty value that misreports as "setting did not take"), then strip the
# ``value=`` prefix without a pipe (issue #239).
ZYGISK_ROW="$(magisk --sqlite "SELECT value FROM settings WHERE key='zygisk';")"
ZYGISK_VALUE="${ZYGISK_ROW##*=}"
if [ "$ZYGISK_VALUE" != "1" ]; then
echo "[!] Magisk Zygisk setting did not take (got: '$ZYGISK_VALUE'). Aborting."
echo "[!] Magisk reports zygisk='$ZYGISK_VALUE' after the REPLACE INTO (expected '1'); the setting did not persist. Aborting." >&2
exit 1
fi

Expand Down
Loading