Skip to content

Instantly share code, notes, and snippets.

@samhenrigold
Created September 7, 2026 19:11
Show Gist options
  • Select an option

  • Save samhenrigold/3beed2b564774c7293c912adefb02000 to your computer and use it in GitHub Desktop.

Select an option

Save samhenrigold/3beed2b564774c7293c912adefb02000 to your computer and use it in GitHub Desktop.
Hopper class decompilation script
# -*- 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