#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-2.0-or-later """Fetch only A6 boot-chain files from Apple's iOS 10.3.4 IPSW. The complete IPSW is about 2 GB. This utility reads its ZIP directory with HTTP range requests, then downloads only BuildManifest.plist and the selected iBSS/iBEC pair. It does not personalize or decrypt the images. """ import argparse import binascii import os from pathlib import Path import re import struct import sys import urllib.request import zlib DEFAULT_URL = ( "https://updates.cdn-apple.com/2019/ios/" "091-25277-20190722-0C1B94DE-992C-11E9-A2EE-E2C9A77C2E40/" "iPhone_4.0_32bit_10.3.4_14G61_Restore.ipsw" ) EOCD = struct.Struct("<4s4H2IH") CENTRAL = struct.Struct("<4s6H3I5H2I") LOCAL = struct.Struct("<4s5H3I2H") class RangeReader: def __init__(self, url): self.url = url self.total_size = None def read(self, start, end): request = urllib.request.Request( self.url, headers={"Range": f"bytes={start}-{end}"}, ) with urllib.request.urlopen(request, timeout=60) as response: if response.status != 206: raise RuntimeError( f"server ignored byte range {start}-{end} " f"(HTTP {response.status})" ) content_range = response.headers.get("Content-Range", "") match = re.fullmatch(r"bytes (\d+)-(\d+)/(\d+)", content_range) if not match: raise RuntimeError(f"invalid Content-Range: {content_range!r}") actual_start, actual_end, total = map(int, match.groups()) if (actual_start, actual_end) != (start, end): raise RuntimeError( f"unexpected byte range {actual_start}-{actual_end}" ) self.total_size = total data = response.read(end - start + 1) if len(data) != end - start + 1: raise RuntimeError("truncated HTTP range response") return data def get_size(self): self.read(0, 0) return self.total_size def read_directory(reader): total_size = reader.get_size() tail_size = min(total_size, 65557) tail_offset = total_size - tail_size tail = reader.read(tail_offset, total_size - 1) eocd_offset = tail.rfind(b"PK\x05\x06") if eocd_offset < 0 or eocd_offset + EOCD.size > len(tail): raise RuntimeError("ZIP end-of-central-directory record not found") fields = EOCD.unpack_from(tail, eocd_offset) ( _, disk, directory_disk, disk_entries, entries, size, offset, comment, ) = fields if disk or directory_disk or disk_entries != entries: raise RuntimeError("multi-disk ZIP archives are unsupported") if eocd_offset + EOCD.size + comment > len(tail): raise RuntimeError("truncated ZIP comment") if offset == 0xFFFFFFFF or size == 0xFFFFFFFF or entries == 0xFFFF: raise RuntimeError("ZIP64 directory is unsupported") data = reader.read(offset, offset + size - 1) result = {} cursor = 0 for _ in range(entries): if cursor + CENTRAL.size > len(data): raise RuntimeError("truncated ZIP central directory") fields = CENTRAL.unpack_from(data, cursor) if fields[0] != b"PK\x01\x02": raise RuntimeError("invalid ZIP central-directory signature") flags = fields[3] compressed_size = fields[8] uncompressed_size = fields[9] name_length = fields[10] extra_length = fields[11] comment_length = fields[12] local_offset = fields[16] name_start = cursor + CENTRAL.size name_end = name_start + name_length encoding = "utf-8" if flags & 0x800 else "cp437" name = data[name_start:name_end].decode(encoding) result[name] = { "flags": flags, "method": fields[4], "crc32": fields[7], "compressed_size": compressed_size, "uncompressed_size": uncompressed_size, "local_offset": local_offset, } cursor = name_end + extra_length + comment_length return result def extract_entry(reader, entry): if entry["flags"] & 1: raise RuntimeError("encrypted ZIP entries are unsupported") if entry["compressed_size"] == 0xFFFFFFFF: raise RuntimeError("ZIP64 entries are unsupported") local_offset = entry["local_offset"] header = reader.read(local_offset, local_offset + LOCAL.size - 1) fields = LOCAL.unpack(header) if fields[0] != b"PK\x03\x04": raise RuntimeError("invalid ZIP local-header signature") name_length = fields[9] extra_length = fields[10] data_offset = local_offset + LOCAL.size + name_length + extra_length compressed_size = entry["compressed_size"] compressed = reader.read(data_offset, data_offset + compressed_size - 1) if entry["method"] == 0: data = compressed elif entry["method"] == 8: data = zlib.decompress(compressed, -zlib.MAX_WBITS) else: raise RuntimeError(f"unsupported ZIP method {entry['method']}") if len(data) != entry["uncompressed_size"]: raise RuntimeError("uncompressed size mismatch") if binascii.crc32(data) & 0xFFFFFFFF != entry["crc32"]: raise RuntimeError("CRC-32 mismatch") return data def write_atomic(path, data): path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(path.name + ".tmp") temporary.write_bytes(data) os.replace(temporary, path) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--board", choices=("n41", "n42"), default="n41") parser.add_argument("--url", default=DEFAULT_URL) parser.add_argument("--output-dir", type=Path, default=Path("firmware")) args = parser.parse_args() reader = RangeReader(args.url) directory = read_directory(reader) requested = ["BuildManifest.plist"] ipsw_board = "iphone5" for component in ("iBSS", "iBEC"): candidates = ( f"Firmware/dfu/{component}.{args.board}.RELEASE.dfu", f"Firmware/dfu/{component}.{args.board}ap.RELEASE.dfu", f"Firmware/dfu/{component}.{ipsw_board}.RELEASE.dfu", ) match = next((name for name in candidates if name in directory), None) if not match: available = sorted( name for name in directory if "/dfu/" in name.lower() and component.lower() in name.lower() ) raise RuntimeError( "missing IPSW entry; tried: " + ", ".join(candidates) + "; available: " + (", ".join(available) or "none") ) requested.append(match) for name in requested: destination = args.output_dir / Path(name).name print(f"Fetching {name} -> {destination}", flush=True) write_atomic(destination, extract_entry(reader, directory[name])) print("Raw Apple images extracted; personalization is still required.") if __name__ == "__main__": try: main() except Exception as error: print(f"error: {error}", file=sys.stderr) sys.exit(1)