#!/usr/bin/env python3 import gzip import hashlib import json import math import os import shutil import struct import subprocess from pathlib import Path BASE = Path("/data/temp/gb1/gb2") EWF = BASE / "IMAGE_2.E01" EWF_MOUNT = BASE / "ewf_mount" RAW = EWF_MOUNT / "ewf1" OUT = BASE / "img" SECTOR = 512 PARTITIONS = [ ("0.APP.img", "APP", 3050048 * SECTOR, 247019592 * SECTOR, "app"), ("1.A_kernel.img", "A_KERNEL", 40 * SECTOR, 262144 * SECTOR, "boot"), ("2.A_kernel-dtb.img", "A_KERNEL-DTB", 262184 * SECTOR, 1536 * SECTOR, "dtb"), ("3.A_reserved_on_user.img", "A_RESERVED_ON_USER", 263720 * SECTOR, 64768 * SECTOR, "opaque"), ("4.B_kernel.img", "B_KERNEL", 328488 * SECTOR, 262144 * SECTOR, "boot"), ("5.B_kernel-dtb.img", "B_KERNEL-DTB", 590632 * SECTOR, 1536 * SECTOR, "dtb"), ("6.B_reserved_on_user.img", "B_RESERVED_ON_USER", 592168 * SECTOR, 64768 * SECTOR, "opaque"), ("7.recovery.img", "RECOVERY", 656936 * SECTOR, 163840 * SECTOR, "boot"), ("8.recovery-dtb.img", "RECOVERY-DTB", 820776 * SECTOR, 1024 * SECTOR, "dtb"), ("9.esp.img", "ESP", 821800 * SECTOR, 131072 * SECTOR, "fat"), ("10.recovery_alt.img", "RECOVERY_ALT", 952872 * SECTOR, 163840 * SECTOR, "opaque"), ("11.recovery-dtb_alt.img", "RECOVERY-DTB_ALT", 1116712 * SECTOR, 1024 * SECTOR, "opaque"), ("12.esp_alt.img", "ESP_ALT", 1117736 * SECTOR, 131072 * SECTOR, "fat"), ("13.UDA.img", "UDA", 1248832 * SECTOR, 819200 * SECTOR, "uda"), ("14.reserved.img", "RESERVED", 2068032 * SECTOR, 982016 * SECTOR, "apt"), ] SIGNATURES = [ ("android_boot", b"ANDROID!"), ("dtb", bytes.fromhex("d00dfeed")), ("elf", b"\x7fELF"), ("pe_mz", b"MZ"), ("gzip", b"\x1f\x8b\x08"), ("fat_oem_mkfs", b"mkfs.fat"), ("fat32_label", b"FAT32"), ("ext_magic", b"\x53\xef"), ] def log(message): print(message, flush=True) def run(cmd, *, cwd=None, stdout_path=None, stderr_path=None, check=False, input_path=None): stdout = subprocess.PIPE if stdout_path is None else open(stdout_path, "wb") stderr = subprocess.PIPE if stderr_path is None else open(stderr_path, "wb") stdin = None if input_path is None else open(input_path, "rb") try: proc = subprocess.run( cmd, cwd=cwd, stdin=stdin, stdout=stdout, stderr=stderr, check=False, ) out = b"" if stdout_path is not None else proc.stdout err = b"" if stderr_path is not None else proc.stderr if check and proc.returncode != 0: raise RuntimeError( f"command failed ({proc.returncode}): {' '.join(map(str, cmd))}\n" f"{err.decode(errors='replace')}" ) return proc.returncode, out.decode(errors="replace"), err.decode(errors="replace") finally: if stdout_path is not None: stdout.close() if stderr_path is not None: stderr.close() if stdin is not None: stdin.close() def is_mountpoint(path): return subprocess.run(["mountpoint", "-q", str(path)]).returncode == 0 def mount_ewf(): EWF_MOUNT.mkdir(exist_ok=True) if RAW.exists() and is_mountpoint(EWF_MOUNT): return False if is_mountpoint(EWF_MOUNT): run(["fusermount", "-u", str(EWF_MOUNT)], check=True) run(["ewfmount", str(EWF), str(EWF_MOUNT)], check=True) return True def unmount_ewf(): if is_mountpoint(EWF_MOUNT): run(["fusermount", "-u", str(EWF_MOUNT)]) def sha256_file(path): h = hashlib.sha256() with open(path, "rb") as fh: for chunk in iter(lambda: fh.read(8 * 1024 * 1024), b""): h.update(chunk) return h.hexdigest() def file_type(path): rc, out, err = run(["file", "-s", str(path)]) return (out or err).strip() def copy_slice(src, dst, offset, size, chunk_size=8 * 1024 * 1024): dst.parent.mkdir(parents=True, exist_ok=True) with open(src, "rb") as inf, open(dst, "wb") as outf: inf.seek(offset) remaining = size while remaining: chunk = inf.read(min(chunk_size, remaining)) if not chunk: break outf.write(chunk) remaining -= len(chunk) def link_or_sparse_copy(src, dst): dst.parent.mkdir(parents=True, exist_ok=True) if dst.exists(): return try: os.link(src, dst) except OSError: run(["cp", "--sparse=always", "--reflink=auto", str(src), str(dst)], check=True) def write_strings(path, out_path, limit=5000): out_path.parent.mkdir(parents=True, exist_ok=True) with open(out_path, "wb") as outf: p1 = subprocess.Popen(["strings", "-a", str(path)], stdout=subprocess.PIPE) p2 = subprocess.Popen(["head", f"-{limit}"], stdin=p1.stdout, stdout=outf) p1.stdout.close() p2.wait() p1.wait() def write_signature_hits(path, out_path, max_hits=200, max_scan=700 * 1024 * 1024): size = path.stat().st_size hits = [] if size <= max_scan: data = path.read_bytes() for name, sig in SIGNATURES: start = 0 while len(hits) < max_hits: idx = data.find(sig, start) if idx < 0: break hits.append((idx, name)) start = idx + 1 hits.sort() out_path.parent.mkdir(parents=True, exist_ok=True) with open(out_path, "w", encoding="utf-8") as fh: for offset, name in hits[:max_hits]: fh.write(f"{offset}\t0x{offset:x}\t{name}\n") if size > max_scan: fh.write(f"# skipped full scan for {size} byte file\n") return len(hits[:max_hits]) def first_last_nonzero(path): size = path.stat().st_size chunk_size = 8 * 1024 * 1024 first = None last = None with open(path, "rb") as fh: offset = 0 while True: chunk = fh.read(chunk_size) if not chunk: break if chunk.strip(b"\x00"): if first is None: for i, b in enumerate(chunk): if b: first = offset + i break for i in range(len(chunk) - 1, -1, -1): if chunk[i]: last = offset + i break offset += len(chunk) if first is None: return None, None, 0, size return first, last, last - first + 1, size def entropy(path, max_bytes=None): counts = [0] * 256 total = 0 with open(path, "rb") as fh: remaining = max_bytes while True: want = 4 * 1024 * 1024 if remaining is None else min(4 * 1024 * 1024, remaining) if want <= 0: break chunk = fh.read(want) if not chunk: break total += len(chunk) for b in chunk: counts[b] += 1 if remaining is not None: remaining -= len(chunk) if not total: return 0.0 return -sum((c / total) * math.log2(c / total) for c in counts if c) def android_boot_offset(path): with open(path, "rb") as fh: data = fh.read(1024 * 1024) return data.find(b"ANDROID!") def align(value, page_size): return ((value + page_size - 1) // page_size) * page_size def extract_android_boot(path, part_dir, display_name): rec = part_dir / "recovery" unpack = rec / "unpacked_boot" unpack.mkdir(parents=True, exist_ok=True) notes = [] notes.append(f"Source: {path}") notes.append(f"Copied image: {part_dir / path.name}") notes.append(f"Source apparent size: {path.stat().st_size} bytes") notes.append(f"Source SHA-256: {sha256_file(path)}") notes.append(f"Initial file type: {file_type(path)}") base = android_boot_offset(path) if base < 0: notes.append("No Android boot image magic found in the first 1 MiB.") write_common_reports(path, rec) write_notes(rec, notes) return with open(path, "rb") as fh: fh.seek(base) hdr = fh.read(1660) fields = struct.unpack_from("<10I", hdr, 8) kernel_size, kernel_addr, ramdisk_size, ramdisk_addr = fields[0], fields[1], fields[2], fields[3] second_size, second_addr, tags_addr, page_size = fields[4], fields[5], fields[6], fields[7] dt_size_or_header_version = fields[8] cmdline = hdr[64 + 16:64 + 16 + 512].split(b"\x00", 1)[0].decode(errors="replace") kernel_off = base + page_size ramdisk_off = kernel_off + align(kernel_size, page_size) second_off = ramdisk_off + align(ramdisk_size, page_size) dt_off = second_off + align(second_size, page_size) copy_slice(path, unpack / "kernel", kernel_off, kernel_size) copy_slice(path, unpack / "ramdisk", ramdisk_off, ramdisk_size) header = { "base_offset": base, "page_size": page_size, "kernel_size": kernel_size, "kernel_offset": kernel_off, "ramdisk_size": ramdisk_size, "ramdisk_offset": ramdisk_off, "second_size": second_size, "second_offset": second_off, "dt_size_or_header_version": dt_size_or_header_version, "dt_offset": dt_off, "cmdline": cmdline, } (unpack / "boot_header.json").write_text(json.dumps(header, indent=2) + "\n", encoding="utf-8") with open(unpack / "ramdisk.cpio", "wb") as outfh, open(unpack / "ramdisk", "rb") as infh: try: with gzip.GzipFile(fileobj=infh) as gz: shutil.copyfileobj(gz, outfh) (unpack / "ramdisk_decompress.log").write_text("", encoding="utf-8") except Exception as exc: (unpack / "ramdisk_decompress.log").write_text(f"{exc}\n", encoding="utf-8") root = unpack / "ramdisk_root" root.mkdir(exist_ok=True) cpio_log = unpack / "cpio_extract.txt" if (unpack / "ramdisk.cpio").stat().st_size: rc, out, err = run( ["cpio", "-idm", "--quiet", "--no-absolute-filenames"], cwd=root, input_path=unpack / "ramdisk.cpio", ) cpio_log.write_text(out + err + f"cpio_exit={rc}\n", encoding="utf-8") rc, out, err = run(["file", str(unpack / "kernel"), str(unpack / "ramdisk"), str(unpack / "ramdisk.cpio")]) (unpack / "file_report.txt").write_text(out + err, encoding="utf-8") write_common_reports(path, rec) notes.append("Android boot image parsed into recovery/unpacked_boot.") if base: notes.append(f"Android boot magic was found at byte offset {base}.") notes.append( f"Boot page size: {page_size}; kernel={kernel_size}; ramdisk={ramdisk_size}; " f"second={second_size}; dt={dt_size_or_header_version}." ) notes.append(f"Extracted kernel at offset {kernel_off}, size {kernel_size}.") notes.append(f"Extracted ramdisk at offset {ramdisk_off}, size {ramdisk_size}.") notes.append(f"Ramdisk file type: {file_type(unpack / 'ramdisk')}") notes.append(f"Decompressed gzip ramdisk to recovery/unpacked_boot/ramdisk.cpio, size {(unpack / 'ramdisk.cpio').stat().st_size}.") notes.append("Extracted ramdisk.cpio into recovery/unpacked_boot/ramdisk_root.") notes.append("See recovery/signature_hits.txt and recovery/strings.first5000.txt for scan output.") write_notes(rec, notes) def extract_dtb(path, part_dir, label): rec = part_dir / "recovery" dtb_dir = rec / "dtb" dtb_dir.mkdir(parents=True, exist_ok=True) notes = base_notes(path, part_dir) with open(path, "rb") as fh: data = fh.read(1024 * 1024) off = data.find(bytes.fromhex("d00dfeed")) if off >= 0 and off + 8 <= len(data): total_size = struct.unpack_from(">I", data, off + 4)[0] out = dtb_dir / f"{label}.dtb" copy_slice(path, out, off, total_size) notes.append(f"Device-tree blob recovered to recovery/dtb/{out.name}, declared size {total_size}.") if off: notes.append(f"Device-tree magic was found at byte offset {off}.") notes.append(f"DTB file type: {file_type(out)}") else: notes.append("No device-tree blob magic found in the first 1 MiB.") hit_count = write_common_reports(path, rec) notes.append(f"Wrote signature scan with {hit_count} hit(s) to recovery/signature_hits.txt.") notes.append("Captured first strings to recovery/strings.first5000.txt.") write_notes(rec, notes) def find_fat_boot(path): size = path.stat().st_size candidates = set() chunk_size = 8 * 1024 * 1024 overlap = 512 with open(path, "rb") as fh: offset = 0 prev = b"" while offset < size: chunk = fh.read(chunk_size) if not chunk: break data = prev + chunk base = offset - len(prev) for sig, back in [(b"mkfs.fat", 3), (b"FAT32", 82)]: start = 0 while True: idx = data.find(sig, start) if idx < 0: break candidates.add(base + idx - back) start = idx + 1 prev = data[-overlap:] offset += len(chunk) for cand in sorted(c for c in candidates if c >= 0): with open(path, "rb") as fh: fh.seek(cand) bs = fh.read(512) if len(bs) < 90: continue bps = struct.unpack_from("= 0 and fs_start % 512 == 0: sb = read_at(path, fs_start + 1024, 1024) if len(sb) == 1024 and sb[56:58] == b"\x53\xef": inodes, blocks = struct.unpack_from("