Created
September 7, 2026 19:11
-
-
Save samhenrigold/3beed2b564774c7293c912adefb02000 to your computer and use it in GitHub Desktop.
Hopper class decompilation script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # -*- coding: utf-8 -*- | |
| import re | |
| import gc | |
| import os | |
| IGNORED_CLASS_PREFIXES = [ | |
| 'AFNetwork', 'AFHTTP', 'AFURL', 'AFSecurity', | |
| 'Flurry', 'FMDatabase', | |
| 'MBProgressHUD', 'MJ', | |
| 'SDWebImage', 'EX', 'ESD' | |
| ] | |
| IGNORED_CLASS_LABEL_NAMES = [ | |
| '-[ClassName methodName:]', | |
| ] | |
| label_re = re.compile(r'^([+-])\[(.+?)\s(.+)\]') | |
| return_re = re.compile(r'return\s.+;') | |
| # Heuristics and performance tuning | |
| MAX_BASIC_BLOCKS = 2000 # skip decompilation for huge procedures | |
| MAX_HEAP_SIZE_BYTES = 128 * 1024 # skip procedures with very large heap | |
| PRINT_EVERY = 250 # progress print throttling | |
| # --- Assembly emission ------------------------------------------------------- | |
| INCLUDE_ASSEMBLY = True # emit disassembly as comments after each method | |
| ASM_MAX_INSTRUCTIONS = 4000 # per-procedure cap; prevents runaway output | |
| ASM_SHOW_BLOCK_HEADERS = True # annotate basic block boundaries | |
| ASM_POSITION = 'after' # 'after' or 'before' the pseudocode body | |
| # ----------------------------------------------------------------------------- | |
| def is_ignored_class(class_name): | |
| for prefix in IGNORED_CLASS_PREFIXES: | |
| if class_name.startswith(prefix): | |
| return True | |
| return False | |
| def is_ignored_method(label_name): | |
| for name in IGNORED_CLASS_LABEL_NAMES: | |
| if name == label_name: | |
| return True | |
| return False | |
| def get_file_path(class_name): | |
| return '%s/%s.m' % (path, class_name) | |
| def get_header_file_path(class_name): | |
| return '%s/%s.h' % (path, class_name) | |
| def get_file_header(class_name): | |
| return '@implementation %s\n\n' % class_name | |
| def get_file_footer(): | |
| return '@end\n' | |
| def get_header_file_header(class_name): | |
| return '@interface %s : NSObject\n\n' % class_name | |
| def get_header_file_footer(): | |
| return '@end\n' | |
| def parse_label_name(label_name): | |
| result = label_re.search(label_name) | |
| if result: | |
| symbol, class_name, method_name = result.groups() | |
| params_count = method_name.count(':') | |
| params = tuple('arg%d' % (i + 2) for i in range(params_count)) | |
| # Use the tuple with .replace and strip as before: | |
| method_name_formatted = method_name.replace(':', ':(id)%s ') % params | |
| method_name_formatted = method_name_formatted.strip() | |
| method_impl_signature = '%s (%%s)%s' % (symbol, method_name_formatted) | |
| method_decl_signature = '%s (%%s)%s;' % (symbol, method_name_formatted) | |
| return class_name, method_impl_signature, method_decl_signature | |
| else: | |
| return None, None, None | |
| # --- Disassembly helpers ----------------------------------------------------- | |
| def _instruction_text(instruction): | |
| """Render one instruction as 'mnemonic op1, op2'. | |
| Hopper's getInstructionString() returns the mnemonic only, so operands have | |
| to be pulled separately. getArgumentString() gives the symbolised form | |
| (function names, string literals) where available; getRawArgument() is the | |
| literal text fallback. | |
| """ | |
| try: | |
| mnemonic = instruction.getInstructionString() or '' | |
| except Exception: | |
| return '<unreadable>' | |
| args = [] | |
| try: | |
| arg_count = instruction.getArgumentCount() | |
| except Exception: | |
| arg_count = 0 | |
| for n in range(arg_count): | |
| text = None | |
| # Prefer the symbolised operand if this Hopper build exposes it. | |
| try: | |
| text = instruction.getArgumentString(n) | |
| except Exception: | |
| text = None | |
| if not text: | |
| try: | |
| text = instruction.getRawArgument(n) | |
| except Exception: | |
| text = None | |
| if text: | |
| args.append(text) | |
| if args: | |
| return '%s %s' % (mnemonic, ', '.join(args)) | |
| return mnemonic | |
| def _instruction_length(instruction, architecture_hint=4): | |
| try: | |
| length = instruction.getInstructionLength() | |
| if length and length > 0: | |
| return length | |
| except Exception: | |
| pass | |
| return architecture_hint | |
| def get_procedure_assembly(procedure): | |
| """Return the procedure's disassembly as a list of '//'-prefixed lines. | |
| Uses '//' rather than a /* */ block so that any '*/' appearing inside a | |
| string operand can't terminate the comment early. | |
| """ | |
| lines = [] | |
| emitted = 0 | |
| truncated = False | |
| try: | |
| entry = procedure.getEntryPoint() | |
| proc_segment = document.getSegmentAtAddress(entry) or segment | |
| except Exception: | |
| proc_segment = segment | |
| try: | |
| block_count = procedure.getBasicBlockCount() | |
| except Exception: | |
| return ['// [disassembly unavailable]'] | |
| for b in range(block_count): | |
| if truncated: | |
| break | |
| try: | |
| block = procedure.getBasicBlock(b) | |
| addr = block.getStartingAddress() | |
| end = block.getEndingAddress() | |
| except Exception: | |
| continue | |
| if ASM_SHOW_BLOCK_HEADERS: | |
| lines.append('// --- block %d @ 0x%x ---' % (b, addr)) | |
| # NOTE: Hopper treats a basic block's ending address as exclusive. | |
| # If you find the final instruction of each block is missing in your | |
| # output, change this to 'while addr <= end:'. | |
| while addr < end: | |
| if emitted >= ASM_MAX_INSTRUCTIONS: | |
| truncated = True | |
| break | |
| try: | |
| instruction = proc_segment.getInstructionAtAddress(addr) | |
| except Exception: | |
| instruction = None | |
| if instruction is None: | |
| break | |
| lines.append('// 0x%012x %s' % (addr, _instruction_text(instruction))) | |
| emitted += 1 | |
| addr += _instruction_length(instruction) | |
| if truncated: | |
| lines.append('// ... truncated at %d instructions ...' % ASM_MAX_INSTRUCTIONS) | |
| if not lines: | |
| return ['// [disassembly unavailable]'] | |
| return lines | |
| def format_assembly_block(label_name, procedure): | |
| body = get_procedure_assembly(procedure) | |
| header = '// ===== disassembly: %s =====' % label_name | |
| footer = '// ===== end disassembly =====' | |
| return '%s\n%s\n%s\n' % (header, '\n'.join(body), footer) | |
| # ----------------------------------------------------------------------------- | |
| def start_decompile(): | |
| classes = {} | |
| total_count = 0 | |
| skipped_by_size = 0 | |
| for i in range(segment.getProcedureCount()): | |
| procedure = segment.getProcedureAtIndex(i) | |
| address = procedure.getEntryPoint() | |
| label_name = segment.getNameAtAddress(address) | |
| if not label_name or is_ignored_method(label_name): | |
| continue | |
| class_name, method_impl_signature, method_decl_signature = parse_label_name(label_name) | |
| if not class_name or is_ignored_class(class_name): | |
| continue | |
| # Skip extremely large procedures that tend to stall the decompiler | |
| try: | |
| if procedure.getBasicBlockCount() > MAX_BASIC_BLOCKS: | |
| skipped_by_size += 1 | |
| continue | |
| if procedure.getHeapSize() > MAX_HEAP_SIZE_BYTES: | |
| skipped_by_size += 1 | |
| continue | |
| except Exception: | |
| pass | |
| procedure.label_name = label_name | |
| procedure.method_impl_signature = method_impl_signature | |
| procedure.method_decl_signature = method_decl_signature | |
| classes.setdefault(class_name, []).append(procedure) | |
| total_count += 1 | |
| print('Total count:', total_count) | |
| if skipped_by_size: | |
| print('Skipped (too large):', skipped_by_size) | |
| current_count = 0 | |
| last_reported_percent = -1 | |
| for class_name in sorted(classes.keys(), key=lambda name: (0 if name.startswith("TH") else 1, name)): | |
| print('\n***** %s *****' % class_name) | |
| codes_lines = [get_file_header(class_name)] | |
| header_lines = [get_header_file_header(class_name)] | |
| procedures = classes[class_name] | |
| for procedure in procedures: | |
| current_count += 1 | |
| percent = (current_count / total_count) * 100 if total_count else 100.0 | |
| int_percent = int(percent) | |
| if (current_count % PRINT_EVERY == 0) or (int_percent != last_reported_percent): | |
| print('%05.2f%% | %s' % (percent, procedure.label_name)) | |
| last_reported_percent = int_percent | |
| try: | |
| pseudo_code = procedure.decompile() | |
| except Exception as e: | |
| print("Decompilation failed for %s: %s" % (procedure.label_name, e)) | |
| continue | |
| if not pseudo_code: | |
| continue | |
| method_type = 'id' if return_re.search(pseudo_code) else 'void' | |
| try: | |
| method_impl = procedure.method_impl_signature % method_type | |
| method_decl = procedure.method_decl_signature % method_type | |
| except Exception as e: | |
| print("Formatting signature failed for %s: %s" % (procedure.label_name, e)) | |
| continue | |
| asm_block = '' | |
| if INCLUDE_ASSEMBLY: | |
| try: | |
| asm_block = format_assembly_block(procedure.label_name, procedure) | |
| except Exception as e: | |
| print("Assembly dump failed for %s: %s" % (procedure.label_name, e)) | |
| asm_block = '// [disassembly failed: %s]\n' % e | |
| if INCLUDE_ASSEMBLY and ASM_POSITION == 'before': | |
| codes_lines.append('%s%s\n{\n%s}\n\n' % (asm_block, method_impl, pseudo_code)) | |
| elif INCLUDE_ASSEMBLY: | |
| codes_lines.append('%s\n{\n%s}\n\n%s\n' % (method_impl, pseudo_code, asm_block)) | |
| else: | |
| codes_lines.append('%s\n{\n%s}\n\n' % (method_impl, pseudo_code)) | |
| header_lines.append('%s\n' % method_decl) | |
| if (current_count % PRINT_EVERY) == 0: | |
| gc.collect() | |
| codes_lines.append(get_file_footer()) | |
| header_lines.append(get_header_file_footer()) | |
| codes = "".join(codes_lines) | |
| header_codes = "".join(header_lines) | |
| file_path = os.path.join(path, '%s.m' % class_name) | |
| header_file_path = os.path.join(path, '%s.h' % class_name) | |
| try: | |
| with open(file_path, 'w', encoding='utf-8') as file: | |
| file.write(codes) | |
| with open(header_file_path, 'w', encoding='utf-8') as header_file: | |
| header_file.write(header_codes) | |
| except Exception as e: | |
| print("Failed to write files for %s: %s" % (class_name, e)) | |
| continue | |
| gc.collect() | |
| print('Done!') | |
| document = Document.getCurrentDocument() | |
| # Stop background analysis to avoid contention with the decompiler on huge inputs | |
| try: | |
| if document.backgroundProcessActive(): | |
| document.requestBackgroundProcessStop() | |
| document.waitForBackgroundProcessToEnd() | |
| except Exception: | |
| pass | |
| segment = document.getSegmentByName('__TEXT') | |
| if not segment: | |
| segment = document.getSegmentsList()[0] | |
| # Determine output directory - prefer binary's directory, fallback to ~/ClassDecompiles/ | |
| try: | |
| exe_path = document.getExecutableFilePath() | |
| if exe_path: | |
| app_name = os.path.basename(exe_path) | |
| # Try to use the binary's directory first | |
| binary_dir = os.path.dirname(exe_path) | |
| if os.path.exists(binary_dir) and os.access(binary_dir, os.W_OK): | |
| path = binary_dir | |
| else: | |
| # Fallback to home directory if binary directory is not writable | |
| path = os.path.expanduser('~/ClassDecompiles/' + app_name) | |
| else: | |
| # Fallback for dyld cache extractions | |
| app_name = "dyld_cache_extraction" | |
| path = os.path.expanduser('~/ClassDecompiles/' + app_name) | |
| except Exception: | |
| app_name = "unknown_binary" | |
| path = os.path.expanduser('~/ClassDecompiles/' + app_name) | |
| if not os.path.exists(path): | |
| os.makedirs(path) | |
| start_decompile() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment