feature: Add ipa support#204
Conversation
There was a problem hiding this comment.
Pull request overview
Adds experimental iOS (.ipa / app bundle) support by introducing Mach-O parsing and updating tooling/scripts to locate and analyze iOS Flutter binaries alongside existing Android ELF support.
Changes:
- Add Mach-O parsing utilities and tests for snapshot/hash/engine-id extraction.
- Extend
blutter.pyto accept.ipainputs and extractApp.framework/App+Flutter.framework/Flutter. - Update Frida script/template generation to support iOS module naming and non-compressed-pointer configurations.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_macho.py | Adds unit tests for Mach-O parsing and IPA framework extraction. |
| macho.py | New Mach-O reader for fat/thin images, snapshot note/section handling, engine id + Dart version extraction. |
| extract_dart_info.py | Adds Mach-O path for snapshot/engine/dart version extraction; expands ELF arch handling. |
| blutter.py | Adds .ipa extraction and broader app bundle discovery logic. |
| scripts/frida.template.js | Introduces placeholders for module names and compressed-pointer mode; improves module discovery. |
| blutter/src/FridaWriter.cpp | Replaces raw template copy with placeholder substitution for iOS/module + pointer mode. |
| blutter/src/ElfHelper.{h,cpp} | Renames APIs and adds Mach-O snapshot discovery in the native mapper. |
| blutter/src/CodeAnalyzer_arm64.cpp | Makes several compressed-pointer assumptions conditional; improves operand scaling detection. |
| blutter/src/Disassembler_arm64.h | Adjusts register constant compilation around heap register. |
| blutter/src/DartFunction.h | Initializes signature fields to safe defaults. |
| blutter/src/DartApp.cpp | Updates to renamed ELF/Mach-O mapping API and adds null-function guard during finalization. |
| scripts/CMakeLists.txt | Adjusts iOS target OS defines for Dart VM builds. |
| README.md | Documents experimental iOS snapshot extraction and IPA/app bundle usage. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| const uint32_t beMagic = read_be32(file); | ||
| if (beMagic == macho::FAT_MAGIC || beMagic == macho::FAT_MAGIC_64) { |
There was a problem hiding this comment.
select_macho_image reads nfat from file + 4 without first ensuring fileSize >= sizeof(fat_header) (8 bytes). A truncated file that starts with a FAT magic will cause an out-of-bounds read and may crash. Add an explicit fileSize check (or use checked_ptr<fat_header> and validate) before accessing bytes past the first 4.
| if (beMagic == macho::FAT_MAGIC || beMagic == macho::FAT_MAGIC_64) { | |
| if (beMagic == macho::FAT_MAGIC || beMagic == macho::FAT_MAGIC_64) { | |
| if (fileSize < sizeof(macho::fat_header)) { | |
| throw std::invalid_argument("Mach-O: truncated fat header"); | |
| } |
| std::ifstream templateFile(FRIDA_TEMPLATE_DIR "/frida.template.js"); | ||
| std::stringstream templateBuffer; | ||
| templateBuffer << templateFile.rdbuf(); | ||
| auto code = templateBuffer.str(); |
There was a problem hiding this comment.
FridaWriter::Create does not verify that the Frida template file opened successfully before reading it. If the file is missing/unreadable, code will be empty and the later placeholder replacement will throw a misleading "Missing ... placeholder" error. Check templateFile.is_open() (and optionally templateOut open state) and surface an explicit file I/O error.
| magic = _u32be(data, 0) | ||
| if magic not in (FAT_MAGIC, FAT_MAGIC_64): | ||
| return [] | ||
|
|
||
| nfat_arch = _u32be(data, 4) | ||
| arches = [] | ||
| offset = 8 | ||
| for _ in range(nfat_arch): | ||
| if magic == FAT_MAGIC: | ||
| cputype, _, arch_offset, arch_size, _ = struct.unpack_from(">IIIII", data, offset) | ||
| offset += 20 | ||
| else: | ||
| cputype, _, arch_offset, arch_size, _, _ = struct.unpack_from(">IIQQII", data, offset) | ||
| offset += 32 | ||
| arches.append(FatArch(cputype, arch_offset, arch_size)) |
There was a problem hiding this comment.
_parse_fat_arches assumes the file is long enough for the fat header and each arch entry; on truncated/invalid inputs struct.unpack_from will raise a raw struct.error. Add explicit length/bounds checks (including verifying offset + entry_size <= len(data)) and fail with a clear assertion/exception message.
| magic = _u32be(data, 0) | |
| if magic not in (FAT_MAGIC, FAT_MAGIC_64): | |
| return [] | |
| nfat_arch = _u32be(data, 4) | |
| arches = [] | |
| offset = 8 | |
| for _ in range(nfat_arch): | |
| if magic == FAT_MAGIC: | |
| cputype, _, arch_offset, arch_size, _ = struct.unpack_from(">IIIII", data, offset) | |
| offset += 20 | |
| else: | |
| cputype, _, arch_offset, arch_size, _, _ = struct.unpack_from(">IIQQII", data, offset) | |
| offset += 32 | |
| arches.append(FatArch(cputype, arch_offset, arch_size)) | |
| if len(data) < 4: | |
| raise ValueError("Mach-O data too short to read fat magic") | |
| magic = _u32be(data, 0) | |
| if magic not in (FAT_MAGIC, FAT_MAGIC_64): | |
| return [] | |
| if len(data) < 8: | |
| raise ValueError("Mach-O fat header too short") | |
| nfat_arch = _u32be(data, 4) | |
| arches = [] | |
| offset = 8 | |
| entry_size = 20 if magic == FAT_MAGIC else 32 | |
| for i in range(nfat_arch): | |
| if offset + entry_size > len(data): | |
| raise ValueError( | |
| f"Mach-O fat arch table truncated: entry {i} at offset {offset} " | |
| f"requires {entry_size} bytes, file has {len(data)} bytes" | |
| ) | |
| if magic == FAT_MAGIC: | |
| cputype, _, arch_offset, arch_size, _ = struct.unpack_from(">IIIII", data, offset) | |
| else: | |
| cputype, _, arch_offset, arch_size, _, _ = struct.unpack_from(">IIQQII", data, offset) | |
| if arch_offset + arch_size > len(data): | |
| raise ValueError( | |
| f"Mach-O fat arch slice out of bounds: entry {i} has offset {arch_offset} " | |
| f"and size {arch_size}, file has {len(data)} bytes" | |
| ) | |
| arches.append(FatArch(cputype, arch_offset, arch_size)) | |
| offset += entry_size |
| if elf.header.e_machine == 'EM_AARCH64': # 183 | ||
| arch = 'arm64' | ||
| elif elf.header.e_machine == 'EM_IA_64': # 50 | ||
| elif elf.header.e_machine in ('EM_X86_64', 'EM_IA_64'): # 62, 50 |
There was a problem hiding this comment.
EM_IA_64 represents the Itanium architecture, not x86_64. Treating it as 'x64' can mislabel binaries and lead to incorrect downstream assumptions. Consider handling only EM_X86_64 as 'x64' (and error out or add a separate label for EM_IA_64).
| elif elf.header.e_machine in ('EM_X86_64', 'EM_IA_64'): # 62, 50 | |
| elif elf.header.e_machine == 'EM_X86_64': # 62 |
| HANDLE hMapFile = CreateFileMapping(hFile, NULL, PAGE_READONLY, 0, 0, NULL); | ||
| if (hMapFile == INVALID_HANDLE_VALUE) | ||
| return NULL; | ||
| if (hMapFile == INVALID_HANDLE_VALUE) { | ||
| CloseHandle(hFile); | ||
| throw std::invalid_argument(std::format("Cannot map input file: {}", path)); | ||
| } | ||
|
|
||
| // need RW because dart initialization need writing data in BSS | ||
| void* mem = MapViewOfFile(hMapFile, FILE_MAP_COPY, 0, 0, 0); | ||
| CloseHandle(hMapFile); |
There was a problem hiding this comment.
On Windows, CreateFileMapping returns NULL on failure (not INVALID_HANDLE_VALUE), so the current check won't catch mapping creation errors at the right point. Also, MapViewOfFile(..., FILE_MAP_COPY, ...) typically requires creating the mapping with a compatible protection such as PAGE_WRITECOPY/PAGE_EXECUTE_WRITECOPY; using PAGE_READONLY may cause the view mapping to fail. Update the failure check and ensure the protection/access flags are consistent.
No description provided.