From d1008097fce828fff094349e2c0aafdfe64e5b25 Mon Sep 17 00:00:00 2001 From: Exploit Intel Date: Tue, 15 Sep 2026 14:01:01 +0300 Subject: [PATCH 1/9] fix: prove installed module bytes during updates KernelSU's module update can preserve previously installed file bytes beside a fresh module.prop, and the update path never proved what ksud left on disk. Updates now re-stage the payload module tree through a mode-preserving overlay while Docker is stopped, verify it on the phone against a payload-built checksum manifest with toybox-compatible flags, and prune files the payload no longer contains. Module reinstalls also fetch and hash-verify the pinned Docker Engine archive when neither the bundle nor the phone holds it. Ports the Pixel 11 fix (its PR 29). --- README.md | 14 +++ deployment/build-simple-package.sh | 1 + deployment/simple-install.sh | 71 +++++++++++++++ tests/simple-installer.test.mjs | 135 +++++++++++++++++++++++++++-- 4 files changed, 215 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5b06e58..5c59fac 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,20 @@ waits for current Forge work to become idle, and updates with rollback. It preserves the Docker disk, Forge state, WebUI password, provider keys, and CVE data. Do not run `prepare-firmware.sh`, `--wipe`, or `--disk-gib` for an update. +When the payload module version differs from the installed one, the update +parks Forge, reinstalls the module (fetching and hash-verifying the pinned +Docker Engine archive when neither the bundle nor the phone holds it), and +reboots. Every update then proves the installed module tree against the +payload, with the overlay confined to the KernelSU module tree while the +Docker daemon runs from the separately managed /data/docker release tree: +the payload files are re-staged through a +mode-preserving overlay, verified on the phone against a payload-built +checksum manifest, and pruned of files the payload no longer contains. The +verification exists because KernelSU's module update can preserve previously +installed file bytes - observed as a fresh module.prop beside a stale +bin/hostctl on a Pixel 11 Pro XL on 2026-09-15 - and a silent mismatch would +leave boot-time module code running older behavior than the release claims. + ## What gets installed - The matched Pixel kernel and native Docker host diff --git a/deployment/build-simple-package.sh b/deployment/build-simple-package.sh index 91cb47f..ed61fdf 100755 --- a/deployment/build-simple-package.sh +++ b/deployment/build-simple-package.sh @@ -180,6 +180,7 @@ IFS='|' read -r LOCK_PIXEL_REVISION LOCK_FORGE_REVISION LOCK_FORGE_SOURCE_SHA256 mkdir -p "$OUTPUT/payload" "$WORK/ops" cp "$SCRIPT_DIR/simple-install.sh" "$OUTPUT/install.sh" cp "$SCRIPT_DIR/prepare-firmware.sh" "$OUTPUT/prepare-firmware.sh" +cp "$PROJECT_ROOT/tools/engine.json" "$OUTPUT/engine.json" cp "$SCRIPT_DIR/SIMPLE-INSTALLER.md" "$OUTPUT/README.md" cp "$MODULE" "$OUTPUT/payload/host-module.zip" if [[ -n "$ENGINE" ]]; then diff --git a/deployment/simple-install.sh b/deployment/simple-install.sh index a57c2ec..ca9991d 100755 --- a/deployment/simple-install.sh +++ b/deployment/simple-install.sh @@ -173,6 +173,71 @@ push() { "$ADB_BIN" -s "$SERIAL" push "$1" "$2" >/dev/null } +ensure_engine_archive() { + local engine_json engine_url engine_name engine_size engine_sha archive held held_size held_sha + engine_json=$SCRIPT_DIR/engine.json + [[ -f "$engine_json" ]] || engine_json=$SCRIPT_DIR/../tools/engine.json + engine_url= + engine_name= + engine_size= + engine_sha= + if [[ -f "$engine_json" ]]; then + engine_url=$(sed -n 's/.*"url": "\([^"]*\)".*/\1/p' "$engine_json" | head -n 1) + engine_name=${engine_url##*/} + engine_size=$(sed -n 's/.*"size": \([0-9][0-9]*\).*/\1/p' "$engine_json" | head -n 1) + engine_sha=$(sed -n 's/.*"sha256": "\([0-9a-f]\{64\}\)".*/\1/p' "$engine_json" | head -n 1) + fi + [[ -n "$engine_url" && -n "$engine_size" && -n "$engine_sha" ]] \ + || die 'cannot read the pinned Docker Engine identity' + if phone "test -s /data/local/tmp/$engine_name" >/dev/null 2>&1; then + held=$(phone "wc -c < /data/local/tmp/$engine_name; sha256sum /data/local/tmp/$engine_name" 2>/dev/null | tr -d '\r') || held= + held_size=$(printf '%s\n' "$held" | sed -n '1s/^[[:space:]]*\([0-9][0-9]*\).*/\1/p') + held_sha=$(printf '%s\n' "$held" | sed -n '2s/^\([0-9a-f]\{64\}\) .*/\1/p') + if [[ "$held_size" == "$engine_size" && "$held_sha" == "$engine_sha" ]]; then + return 0 + fi + printf 'install: the phone-held %s fails the pinned identity; refetching\n' "$engine_name" >&2 + fi + archive=$(mktemp "${TMPDIR:-/tmp}/eip-engine.XXXXXX") + curl --fail --location --proto '=https' --proto-redir '=https' \ + --output "$archive" "$engine_url" \ + || die 'the pinned Docker Engine archive could not be downloaded' + [[ $(wc -c < "$archive" | tr -d ' ') == "$engine_size" ]] \ + || die 'the pinned Docker Engine archive has the wrong size' + verify_file "$archive" "$engine_sha" 'Docker Engine archive' + push "$archive" "/data/local/tmp/$engine_name" + rm -f "$archive" +} + +# KernelSU's module update can preserve previously installed file bytes +# (observed on the Pixel 11 Pro XL on 2026-09-15: a new module.prop beside a +# stale bin/hostctl), so installed module bytes are always re-staged from the +# payload through a mode-preserving overlay and proven on the phone against a +# payload-built checksum manifest, with files the payload no longer contains +# pruned. The overlay touches only the KernelSU module tree; the daemon +# runs from the separately managed /data/docker release tree. +verify_module_files() { + local work entry + stage 'Verifying the Pixel module bytes' 'Check the module verification output above; Docker stays stopped and existing host state is preserved.' + work=$(mktemp -d "${TMPDIR:-/tmp}/eip-module.XXXXXX") + unzip -q "$PAYLOAD/host-module.zip" -d "$work/module" \ + || die 'the payload module archive cannot be extracted' + while IFS= read -r entry; do + [[ -n "$entry" ]] || continue + hash_file "$work/module/$entry" + printf '%s %s\n' "$FILE_SHA256" "$entry" + done < <(cd "$work/module" && LC_ALL=C find . -type f | LC_ALL=C sed 's|^\./||' | LC_ALL=C sort) \ + > "$work/manifest" + LC_ALL=C awk '{ $1 = ""; sub(/^ /, ""); print }' "$work/manifest" > "$work/names" + tar -C "$work/module" -cf "$work/files.tar" . + push "$work/files.tar" /data/local/tmp/eip-module-files.tar + push "$work/manifest" /data/local/tmp/eip-module-manifest + push "$work/names" /data/local/tmp/eip-module-names + phone 'tar -xf /data/local/tmp/eip-module-files.tar -C /data/adb/modules/eip-pixel8a-forge && chown -R 0:0 /data/adb/modules/eip-pixel8a-forge && cd /data/adb/modules/eip-pixel8a-forge && sha256sum -c /data/local/tmp/eip-module-manifest -s && find . -type f | sed "s|^\\./||" | LC_ALL=C sort | LC_ALL=C comm -23 - /data/local/tmp/eip-module-names | while IFS= read -r stale; do rm -f "$stale"; done; rc=$?; rm -f /data/local/tmp/eip-module-files.tar /data/local/tmp/eip-module-manifest /data/local/tmp/eip-module-names; exit $rc' \ + || die 'installed module files do not match the payload' + rm -rf "$work" +} + # adb install has been observed to stall indefinitely when the installer runs # without a terminal, so every APK install is bounded and retried once. APK_INSTALL_TIMEOUT_SECONDS=180 @@ -441,6 +506,8 @@ if [[ "$HOST_MODULE_CURRENT" == false ]]; then stage 'Installing the Pixel Docker host' 'Check the module output above, package inputs, USB connection, and available phone storage.' if [[ -f "$PAYLOAD/docker-engine.tgz" ]]; then push "$PAYLOAD/docker-engine.tgz" /data/local/tmp/docker-29.8.0.tgz + else + ensure_engine_archive fi push "$PAYLOAD/kernel.lz4" /data/local/tmp/Image-CP2A.260805.005.lz4 push "$PAYLOAD/host-module.zip" /data/local/tmp/eip-pixel8a-forge.zip @@ -457,6 +524,7 @@ if [[ "$HOST_MODULE_CURRENT" == false ]]; then phone 'rm -f /data/local/tmp/docker-29.8.0.tgz /data/local/tmp/Image-CP2A.260805.005.lz4 /data/local/tmp/eip-pixel8a-forge.zip' "$ADB_BIN" -s "$SERIAL" reboot >/dev/null 2>&1 || true wait_android + verify_module_files elif [[ "$EXISTING_INSTALL" == false ]]; then stage 'Preparing Docker storage' 'Check the host storage error above and select the existing disk size if this is a partial installation.' phone "/data/docker/bin/hostctl disk-init --size-bytes $DISK_BYTES" @@ -480,6 +548,9 @@ if [[ "$EXISTING_INSTALL" == true ]]; then install_control_app stage 'Waiting for current Forge work to finish' 'Forge remains available until its active work is idle; close new work and wait.' wait_until_parked + if [[ -f "$PAYLOAD/host-module.zip" ]]; then + verify_module_files + fi stage 'Restarting Docker for the update' 'Forge remains parked; check the Docker startup error above.' start_docker stage 'Installing the matched Forge update' 'Check the source transaction error above; existing state is retained for rollback.' diff --git a/tests/simple-installer.test.mjs b/tests/simple-installer.test.mjs index 0e70056..3718be2 100644 --- a/tests/simple-installer.test.mjs +++ b/tests/simple-installer.test.mjs @@ -36,13 +36,25 @@ async function fakeToolMain() { ["source-ops.txt", "9".repeat(64)], ]); const name = [...hashes.keys()].find((candidate) => file?.endsWith(`/${candidate}`)); - if (!name || args.slice(0, -1).join(" ") !== "-a 256 --") reject(); + if (!name || args.slice(0, -1).join(" ") !== "-a 256 --") { + if (name || !/\/eip-module\.[A-Za-z0-9]+\/module\//.test(file ?? "")) reject(); + const crypto = await import("node:crypto"); + const digest = crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + log({ tool, file, hash: digest }); + process.stdout.write(`${digest} ${file}\n`); + return; + } const hash = env.FAKE_BAD_PAYLOAD === name ? "0".repeat(64) : hashes.get(name); log({ tool, file, hash }); process.stdout.write(`${hash} ${file}\n`); return; } if (tool === "unzip") { + if (args[0] === "-q" && args[1]?.endsWith("/payload/host-module.zip") && args[2] === "-d" && args.length === 4) { + log({ tool, args: ["-q", "host-module.zip", "-d"] }); + const extraction = spawnSync("/usr/bin/unzip", args, { encoding: "utf8" }); + process.exit(extraction.status ?? 96); + } if (args.length === 3 && args[0] === "-p" && args[1].endsWith("/payload/host-module.zip") && args[2] === "module.prop") { log({ tool, args }); @@ -79,7 +91,7 @@ async function fakeToolMain() { if (rest.length !== 2 || !wrapped.startsWith("su -c '") || !wrapped.endsWith("'")) reject(); command = wrapped.slice(7, -1).replaceAll("'\\''", "'"); } - log({ tool, verb, command }); + if (verb !== "push") log({ tool, verb, command }); if (env.FAKE_FAIL_MATCH && command.includes(env.FAKE_FAIL_MATCH)) { process.stderr.write("Injected fixture command failure\n"); process.exit(Number(env.FAKE_FAIL_STATUS)); @@ -100,6 +112,8 @@ async function fakeToolMain() { } if (verb === "push" && rest.length === 2) { if (!fs.existsSync(rest[0]) || !rest[1].startsWith("/data/local/tmp/")) reject(); + fs.copyFileSync(rest[0], `${env.FAKE_PUSH_DIR}/${rest[1].split("/").pop()}`); + log({ tool, verb, args: rest }); if (rest[1] === "/data/local/tmp/eip-provider.env") { fs.copyFileSync(rest[0], env.FAKE_REMOTE_PROVIDER); } @@ -280,6 +294,7 @@ async function fakeToolMain() { "/data/adb/ksud module install /data/local/tmp/eip-pixel8a-forge.zip", "KSU=true KSU_VER=3.3.0 KSU_VER_CODE=33214 KSU_RUNTIME_MODE=lkm /data/adb/modules_update/eip-pixel8a-forge/bin/kernelctl install INSTALL:CP2A.260805.005:_a", "rm -f /data/local/tmp/docker-29.8.0.tgz /data/local/tmp/Image-CP2A.260805.005.lz4 /data/local/tmp/eip-pixel8a-forge.zip", + "test -s /data/local/tmp/docker-29.8.0.tgz", "chmod 700 /data/local/tmp/eip-ksud", "rm -f /data/local/tmp/eip-ksud /data/local/tmp/eip-ksu-init-boot.img /data/local/tmp/eip-ksu-shell-root.img", ]); @@ -297,22 +312,70 @@ async function fakeToolMain() { "chmod 700 /data/local/tmp/eip-ksu-grant-profile;", "/data/local/tmp/eip-ksud boot-patch --boot /data/local/tmp/eip-ksu-init-boot.img ", ]; + if (command === "wc -c < /data/local/tmp/docker-29.8.0.tgz; sha256sum /data/local/tmp/docker-29.8.0.tgz") { + process.stdout.write(`${env.FAKE_ENGINE_HELD_SIZE ?? "77727467"}\n${env.FAKE_ENGINE_HELD_SHA ?? "1462a696be6029bd478d7d60d7f3c31cdd15affd1178a4a278aaf4a1d1b7f8b5"} /data/local/tmp/docker-29.8.0.tgz\n`); + return; + } + if (command.startsWith("tar -xf /data/local/tmp/eip-module-files.tar")) { + // Reproduce the phone-side proof on the host: extract the pushed overlay + // and verify it against the pushed manifest with the same flags. + const staged = fs.mkdtempSync(`${env.FAKE_PUSH_DIR}/verify-`); + try { + const extract = spawnSync("/usr/bin/tar", + ["-xf", `${env.FAKE_PUSH_DIR}/eip-module-files.tar`, "-C", staged], { encoding: "utf8" }); + if (extract.status !== 0) process.exit(1); + const check = spawnSync("/bin/bash", ["-c", + `cd '${staged.replaceAll("'", "'\\''")}' && sha_helper() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$@"; else sha256sum "$@"; fi; }; sha_helper -c '${env.FAKE_PUSH_DIR}/eip-module-manifest' -s`], + { encoding: "utf8", env: { ...env, PATH: "/usr/bin:/bin" } }); + if (check.status !== 0) process.exit(1); + } finally { + fs.rmSync(staged, { recursive: true, force: true }); + } + process.exit(Number(env.FAKE_MODULE_VERIFY ?? "0")); + } if (!exact.has(command) && !prefixes.some((prefix) => command.startsWith(prefix))) reject(); } function fixture(t) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "pixel-simple-installer-")); t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const deployment = path.join(root, "deployment"); const bin = path.join(root, "bin"); - const payload = path.join(root, "payload"); + const payload = path.join(deployment, "payload"); fs.mkdirSync(bin); + fs.mkdirSync(deployment, { recursive: true }); fs.mkdirSync(payload); + const pushDir = path.join(root, "pushed"); + fs.mkdirSync(pushDir); for (const name of ["host-module.zip", "docker-engine.tgz", "kernel.lz4", "stock-boot.img", "ksu-init-boot.img", "ksu-manager.apk", "ksu-grant-profile", "forge-control.apk", "forge-source.tar", "forge.lock", "ops.tar", "source-ops.tar", "source-ops.txt", "install-source-ops-phone.sh", "restore-source-ops-phone.sh", "deployment-manifest.json"]) { fs.writeFileSync(path.join(payload, name), "inert installer test payload\n"); } + const moduleStage = path.join(root, "module-stage"); + fs.mkdirSync(path.join(moduleStage, "bin"), { recursive: true }); + fs.writeFileSync(path.join(moduleStage, "module.prop"), [ + "id=eip-pixel8a-forge", + "name=EIP Pixel 8a Forge", + "version=0.1.0-rc.5", + "versionCode=8", + "author=Exploit Intel", + "description=fixture module", + "", + ].join("\n")); + fs.writeFileSync(path.join(moduleStage, "bin", "hostctl"), "fixture module binary\n"); + fs.writeFileSync(path.join(moduleStage, "bin", "install-host"), "fixture module binary\n"); + fs.writeFileSync(path.join(moduleStage, "action.sh"), "fixture module script\n"); + fs.writeFileSync(path.join(moduleStage, "service.sh"), "fixture module script\n"); + fs.writeFileSync(path.join(moduleStage, "customize.sh"), "fixture module script\n"); + fs.writeFileSync(path.join(moduleStage, "uninstall.sh"), "fixture module script\n"); + const moduleZip = path.join(payload, "host-module.zip"); + const zipResult = spawnSync("python3", ["-m", "zipfile", "-c", moduleZip, + "module.prop", "bin", "action.sh", "service.sh", "customize.sh", "uninstall.sh"], + { cwd: moduleStage, encoding: "utf8" }); + assert.ifError(zipResult.error); + assert.equal(zipResult.status, 0, zipResult.stderr); fs.writeFileSync(path.join(payload, "redeploy.sh"), '#!/bin/bash\nprintf "%s\\n" "$*" >"$FAKE_REDEPLOY_ARGS"\ntouch "$FAKE_STARTED"\n', { mode: 0o755 }); const controllerConfig = `sha256:${"d".repeat(64)}`; @@ -326,7 +389,9 @@ function fixture(t) { + `CONTROLLER_CONFIG_SHA256=${controllerConfig.slice("sha256:".length)}\n` + `OPERATOR_IMAGE=ghcr.io/exploitintel/eip-pixel8a-forge-operator@sha256:${"b".repeat(64)}\n` + `OPERATOR_CONFIG_SHA256=${operatorConfig.slice("sha256:".length)}\n`); - const script = path.join(root, "install.sh"); + fs.mkdirSync(path.join(root, "tools")); + fs.copyFileSync(new URL("../tools/engine.json", import.meta.url), path.join(root, "tools", "engine.json")); + const script = path.join(deployment, "install.sh"); fs.copyFileSync(installer, script); const mock = path.join(root, "fake-tool.mjs"); fs.writeFileSync(mock, `(${fakeToolMain.toString()})();\n`); @@ -355,7 +420,7 @@ function fixture(t) { FAKE_STARTED: path.join(root, "started"), FAKE_UI_STARTED: path.join(root, "ui-started"), FAKE_DOCKER_RUNNING: dockerRunning, FAKE_STATUS_MODE: "ready", FAKE_UI_STATUS_MODE: "ready", FAKE_EXISTING_INSTALL: "0", - FAKE_HOST_MODULE_CURRENT: "1", + FAKE_HOST_MODULE_CURRENT: "1", FAKE_PUSH_DIR: pushDir, FAKE_HOST_STATUS_ATTEMPTS: path.join(root, "host-status-attempts"), FAKE_WIFI_DISCONNECTED_CHECKS: "0", FAKE_PARK_PENDING: path.join(root, "park-pending"), FAKE_PARKED: path.join(root, "parked"), @@ -504,7 +569,7 @@ test("existing-install update preserves the disk and uses the transactional rele test("existing-install update does not require fresh-install firmware or host payloads", (t) => { const item = fixture(t); for (const name of ["docker-engine.tgz", "stock-boot.img", "ksu-init-boot.img", "ksu-manager.apk"]) { - fs.rmSync(path.join(item.root, "payload", name)); + fs.rmSync(path.join(item.root, "deployment", "payload", name)); } const result = item.run([], { FAKE_EXISTING_INSTALL: "1" }); assert.equal(result.status, 0, result.stderr); @@ -849,3 +914,61 @@ test("interrupting a quiet operation reports its stage and does not claim comple assert.match(result.stderr, /(?:failed|failure)[^\n]*(?:source|Forge)|(?:source|Forge)[^\n]*(?:failed|failure)/i); expectNoCompletion(result, item.calls()); }); + +test("module change update installs, reboots, and proves installed module bytes", (t) => { + const item = fixture(t); + const result = item.run([], { FAKE_EXISTING_INSTALL: "1", FAKE_HOST_MODULE_CURRENT: "0" }); + assert.equal(result.status, 0, result.stderr); + const commands = item.calls().map((call) => call.command).filter(Boolean); + const moduleInstall = commands.indexOf("/data/adb/ksud module install /data/local/tmp/eip-pixel8a-forge.zip"); + const verifies = commands.map((command, index) => command?.startsWith("tar -xf /data/local/tmp/eip-module-files.tar") ? index : -1).filter((index) => index >= 0); + assert.ok(moduleInstall >= 0, "ksud module install must run when the module version changes"); + assert.ok(verifies.length >= 1, "installed module bytes must be verified against the payload manifest"); + assert.ok(Math.min(...verifies) > moduleInstall, "verification must follow the module install"); + const pushes = item.calls().filter((call) => call.tool === "adb" && call.verb === "push") + .map((call) => call.args?.at(-1)); + assert.ok(pushes.includes("/data/local/tmp/eip-module-files.tar"), "the module file overlay must be pushed"); + assert.ok(pushes.includes("/data/local/tmp/eip-module-manifest"), "the payload checksum manifest must be pushed"); + assert.equal(result.stdout.trim().split("\n").at(-1), "READY"); +}); + +test("module-current update still verifies installed bytes after parking", (t) => { + const item = fixture(t); + const result = item.run([], { FAKE_EXISTING_INSTALL: "1", FAKE_HOST_MODULE_CURRENT: "1" }); + assert.equal(result.status, 0, result.stderr); + const commands = item.calls().map((call) => call.command).filter(Boolean); + assert.ok(!commands.includes("/data/adb/ksud module install /data/local/tmp/eip-pixel8a-forge.zip"), + "ksud module install must not run when the module version is current"); + const verify = commands.findIndex((command) => command?.startsWith("tar -xf /data/local/tmp/eip-module-files.tar")); + const parkReconcile = commands.indexOf("/data/eip-cve-ops/eip-hostctl.sh reconcile"); + assert.ok(verify >= 0, "the byte verification must run after parking"); + assert.ok(verify > parkReconcile, "verification must follow the park"); + assert.equal(result.stdout.trim().split("\n").at(-1), "READY"); +}); + +test("module byte verification failure blocks the update with a clear diagnosis", (t) => { + const item = fixture(t); + const result = item.run([], { FAKE_EXISTING_INSTALL: "1", FAKE_HOST_MODULE_CURRENT: "1", FAKE_MODULE_VERIFY: "1" }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /installed module files do not match the payload/); + const failingVerify = item.calls().findIndex((call) => call.command?.startsWith("tar -xf /data/local/tmp/eip-module-files.tar")); + assert.ok(failingVerify >= 0); + assert.ok(!item.calls().slice(failingVerify).some((call) => call.command === "/data/docker/bin/hostctl start" + || call.command?.startsWith("/data/local/tmp/eip-source-ops-")), + "Docker must not start again after a failed module verification"); +}); + + +test("module change update uses the phone-held engine archive when the bundle omits it", (t) => { + const item = fixture(t); + fs.rmSync(path.join(item.root, "deployment", "payload", "docker-engine.tgz")); + const result = item.run([], { FAKE_EXISTING_INSTALL: "1", FAKE_HOST_MODULE_CURRENT: "0" }); + assert.equal(result.status, 0, result.stderr); + const commands = item.calls().map((call) => call.command).filter(Boolean); + assert.ok(commands.includes("test -s /data/local/tmp/docker-29.8.0.tgz"), + "the phone-held engine archive must be preferred over a download"); + assert.ok(!item.calls().some((call) => call.tool === "curl"), + "no engine download is needed when the phone already holds the pinned archive"); + assert.ok(commands.includes("/data/adb/ksud module install /data/local/tmp/eip-pixel8a-forge.zip")); + assert.equal(result.stdout.trim().split("\n").at(-1), "READY"); +}); From ac90ddd2fc8acb88f8bc86e0be374ae1bd99425c Mon Sep 17 00:00:00 2001 From: Exploit Intel Date: Tue, 15 Sep 2026 14:56:42 +0300 Subject: [PATCH 2/9] fix: verify through the bundled busybox and keep module-state markers --- deployment/simple-install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployment/simple-install.sh b/deployment/simple-install.sh index ca9991d..9e7e15b 100755 --- a/deployment/simple-install.sh +++ b/deployment/simple-install.sh @@ -233,7 +233,7 @@ verify_module_files() { push "$work/files.tar" /data/local/tmp/eip-module-files.tar push "$work/manifest" /data/local/tmp/eip-module-manifest push "$work/names" /data/local/tmp/eip-module-names - phone 'tar -xf /data/local/tmp/eip-module-files.tar -C /data/adb/modules/eip-pixel8a-forge && chown -R 0:0 /data/adb/modules/eip-pixel8a-forge && cd /data/adb/modules/eip-pixel8a-forge && sha256sum -c /data/local/tmp/eip-module-manifest -s && find . -type f | sed "s|^\\./||" | LC_ALL=C sort | LC_ALL=C comm -23 - /data/local/tmp/eip-module-names | while IFS= read -r stale; do rm -f "$stale"; done; rc=$?; rm -f /data/local/tmp/eip-module-files.tar /data/local/tmp/eip-module-manifest /data/local/tmp/eip-module-names; exit $rc' \ + phone 'tar -xf /data/local/tmp/eip-module-files.tar -C /data/adb/modules/eip-pixel8a-forge && chown -R 0:0 /data/adb/modules/eip-pixel8a-forge && cd /data/adb/modules/eip-pixel8a-forge && /data/adb/ksu/bin/busybox sha256sum -c /data/local/tmp/eip-module-manifest -s && find . -type f | sed "s|^\\./||" | LC_ALL=C sort | LC_ALL=C comm -23 - /data/local/tmp/eip-module-names | while IFS= read -r stale; do case "$stale" in disable|remove|update|skip_mount) continue ;; esac; rm -f "$stale"; done; rc=$?; rm -f /data/local/tmp/eip-module-files.tar /data/local/tmp/eip-module-manifest /data/local/tmp/eip-module-names; exit $rc' \ || die 'installed module files do not match the payload' rm -rf "$work" } From efc87403cc45e89c8d21f53b98f2f1ff8f96d5b0 Mon Sep 17 00:00:00 2001 From: Exploit Intel Date: Tue, 15 Sep 2026 15:21:15 +0300 Subject: [PATCH 3/9] fix: source the engine identity from the tarball pin only --- deployment/simple-install.sh | 24 +++++++++++++++++++----- tests/simple-installer.test.mjs | 7 +++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/deployment/simple-install.sh b/deployment/simple-install.sh index 9e7e15b..9a585ae 100755 --- a/deployment/simple-install.sh +++ b/deployment/simple-install.sh @@ -173,6 +173,20 @@ push() { "$ADB_BIN" -s "$SERIAL" push "$1" "$2" >/dev/null } +# The pinned Docker Engine identity is read only from the engine.tarball +# object; the binaries block carries its own size and sha256 keys and must +# never satisfy these parses. +engine_tarball_block() { + sed -n '/"tarball": {/,/}/p' "$1" +} + +engine_archive_name() { + local engine_json=$SCRIPT_DIR/engine.json url + [[ -f "$engine_json" ]] || engine_json=$SCRIPT_DIR/../tools/engine.json + url=$(engine_tarball_block "$engine_json" | sed -n 's/.*"url": "\([^"]*\)".*/\1/p') + printf '%s' "${url##*/}" +} + ensure_engine_archive() { local engine_json engine_url engine_name engine_size engine_sha archive held held_size held_sha engine_json=$SCRIPT_DIR/engine.json @@ -182,10 +196,10 @@ ensure_engine_archive() { engine_size= engine_sha= if [[ -f "$engine_json" ]]; then - engine_url=$(sed -n 's/.*"url": "\([^"]*\)".*/\1/p' "$engine_json" | head -n 1) + engine_url=$(engine_tarball_block "$engine_json" | sed -n 's/.*"url": "\([^"]*\)".*/\1/p') engine_name=${engine_url##*/} - engine_size=$(sed -n 's/.*"size": \([0-9][0-9]*\).*/\1/p' "$engine_json" | head -n 1) - engine_sha=$(sed -n 's/.*"sha256": "\([0-9a-f]\{64\}\)".*/\1/p' "$engine_json" | head -n 1) + engine_size=$(engine_tarball_block "$engine_json" | sed -n 's/.*"size": \([0-9][0-9]*\).*/\1/p') + engine_sha=$(engine_tarball_block "$engine_json" | sed -n 's/.*"sha256": "\([0-9a-f]\{64\}\)".*/\1/p') fi [[ -n "$engine_url" && -n "$engine_size" && -n "$engine_sha" ]] \ || die 'cannot read the pinned Docker Engine identity' @@ -505,7 +519,7 @@ if [[ "$HOST_MODULE_CURRENT" == false ]]; then fi stage 'Installing the Pixel Docker host' 'Check the module output above, package inputs, USB connection, and available phone storage.' if [[ -f "$PAYLOAD/docker-engine.tgz" ]]; then - push "$PAYLOAD/docker-engine.tgz" /data/local/tmp/docker-29.8.0.tgz + push "$PAYLOAD/docker-engine.tgz" "/data/local/tmp/$(engine_archive_name)" else ensure_engine_archive fi @@ -521,7 +535,7 @@ if [[ "$HOST_MODULE_CURRENT" == false ]]; then case "$slot" in _a|_b) ;; *) die "cannot determine active slot: $slot" ;; esac module_root=$(phone 'if test -x /data/adb/modules_update/eip-pixel8a-forge/bin/kernelctl; then printf /data/adb/modules_update/eip-pixel8a-forge; else printf /data/adb/modules/eip-pixel8a-forge; fi' | tr -d '\r') phone "KSU=true KSU_VER=3.3.0 KSU_VER_CODE=33214 KSU_RUNTIME_MODE=lkm $module_root/bin/kernelctl install INSTALL:CP2A.260805.005:$slot" - phone 'rm -f /data/local/tmp/docker-29.8.0.tgz /data/local/tmp/Image-CP2A.260805.005.lz4 /data/local/tmp/eip-pixel8a-forge.zip' + phone "rm -f /data/local/tmp/$(engine_archive_name) /data/local/tmp/Image-CP2A.260805.005.lz4 /data/local/tmp/eip-pixel8a-forge.zip" "$ADB_BIN" -s "$SERIAL" reboot >/dev/null 2>&1 || true wait_android verify_module_files diff --git a/tests/simple-installer.test.mjs b/tests/simple-installer.test.mjs index 3718be2..1adf00c 100644 --- a/tests/simple-installer.test.mjs +++ b/tests/simple-installer.test.mjs @@ -972,3 +972,10 @@ test("module change update uses the phone-held engine archive when the bundle om assert.ok(commands.includes("/data/adb/ksud module install /data/local/tmp/eip-pixel8a-forge.zip")); assert.equal(result.stdout.trim().split("\n").at(-1), "READY"); }); + + +test("the installer never hardcodes the pinned Docker Engine archive name", () => { + const source = fs.readFileSync(installer, "utf8"); + assert.doesNotMatch(source, /docker-29\.8\.0\.tgz/, + "the archive name must always be derived from the engine.tarball pin"); +}); From 42d1f3a34fdb10dbac9018ecbdc26b7f84ccd18e Mon Sep 17 00:00:00 2001 From: Exploit Intel Date: Tue, 15 Sep 2026 15:31:31 +0300 Subject: [PATCH 4/9] fix: state-accurate module verification guidance --- deployment/simple-install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployment/simple-install.sh b/deployment/simple-install.sh index 9a585ae..8b6c0c6 100755 --- a/deployment/simple-install.sh +++ b/deployment/simple-install.sh @@ -232,7 +232,7 @@ ensure_engine_archive() { # runs from the separately managed /data/docker release tree. verify_module_files() { local work entry - stage 'Verifying the Pixel module bytes' 'Check the module verification output above; Docker stays stopped and existing host state is preserved.' + stage 'Verifying the Pixel module bytes' 'Check the module verification output above; only the module tree was being restored, and the Docker daemon runs from the separate /data/docker release tree.' work=$(mktemp -d "${TMPDIR:-/tmp}/eip-module.XXXXXX") unzip -q "$PAYLOAD/host-module.zip" -d "$work/module" \ || die 'the payload module archive cannot be extracted' From de4157750d3e38df587d8e3ec1d78665f38ab90e Mon Sep 17 00:00:00 2001 From: Exploit Intel Date: Tue, 15 Sep 2026 15:43:18 +0300 Subject: [PATCH 5/9] fix: verify module bytes on installation resumes and guard the pin name --- deployment/simple-install.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/deployment/simple-install.sh b/deployment/simple-install.sh index 8b6c0c6..f01524c 100755 --- a/deployment/simple-install.sh +++ b/deployment/simple-install.sh @@ -181,10 +181,12 @@ engine_tarball_block() { } engine_archive_name() { - local engine_json=$SCRIPT_DIR/engine.json url + local engine_json=$SCRIPT_DIR/engine.json url name [[ -f "$engine_json" ]] || engine_json=$SCRIPT_DIR/../tools/engine.json - url=$(engine_tarball_block "$engine_json" | sed -n 's/.*"url": "\([^"]*\)".*/\1/p') - printf '%s' "${url##*/}" + url=$(engine_tarball_block "$engine_json" 2>/dev/null | sed -n 's/.*"url": "\([^"]*\)".*/\1/p') + name=${url##*/} + [[ -n "$name" ]] || die 'cannot read the pinned Docker Engine identity' + printf '%s' "$name" } ensure_engine_archive() { @@ -541,6 +543,9 @@ if [[ "$HOST_MODULE_CURRENT" == false ]]; then verify_module_files elif [[ "$EXISTING_INSTALL" == false ]]; then stage 'Preparing Docker storage' 'Check the host storage error above and select the existing disk size if this is a partial installation.' + if [[ -f "$PAYLOAD/host-module.zip" ]]; then + verify_module_files + fi phone "/data/docker/bin/hostctl disk-init --size-bytes $DISK_BYTES" fi From bbdb197cfc85f5cd480621bc453558baeb56f632 Mon Sep 17 00:00:00 2001 From: Exploit Intel Date: Tue, 15 Sep 2026 15:45:20 +0300 Subject: [PATCH 6/9] docs: cover installation resumes in the module proof contract --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c59fac..ee6b4c2 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ data. Do not run `prepare-firmware.sh`, `--wipe`, or `--disk-gib` for an update. When the payload module version differs from the installed one, the update parks Forge, reinstalls the module (fetching and hash-verifying the pinned Docker Engine archive when neither the bundle nor the phone holds it), and -reboots. Every update then proves the installed module tree against the +reboots. Every update and installation resume then proves the installed payload, with the overlay confined to the KernelSU module tree while the Docker daemon runs from the separately managed /data/docker release tree: the payload files are re-staged through a From 47277619e34c8016d27b743baaf19c5a73d6dbdd Mon Sep 17 00:00:00 2001 From: Exploit Intel Date: Tue, 15 Sep 2026 16:05:02 +0300 Subject: [PATCH 7/9] fix: check the derived engine name at its call sites --- deployment/simple-install.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/deployment/simple-install.sh b/deployment/simple-install.sh index f01524c..5de0ebe 100755 --- a/deployment/simple-install.sh +++ b/deployment/simple-install.sh @@ -181,12 +181,10 @@ engine_tarball_block() { } engine_archive_name() { - local engine_json=$SCRIPT_DIR/engine.json url name + local engine_json=$SCRIPT_DIR/engine.json url [[ -f "$engine_json" ]] || engine_json=$SCRIPT_DIR/../tools/engine.json url=$(engine_tarball_block "$engine_json" 2>/dev/null | sed -n 's/.*"url": "\([^"]*\)".*/\1/p') - name=${url##*/} - [[ -n "$name" ]] || die 'cannot read the pinned Docker Engine identity' - printf '%s' "$name" + printf '%s' "${url##*/}" } ensure_engine_archive() { @@ -521,7 +519,8 @@ if [[ "$HOST_MODULE_CURRENT" == false ]]; then fi stage 'Installing the Pixel Docker host' 'Check the module output above, package inputs, USB connection, and available phone storage.' if [[ -f "$PAYLOAD/docker-engine.tgz" ]]; then - push "$PAYLOAD/docker-engine.tgz" "/data/local/tmp/$(engine_archive_name)" + bundled_engine_name=$(engine_archive_name) || die 'cannot read the pinned Docker Engine identity' + push "$PAYLOAD/docker-engine.tgz" "/data/local/tmp/$bundled_engine_name" else ensure_engine_archive fi @@ -537,7 +536,8 @@ if [[ "$HOST_MODULE_CURRENT" == false ]]; then case "$slot" in _a|_b) ;; *) die "cannot determine active slot: $slot" ;; esac module_root=$(phone 'if test -x /data/adb/modules_update/eip-pixel8a-forge/bin/kernelctl; then printf /data/adb/modules_update/eip-pixel8a-forge; else printf /data/adb/modules/eip-pixel8a-forge; fi' | tr -d '\r') phone "KSU=true KSU_VER=3.3.0 KSU_VER_CODE=33214 KSU_RUNTIME_MODE=lkm $module_root/bin/kernelctl install INSTALL:CP2A.260805.005:$slot" - phone "rm -f /data/local/tmp/$(engine_archive_name) /data/local/tmp/Image-CP2A.260805.005.lz4 /data/local/tmp/eip-pixel8a-forge.zip" + engine_cleanup_name=$(engine_archive_name) || die 'cannot read the pinned Docker Engine identity' + phone "rm -f /data/local/tmp/$engine_cleanup_name /data/local/tmp/Image-CP2A.260805.005.lz4 /data/local/tmp/eip-pixel8a-forge.zip" "$ADB_BIN" -s "$SERIAL" reboot >/dev/null 2>&1 || true wait_android verify_module_files From d1061e4c4f40db52eb3c832e5733eb096ce7b1e9 Mon Sep 17 00:00:00 2001 From: Exploit Intel Date: Tue, 15 Sep 2026 16:20:16 +0300 Subject: [PATCH 8/9] fix: fail closed when the engine pin is unreadable, proven by test --- deployment/simple-install.sh | 1 + tests/simple-installer.test.mjs | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/deployment/simple-install.sh b/deployment/simple-install.sh index 5de0ebe..239ca3c 100755 --- a/deployment/simple-install.sh +++ b/deployment/simple-install.sh @@ -185,6 +185,7 @@ engine_archive_name() { [[ -f "$engine_json" ]] || engine_json=$SCRIPT_DIR/../tools/engine.json url=$(engine_tarball_block "$engine_json" 2>/dev/null | sed -n 's/.*"url": "\([^"]*\)".*/\1/p') printf '%s' "${url##*/}" + [[ -n "$url" ]] } ensure_engine_archive() { diff --git a/tests/simple-installer.test.mjs b/tests/simple-installer.test.mjs index 1adf00c..128f3ac 100644 --- a/tests/simple-installer.test.mjs +++ b/tests/simple-installer.test.mjs @@ -979,3 +979,28 @@ test("the installer never hardcodes the pinned Docker Engine archive name", () = assert.doesNotMatch(source, /docker-29\.8\.0\.tgz/, "the archive name must always be derived from the engine.tarball pin"); }); + + +test("engine_archive_name fails closed when the pin is unreadable", (t) => { + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "pixel-engine-pin-")); + t.after(() => fs.rmSync(scratch, { recursive: true, force: true })); + fs.mkdirSync(path.join(scratch, "deployment")); + fs.copyFileSync(installer, path.join(scratch, "deployment", "install.sh")); + const driver = `#!/bin/bash +set -euo pipefail +SCRIPT_DIR=\'${scratch}/deployment\' +source_helpers() { :; } +eval "$(sed -n '/^engine_tarball_block()/,/^}/p; /^engine_archive_name()/,/^}/p' "$SCRIPT_DIR/install.sh")" +name=$(engine_archive_name) || exit 3 +[[ -n "$name" ]] || exit 4 +printf 'name=%s\n' "$name" +`; + fs.writeFileSync(path.join(scratch, "driver.sh"), driver, { mode: 0o755 }); + const result = spawnSync("/bin/bash", [path.join(scratch, "driver.sh")], { encoding: "utf8" }); + assert.equal(result.status, 3, `missing pin must fail the name helper: ${result.stderr}`); + fs.writeFileSync(path.join(scratch, "deployment", "engine.json"), + JSON.stringify({ engine: { tarball: { url: "https://example.com/docker-31.0.0.tgz", size: 1, sha256: "0".repeat(64) } } }, null, 2)); + const healthy = spawnSync("/bin/bash", [path.join(scratch, "driver.sh")], { encoding: "utf8" }); + assert.equal(healthy.status, 0, healthy.stderr); + assert.equal(healthy.stdout.trim(), "name=docker-31.0.0.tgz"); +}); From f75fed9825f8a3041fedc825420357c510337ac3 Mon Sep 17 00:00:00 2001 From: Exploit Intel Date: Tue, 15 Sep 2026 16:38:54 +0300 Subject: [PATCH 9/9] fix: prove the prune outcome with a file-count invariant --- deployment/simple-install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployment/simple-install.sh b/deployment/simple-install.sh index 239ca3c..dae803c 100755 --- a/deployment/simple-install.sh +++ b/deployment/simple-install.sh @@ -248,7 +248,7 @@ verify_module_files() { push "$work/files.tar" /data/local/tmp/eip-module-files.tar push "$work/manifest" /data/local/tmp/eip-module-manifest push "$work/names" /data/local/tmp/eip-module-names - phone 'tar -xf /data/local/tmp/eip-module-files.tar -C /data/adb/modules/eip-pixel8a-forge && chown -R 0:0 /data/adb/modules/eip-pixel8a-forge && cd /data/adb/modules/eip-pixel8a-forge && /data/adb/ksu/bin/busybox sha256sum -c /data/local/tmp/eip-module-manifest -s && find . -type f | sed "s|^\\./||" | LC_ALL=C sort | LC_ALL=C comm -23 - /data/local/tmp/eip-module-names | while IFS= read -r stale; do case "$stale" in disable|remove|update|skip_mount) continue ;; esac; rm -f "$stale"; done; rc=$?; rm -f /data/local/tmp/eip-module-files.tar /data/local/tmp/eip-module-manifest /data/local/tmp/eip-module-names; exit $rc' \ + phone 'tar -xf /data/local/tmp/eip-module-files.tar -C /data/adb/modules/eip-pixel8a-forge && chown -R 0:0 /data/adb/modules/eip-pixel8a-forge && cd /data/adb/modules/eip-pixel8a-forge && /data/adb/ksu/bin/busybox sha256sum -c /data/local/tmp/eip-module-manifest -s && find . -type f | sed "s|^\\./||" | LC_ALL=C sort | LC_ALL=C comm -23 - /data/local/tmp/eip-module-names | while IFS= read -r stale; do case "$stale" in disable|remove|update|skip_mount) continue ;; esac; rm -f "$stale"; done; n_names=$(wc -l < /data/local/tmp/eip-module-names); n_files=$(find . -type f | wc -l); n_markers=0; for m in disable remove update skip_mount; do [ -f "$m" ] && n_markers=$((n_markers + 1)); done; if [ "$n_files" -ne $((n_names + n_markers)) ]; then printf 'module tree contains unexpected files beyond the payload and module-state markers\n' >&2; exit 5; fi; rm -f /data/local/tmp/eip-module-files.tar /data/local/tmp/eip-module-manifest /data/local/tmp/eip-module-names; exit 0' \ || die 'installed module files do not match the payload' rm -rf "$work" }