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
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,22 @@ Phase-1 security + functional redesign of the in-app updater (tech-debt **F15**)
- **Legacy seam:** `MainActivity` stages the APK privately, checks the download
status, verifies the signature, handles the install permission, and registers
the completion receiver NOT_EXPORTED. (PR A.) Presentation/progress UX = PR B.
**Slice (DONE) — backup import/restore validation (`org.iiab.controller.deploy`)**
Enforces the ABI-separation policy + rootfs sanity at the two untrusted gates.

- `domain/` — `ElfClass` (32/64 from an ELF header) + `RootfsArchive`
(structural rootfs check + probe-binary picker). Pure, unit-tested.
- `data/RootfsArchiveValidator` — lists the tar, checks structure, probes one
internal binary's ELF class vs the app ABI (`Process.is64Bit()`).
- **Seams:** import (`DeployFragment.importBackupSafely` rejects + deletes) and
restore (`TarExtractor.startExtraction(..., validateRootfs=true, ...)` reusing
the D11 listing). Hard-block on a definite wrong arch / non-rootfs.
- Identity manifest (soft): reads `installed-rootfs/iiab/.iiab-rootfs.json`
(`docs/ROOTFS_MANIFEST.md`) when present (authoritative `kind`+`arch`); when
absent, a non-blocking "manifest not found" alert + the ELF/structure fallback.
- Pending: the integrity `iiab-tree-sha256-v1` check (`Result.CORRUPT`, mirrors
`tools/iiab_tree_hash.py`), the in-app backup-writer emitting both members, and
the arbitrary-file attack-vector analysis.

**Legacy (NOT yet layered)** — most of `org.iiab.controller` is still flat:
god classes `MainActivity` and `DeployFragment` (~2.7k LOC), shared mutable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2162,7 +2162,7 @@ private void refreshRestoreButtonLogic() {
TarExtractor tarExtractor = new TarExtractor();

enableSystemProtection();
tarExtractor.startExtraction(requireContext(), backupFile.getAbsolutePath(), iiabRootDir.getAbsolutePath(), new TarExtractor.ExtractionListener() {
tarExtractor.startExtraction(requireContext(), backupFile.getAbsolutePath(), iiabRootDir.getAbsolutePath(), true, new TarExtractor.ExtractionListener() {
@Override
public void onComplete(String destDir) {
mainAct.runOnUiThread(() -> {
Expand Down Expand Up @@ -2275,6 +2275,38 @@ private void importBackupSafely(Uri sourceUri) {
os.close();
is.close();

// Gate the import: must be a valid rootfs of THIS app's architecture
// (ABI policy). Reject and delete otherwise.
org.iiab.controller.deploy.data.RootfsArchiveValidator.Result vr =
org.iiab.controller.deploy.data.RootfsArchiveValidator
.validate(requireContext(), destFile.getAbsolutePath());
boolean okValidated =
vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.OK;
boolean okNoManifest =
vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.OK_NO_MANIFEST;
if (!okValidated && !okNoManifest) {
if (destFile.exists()) destFile.delete();
final int errMsg = (vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.WRONG_ARCH)
? R.string.install_error_wrong_arch
: R.string.install_error_not_rootfs;
if (getActivity() != null) {
getActivity().runOnUiThread(() -> {
isImporting = false;
updateDynamicButtons();
btnImportBackup.setEnabled(true);
btnImportBackup.setText(getString(R.string.install_btn_import_backup));
Snackbar.make(getView(), errMsg, Snackbar.LENGTH_LONG).show();
});
}
return;
}
// Soft phase: no identity manifest -> import is allowed, but warn the
// user (a future version will validate silently). See docs/ROOTFS_MANIFEST.md.
if (okNoManifest && getActivity() != null) {
getActivity().runOnUiThread(() ->
Snackbar.make(getView(), R.string.install_warn_manifest_missing, Snackbar.LENGTH_LONG).show());
}

if (getActivity() != null) {
getActivity().runOnUiThread(() -> {
isImporting = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ default void onProgress(String line) { }
}

public void startExtraction(Context context, String archivePath, String destDir, ExtractionListener listener) {
startExtraction(context, archivePath, destDir, false, listener);
}

/**
* @param validateRootfs when true (untrusted import/restore), also require the
* archive to look like a rootfs of THIS app's architecture before extracting.
*/
public void startExtraction(Context context, String archivePath, String destDir, boolean validateRootfs, ExtractionListener listener) {
if (isExtracting) return;

new Thread(() -> {
Expand All @@ -60,12 +68,28 @@ public void startExtraction(Context context, String archivePath, String destDir,
// bail out (without extracting anything) if any member is absolute
// or climbs out of destDir via "..". An imported/restored backup is
// untrusted, so this runs for every extraction.
for (String entry : listEntries(tarBinary, archivePath, isGzip)) {
List<String> entries = listEntries(tarBinary, archivePath, isGzip);
for (String entry : entries) {
if (ArchiveEntry.escapesRoot(entry)) {
throw new Exception("Unsafe archive entry (path traversal): " + entry);
}
}

// For untrusted imports/restores: it must be a valid rootfs of THIS
// app's architecture (ABI policy: 32<->32, 64<->64). Reuses the
// listing above. Fail closed before extracting.
if (validateRootfs) {
org.iiab.controller.deploy.data.RootfsArchiveValidator.Result vr =
org.iiab.controller.deploy.data.RootfsArchiveValidator
.validateWithEntries(context, archivePath, isGzip, tarBinary, entries);
if (vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.NOT_A_ROOTFS) {
throw new Exception(context.getString(R.string.install_error_not_rootfs));
}
if (vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.WRONG_ARCH) {
throw new Exception(context.getString(R.string.install_error_wrong_arch));
}
}

// 2. BUILD THE COMMAND
List<String> command = new ArrayList<>();
command.add(tarBinary);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/*
* ============================================================================
* Name : RootfsArchiveValidator.java
* Author : IIAB Project
* Copyright : Copyright (c) 2026 IIAB Project
* Description : Gate for imported/restored backups: is the tar archive a valid
* rootfs, and is it the SAME architecture as this app (ABI policy:
* ARM64<->ARM64, 32<->32)? Hard-blocks a positively-wrong arch.
* ============================================================================
*/
package org.iiab.controller.deploy.data;

import android.content.Context;
import android.util.Log;

import org.iiab.controller.deploy.domain.ElfClass;
import org.iiab.controller.deploy.domain.RootfsArchive;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.zip.GZIPInputStream;

/**
* Validates a {@code .tar.gz}/{@code .tar.xz} before it is imported or restored:
* <ol>
* <li>structural sanity — it must look like a rootfs (rejects "imported a ZIM
* or a random file");</li>
* <li>architecture — if we can positively read an ELF binary inside, its class
* (32/64-bit) must match this app's ABI; otherwise it is hard-blocked.</li>
* </ol>
*
* <p>Per the ABI-separation policy a definite architecture mismatch is blocked.
* When the architecture cannot be determined (no probe binary, or the probed
* member is a script/symlink) we do NOT block on arch (avoids false positives);
* the structural check still applies.
*
* <p>Must run off the main thread (spawns {@code tar}).
*/
public final class RootfsArchiveValidator {

private static final String TAG = "IIAB-RootfsValidator";

public enum Result { OK, OK_NO_MANIFEST, NOT_A_ROOTFS, WRONG_ARCH, UNREADABLE }

private RootfsArchiveValidator() {
// Static utility; not instantiable.
}

/** Validate from a file path (lists the archive itself; use for the import gate). */
public static Result validate(Context context, String archivePath) {
try {
String tarBinary = resolveTar(context);
boolean isGzip = archivePath.toLowerCase(Locale.US).endsWith(".gz");
List<String> entries = listEntries(tarBinary, archivePath, isGzip);
if (entries.isEmpty()) {
return Result.UNREADABLE;
}
return validateWithEntries(context, archivePath, isGzip, tarBinary, entries);
} catch (Exception e) {
Log.e(TAG, "Validation error", e);
return Result.UNREADABLE;
}
}

/**
* Validate when the caller already has the entry listing (e.g. {@code TarExtractor}
* lists once for the D11 traversal guard — reuse it here, no second listing).
*/
public static Result validateWithEntries(Context context, String archivePath,
boolean isGzip, String tarBinary, List<String> entries) {
try {
// Authoritative path: the build/app embeds an identity manifest
// (installed-rootfs/iiab/.iiab-rootfs.json, packed first). See
// docs/ROOTFS_MANIFEST.md. When present it decides kind + arch.
RootfsManifest.Identity id = RootfsManifest.read(archivePath);
if (id.present) {
if (!"iiab-rootfs".equals(id.kind)) {
return Result.NOT_A_ROOTFS;
}
if (id.arch != null && !id.arch.isEmpty()
&& !id.arch.equals(RootfsManifest.appAbiId())) {
return Result.WRONG_ARCH;
}
return Result.OK; // manifest-validated; no need to probe ELF
}

// Soft fallback (no manifest): legacy ELF/structure heuristic. We
// return OK_NO_MANIFEST so the caller can surface a "manifest not
// found" alert (non-blocking) for this first version.
if (!RootfsArchive.looksLikeRootfs(entries)) {
return Result.NOT_A_ROOTFS;
}
String probe = RootfsArchive.pickBinaryEntry(entries);
if (probe == null) {
return Result.OK_NO_MANIFEST; // structurally a rootfs; cannot probe arch
}
byte[] header = readMemberHeader(tarBinary, archivePath, isGzip, probe, 8);
int cls = ElfClass.of(header);
if (cls == ElfClass.UNKNOWN) {
return Result.OK_NO_MANIFEST; // probed member wasn't a plain ELF -> arch undetermined
}
int want = android.os.Process.is64Bit() ? ElfClass.BITS_64 : ElfClass.BITS_32;
return (cls == want) ? Result.OK_NO_MANIFEST : Result.WRONG_ARCH;
} catch (Exception e) {
Log.e(TAG, "Validation (with entries) error", e);
return Result.UNREADABLE;
}
}

private static String resolveTar(Context context) {
File staticTar = new File(context.getApplicationInfo().nativeLibraryDir, "libtar.so");
return staticTar.exists() ? staticTar.getAbsolutePath() : "/system/bin/tar";
}

private static List<String> listEntries(String tarBinary, String archivePath, boolean isGzip) throws Exception {
List<String> names = new ArrayList<>();
List<String> cmd = new ArrayList<>();
cmd.add(tarBinary);
if (isGzip) {
cmd.add("-t");
cmd.add("-f");
cmd.add("-");
} else {
cmd.add("-tf");
cmd.add(archivePath);
}
Process p = new ProcessBuilder(cmd).start();
Thread feeder = isGzip ? startGzipFeeder(archivePath, p.getOutputStream()) : null;
try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
String line;
while ((line = r.readLine()) != null) {
names.add(line);
}
}
p.waitFor();
if (feeder != null) {
feeder.join();
}
return names;
}

private static byte[] readMemberHeader(String tarBinary, String archivePath, boolean isGzip,
String member, int n) throws Exception {
List<String> cmd = new ArrayList<>();
cmd.add(tarBinary);
cmd.add("-x");
cmd.add("-O");
cmd.add("-f");
cmd.add(isGzip ? "-" : archivePath);
cmd.add(member);
Process p = new ProcessBuilder(cmd).start();
Thread feeder = isGzip ? startGzipFeeder(archivePath, p.getOutputStream()) : null;

byte[] buf = new byte[n];
int got = 0;
try (InputStream is = p.getInputStream()) {
int r;
while (got < n && (r = is.read(buf, got, n - got)) != -1) {
got += r;
}
}
p.destroy(); // we only need the header; let tar stop
if (feeder != null) {
feeder.join(300);
}
if (got < 5) {
return null;
}
byte[] out = new byte[got];
System.arraycopy(buf, 0, out, 0, got);
return out;
}

private static Thread startGzipFeeder(String archivePath, OutputStream os) {
Thread t = new Thread(() -> {
try (GZIPInputStream gis = new GZIPInputStream(new FileInputStream(archivePath))) {
byte[] buffer = new byte[8192];
int read;
while ((read = gis.read(buffer)) != -1) {
os.write(buffer, 0, read);
}
os.flush();
} catch (Exception ignored) {
// broken pipe once we stop reading is expected
} finally {
try {
os.close();
} catch (Exception ignored) {
}
}
});
t.start();
return t;
}
}
Loading
Loading