Model the A6 crypto, interrupt, USB, and platform blocks needed to boot SecureROM through iBSS into iBEC Recovery. Add local lab identity, IMG3, and APTicket tooling, patched macOS recovery utilities, UART and GDB access, and English end-user documentation.
200 lines
7.1 KiB
Python
Executable File
200 lines
7.1 KiB
Python
Executable File
#!/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)
|