diff --git a/README.md b/README.md index 5799730..2742940 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # Droid ASC: R8 Compiler Optimization as a DeCompiler Primitive -> **Rust version:** This project has been rewritten 1:1 in Rust as [rust_ASC](https://github.com/MG1937/rust_ASC). The Rust version is 10× faster and supports full APK decompilation: with 16 threads, it decompiles a 383 MB APK in just 4 seconds. ASC and rust_ASC will be updated in sync going forward. +> **Rust version:** This project has been rewritten 1:1 in Rust as [rust_ASC](https://github.com/MG1937/rust_ASC). The Rust version is 10× faster and supports full APK decompilation: with 16 threads, it decompiles a 383 MB APK in just 4 seconds. > > If your workflow is written in Python, you can continue integrating with this project; it remains the fastest Python Android decompiler available. If you only need a binary, please use [rust_ASC](https://github.com/MG1937/rust_ASC). +> +> ASC and rust_ASC will be maintained in parallel, so users can choose the integration style that best fits their workflow. https://blackhat.com/europe/arsenal/schedule/index.html#droid-asc-r8-compiler-optimization-as-a-decompiler-primitive-54834 @@ -77,14 +79,16 @@ pip install . After installation, the `droidasc` CLI command is available globally: ``` -usage: droidasc [-h] {getclass,listclass,getmanifest,findrefs} ... +usage: droidasc [-h] {getclass,disassemble,listclass,getmanifest,findrefs} ... ASC tooling entry (v0.1.2.post1). positional arguments: - {getclass,listclass,getmanifest,findrefs} + {getclass,disassemble,listclass,getmanifest,findrefs} getclass Locate the target class in APK, extract one DEX in memory, then decompile. + disassemble Extract a class, rebuild a small DEX, then disassemble + it with dadguard. listclass List classes across all DEX entries; use --prefix to filter by package/class prefix. getmanifest Decode AndroidManifest.xml from APK and print it as @@ -99,8 +103,14 @@ examples: droidasc app.apk --gui droidasc getclass app.apk Lcom/poc/Main; -o Main.java droidasc getclass app.apk com.poc.Main --threads 16 + droidasc disassemble app.apk Lcom/poc/Main; -o Main.smali + droidasc disassemble app.apk com.poc.Main --debug + droidasc disassemble app.apk com.poc.Main --debuginfo + droidasc disassemble app.apk com.poc.Main --method 'run(I)V' + droidasc listclass app.apk droidasc listclass app.apk -o classes.txt droidasc listclass app.apk --prefix com.poc + droidasc getmanifest app.apk droidasc getmanifest app.apk -o AndroidManifest.xml droidasc findrefs app.apk string token -o string_refs.txt droidasc findrefs app.apk type com.poc.Main @@ -124,6 +134,14 @@ line. With `-o`, output is written to the selected file instead of stdout. `--prefix com.poc` filters by `Lcom/poc`; an already normalized prefix such as `Lcom/poc` is kept unchanged. +`disassemble` accepts the same dotted or Dalvik class formats as `getclass`. +It extracts the target class from the APK, rebuilds a small DEX in memory, and +disassembles the rebuilt DEX with `dadguard`. `--debug` enables timing output +and instruction offset comments; `--debuginfo` enables DEX `.line` and local +variable directives. `--method 'run(I)V'` restricts output to one exact method +signature; a bare method name is rejected. All commands that support `-o` write +to stdout when `-o` is omitted. + You can also use `python main.py` as before — it delegates to the same entry point. # Sponsors @@ -143,4 +161,4 @@ ASC is built with the hope that it can genuinely help everyone improve their rev Thanks to the [Androguard](https://github.com/androguard/androguard) project for providing the Android reverse-engineering and DEX analysis foundation used -by Droid ASC. \ No newline at end of file +by Droid ASC. diff --git a/dadguard/core/dex/__init__.py b/dadguard/core/dex/__init__.py index 64eadaf..ead8584 100644 --- a/dadguard/core/dex/__init__.py +++ b/dadguard/core/dex/__init__.py @@ -568,7 +568,9 @@ def __init__(self, size, buff: BinaryIO, cm: ClassManager) -> None: except (UnicodeDecodeError, ValueError): self.dex_version = 35 # assume a common version... - if zlib.adler32(read_at(buff, self.offset + 12)) != self.checksum: + if self.CM.vm.verify_checksum and zlib.adler32( + read_at(buff, self.offset + 12) + ) != self.checksum: raise ValueError("Wrong Adler32 checksum for DEX file!") if self.file_size != buff.raw.getbuffer().nbytes: @@ -4473,7 +4475,7 @@ def get_kind(cm: ClassManager, kind: int, value: int) -> str: method = cm.get_method_ref(value) class_name = method.get_class_name() name = method.get_name() - descriptor = method.get_descriptor() + descriptor = method.get_descriptor() return "{}->{}{}".format(class_name, name, descriptor) @@ -4486,7 +4488,7 @@ def get_kind(cm: ClassManager, kind: int, value: int) -> str: elif kind == Kind.FIELD: class_name, proto, field_name = cm.get_field(value) - return "{}->{} {}".format(class_name, field_name, proto) + return "{}->{}:{}".format(class_name, field_name, proto) elif kind == Kind.TYPE: return cm.get_type(value) @@ -4510,6 +4512,25 @@ def get_kind(cm: ClassManager, kind: int, value: int) -> str: return None +def _format_method_reference(cm: ClassManager, value: int) -> str: + method = cm.get_method_ref(value) + descriptor = ( + method.get_real_descriptor() + if hasattr(method, "get_real_descriptor") + else "".join(method.get_descriptor().split()) + ) + return "{}->{}{}".format( + method.get_class_name(), + method.get_name(), + descriptor, + ) + + +def _format_proto_descriptor(cm: ClassManager, value: int) -> str: + proto = cm.get_proto(value) + return "{}{}".format("".join(proto[0].split()), proto[1]) + + class Instruction: """ This class represents a Dalvik instruction @@ -5131,34 +5152,11 @@ def __init__(self, cm: ClassManager, buff: bytes) -> None: def get_output(self, idx: int = -1) -> str: kind = get_kind(self.cm, self.get_kind(), self.BBBB) - - if self.A == 0: - return "%s" % kind - elif self.A == 1: - return "v%d, %s" % (self.C, kind) - elif self.A == 2: - return "v%d, v%d, %s" % (self.C, self.D, kind) - elif self.A == 3: - return "v%d, v%d, v%d, %s" % (self.C, self.D, self.E, kind) - elif self.A == 4: - return "v%d, v%d, v%d, v%d, %s" % ( - self.C, - self.D, - self.E, - self.F, - kind, - ) - elif self.A == 5: - return "v%d, v%d, v%d, v%d, v%d, %s" % ( - self.C, - self.D, - self.E, - self.F, - self.G, - kind, - ) - - return '' + registers = [self.C, self.D, self.E, self.F, self.G][: self.A] + register_text = "{{{}}}".format( + ", ".join("v{}".format(register) for register in registers) + ) + return "{}, {}".format(register_text, kind) def get_operands(self, idx: int = -1) -> list[tuple]: l = [] @@ -5951,9 +5949,10 @@ def get_output(self, idx: int = -1) -> str: kind = get_kind(self.cm, self.get_kind(), self.BBBB) if self.CCCC == self.NNNN: - return "v{}, {}".format(self.CCCC, kind) - else: - return "v{} ... v{}, {}".format(self.CCCC, self.NNNN, kind) + register_text = "{{v{}}}".format(self.CCCC) + else: + register_text = "{{v{} .. v{}}}".format(self.CCCC, self.NNNN) + return "{}, {}".format(register_text, kind) def get_operands(self, idx: int = -1) -> list[tuple[Operand, int]]: kind = get_kind(self.cm, self.get_kind(), self.BBBB) @@ -6549,25 +6548,15 @@ def get_raw(self) -> bytes: ) def get_output(self, idx: int = -1) -> str: - # FIXME get_kind of BBBB (method) and HHHH (proto) - if self.A == 1: - return 'v{}, {}, {}'.format(self.C, self.BBBB, self.HHHH) - if self.A == 2: - return 'v{}, v{}, {}, {}'.format( - self.C, self.D, self.BBBB, self.HHHH - ) - if self.A == 3: - return 'v{}, v{}, v{}, {}, {}'.format( - self.C, self.D, self.E, self.BBBB, self.HHHH - ) - if self.A == 4: - return 'v{}, v{}, v{}, v{}, {}, {}'.format( - self.C, self.D, self.E, self.F, self.BBBB, self.HHHH - ) - if self.A == 5: - return 'v{}, v{}, v{}, v{}, v{}, {}, {}'.format( - self.C, self.D, self.E, self.F, self.G, self.BBBB, self.HHHH - ) + registers = [self.C, self.D, self.E, self.F, self.G][: self.A] + register_text = "{{{}}}".format( + ", ".join("v{}".format(register) for register in registers) + ) + return "{}, {}, {}".format( + register_text, + _format_method_reference(self.cm, self.BBBB), + _format_proto_descriptor(self.cm, self.HHHH), + ) def get_operands(self): # FIXME @@ -6595,9 +6584,10 @@ def get_raw(self) -> bytes: ) def get_output(self, idx: int = -1) -> str: - # FIXME get_kind of BBBB (meth) and HHHH (proto) - return 'v{} .. v{} {} {}'.format( - self.CCCC, self.NNNN, self.BBBB, self.HHHH + return "{}, {}, {}".format( + "{{v{} .. v{}}}".format(self.CCCC, self.NNNN), + _format_method_reference(self.cm, self.BBBB), + _format_proto_descriptor(self.cm, self.HHHH), ) def get_operands(self): @@ -8293,6 +8283,7 @@ def __init__( decompiler: Union[DecompilerDAD, None] = None, config=None, using_api: Union[int, None] = None, + verify_checksum: bool = True, ) -> None: # to allow to pass apk object ==> we do not need to pass additionally target version @@ -8305,6 +8296,7 @@ def __init__( else: self.api_version = CONF["DEFAULT_API"] + self.verify_checksum = verify_checksum self.raw = io.BufferedReader(io.BytesIO(buff)) self._flush() diff --git a/dadguard/decompiler/writer.py b/dadguard/decompiler/writer.py index f05af9b..5d578fe 100644 --- a/dadguard/decompiler/writer.py +++ b/dadguard/decompiler/writer.py @@ -181,6 +181,8 @@ def write_method(self): params = self.method.lparams if 'static' not in access: params = params[1:] + if len(params) != len(self.method.params_type): + params = list(range(len(self.method.params_type))) proto = '' self.write_ext(('PARENTHESIS_START', '(')) if self.method.params_type: diff --git a/dadguard/disassembler.py b/dadguard/disassembler.py new file mode 100644 index 0000000..8761e96 --- /dev/null +++ b/dadguard/disassembler.py @@ -0,0 +1,798 @@ +"""Lightweight DEX disassembly and smali rendering. + +The renderer deliberately depends on dadguard's DEX parser only. It does not +load the Java decompiler or the analysis graph, which keeps the APK extraction +path small and usable in installations without optional UI dependencies. +""" + +from __future__ import annotations + +import json +import re +from collections import defaultdict + +from dadguard.core.dex import ( + DBG_ADVANCE_LINE, + DBG_ADVANCE_PC, + DBG_END_LOCAL, + DBG_LINE_BASE, + DBG_LINE_RANGE, + DBG_RESTART_LOCAL, + DBG_SET_EPILOGUE_BEGIN, + DBG_SET_FILE, + DBG_SET_PROLOGUE_END, + DBG_START_LOCAL, + DBG_START_LOCAL_EXTENDED, + DEX, + DebugInfoItem, + VALUE_ANNOTATION, + VALUE_ARRAY, + VALUE_BOOLEAN, + VALUE_BYTE, + VALUE_CHAR, + VALUE_DOUBLE, + VALUE_ENUM, + VALUE_FIELD, + VALUE_FLOAT, + VALUE_INT, + VALUE_LONG, + VALUE_METHOD, + VALUE_NULL, + VALUE_SHORT, + VALUE_STRING, + VALUE_TYPE, +) + + +_NO_INDEX = 0xFFFFFFFF +_ANNOTATION_VISIBILITY = { + 0: "build", + 1: "runtime", + 2: "system", +} +_BRANCH_OFFSET_RE = re.compile(r"([+-]?[0-9a-fA-F]+)h$") +_METHOD_DESCRIPTOR_RE = re.compile(r"(->[^,\s]+)\(([^)]*)\)([^\s,]+)") + + +def _format_instruction(index: int, offset: int, instruction) -> str: + """Format one instruction like Androguard's offset disassembly command.""" + return "{:<8d}({:08x}) {} {}".format( + index, + offset, + instruction.get_name(), + instruction.get_output(offset), + ).rstrip() + + +def _safe_string(cm, index: int): + if index is None or index < 0 or index == _NO_INDEX: + return None + try: + return cm.get_string(index) + except (IndexError, KeyError, TypeError): + return None + + +def _safe_type(cm, index: int): + if index is None or index < 0 or index == _NO_INDEX: + return None + try: + return cm.get_type(index) + except (IndexError, KeyError, TypeError): + return None + + +def _quote(value) -> str: + return json.dumps(str(value), ensure_ascii=True) + + +def _compact_descriptor(descriptor) -> str: + return "".join(str(descriptor).split()) + + +def _compact_method_descriptors(text: str) -> str: + return _METHOD_DESCRIPTOR_RE.sub( + lambda match: "{}({}){}".format( + match.group(1), + _compact_descriptor(match.group(2)), + match.group(3), + ), + text, + ) + + +def _normalize_method_signature(signature: str) -> str: + if not signature or "(" not in signature or ")" not in signature: + raise ValueError( + "method signature must include the name, parameter descriptor and return type" + ) + name, rest = signature.split("(", 1) + params, return_type = rest.split(")", 1) + if not name or not return_type: + raise ValueError( + "method signature must include the name, parameter descriptor and return type" + ) + return "{}({}){}".format( + name, + _compact_descriptor(params), + _compact_descriptor(return_type), + ) + + +def _method_matches(method, signature: str) -> bool: + return "{}{}".format( + method.get_name(), + _compact_descriptor(method.get_descriptor()), + ) == signature + + +def _format_reference(value) -> str: + """Render a field/method reference stored inside an encoded value.""" + try: + owner = value.get_class_name() + name = value.get_name() + descriptor = _compact_descriptor(value.get_descriptor()) + if descriptor.startswith("("): + return "{}->{}{}".format(owner, name, descriptor) + return "{}->{}:{}".format(owner, name, descriptor) + except (AttributeError, IndexError, KeyError, TypeError): + return str(value) + + +def _format_encoded_value(encoded_value, cm) -> str: + value_type = encoded_value.get_value_type() + value = encoded_value.get_value() + + if value_type == VALUE_STRING: + return _quote(value) + if value_type == VALUE_TYPE: + return str(value) + if value_type in (VALUE_FIELD, VALUE_METHOD, VALUE_ENUM): + return _format_reference(value) + if value_type == VALUE_BOOLEAN: + return "true" if value else "false" + if value_type == VALUE_NULL: + return "null" + if value_type == VALUE_ARRAY: + values = getattr(value, "get_values", lambda: [])() + return "{{{}}}".format( + ", ".join(_format_encoded_value(item, cm) for item in values) + ) + if value_type == VALUE_ANNOTATION: + annotation_type = _safe_type(cm, value.get_type_idx()) + elements = [] + for element in value.get_elements(): + name = _safe_string(cm, element.get_name_idx()) + if name is None: + name = "unknown" + elements.append( + "{} = {}".format( + name, + _format_encoded_value(element.get_value(), cm), + ) + ) + return "{} {{{}}}".format( + annotation_type or "Lunknown/Annotation;", + ", ".join(elements), + ) + if value_type in ( + VALUE_BYTE, + VALUE_SHORT, + VALUE_CHAR, + VALUE_INT, + VALUE_LONG, + VALUE_FLOAT, + VALUE_DOUBLE, + ): + if isinstance(value, int) and value >= 0: + return "0x{:x}".format(value) + return str(value) + return str(value) + + +def _annotation_items(cm, annotations_off: int): + if not annotations_off: + return [] + try: + annotation_set = cm.get_annotation_set_item(annotations_off) + if annotation_set is None: + return [] + result = [] + for annotation_off in annotation_set.get_annotation_off_item(): + item = annotation_off.get_annotation_item() + if item is not None: + result.append(item) + return result + except (AttributeError, IndexError, KeyError, TypeError): + return [] + + +def _annotation_lines(cm, annotation_items, indent: str = " ") -> list[str]: + lines = [] + for item in annotation_items: + annotation = item.get_annotation() + annotation_type = _safe_type(cm, annotation.get_type_idx()) + if annotation_type is None: + annotation_type = "Lunknown/Annotation;" + visibility = _ANNOTATION_VISIBILITY.get(item.get_visibility(), "runtime") + lines.append("{}.annotation {} {}".format(indent, visibility, annotation_type)) + for element in annotation.get_elements(): + name = _safe_string(cm, element.get_name_idx()) or "unknown" + lines.append( + "{} {} = {}".format( + indent, + name, + _format_encoded_value(element.get_value(), cm), + ) + ) + lines.append("{}.end annotation".format(indent)) + return lines + + +def _annotation_data(cls): + """Load annotation directory data only for the class being rendered.""" + directory = getattr(cls, "annotations_directory_item", None) + if directory is None and cls.get_annotations_off(): + try: + directory = cls.CM.get_annotations_directory_item( + cls.get_annotations_off() + ) + cls.annotations_directory_item = directory + except (AttributeError, IndexError, KeyError, TypeError): + directory = None + + data = { + "class": [], + "fields": defaultdict(list), + "methods": defaultdict(list), + "parameters": {}, + } + if directory is None: + return data + + data["class"] = _annotation_items( + cls.CM, directory.get_class_annotations_off() + ) + for item in directory.get_field_annotations(): + data["fields"][item.get_field_idx()] = _annotation_items( + cls.CM, item.get_annotations_off() + ) + for item in directory.get_method_annotations(): + data["methods"][item.get_method_idx()] = _annotation_items( + cls.CM, item.get_annotations_off() + ) + for item in directory.get_parameter_annotations(): + data["parameters"][item.get_method_idx()] = item.get_annotations_off() + return data + + +def _parameter_annotation_lines(cm, annotations_off: int) -> list[str]: + if not annotations_off: + return [] + try: + ref_list = cm.get_obj_by_offset(annotations_off) + refs = ref_list.get_list() + except (AttributeError, IndexError, KeyError, TypeError): + return [] + + lines = [] + for parameter, ref in enumerate(refs): + items = _annotation_items(cm, ref.get_annotations_off()) + if not items: + continue + lines.append(" .param p{}".format(parameter)) + lines.extend(_annotation_lines(cm, items, " ")) + lines.append(" .end param") + return lines + + +def _debug_lines(method) -> dict[int, list[str]]: + """Decode DEX debug opcodes into smali directives by code-unit address.""" + code = method.get_code() + if code is None or not code.get_debug_info_off(): + return {} + try: + cm = method.CM + try: + debug = code.get_debug() + except AttributeError: + # dadguard's ClassManager keeps the DEX object in ``buff`` while + # the actual seekable stream is owned by DEX.raw. + raw = getattr(getattr(cm, "vm", None), "raw", None) + if raw is None: + return {} + current = raw.tell() + try: + raw.seek(code.get_debug_info_off()) + debug = DebugInfoItem(raw, cm) + finally: + raw.seek(current) + address = 0 + line = debug.get_line_start() + events = defaultdict(list) + for bytecode in debug.get_bytecodes(): + op = bytecode.get_op_value() + values = [value for value, _ in bytecode.format] + if op == DBG_ADVANCE_PC: + address += values[0] + elif op == DBG_ADVANCE_LINE: + line += values[0] + elif op == DBG_START_LOCAL: + register = values[0] + name = _safe_string(cm, values[1]) + value_type = _safe_type(cm, values[2]) + directive = " .local v{}".format(register) + if name is not None: + directive += ", {}".format(_quote(name)) + if value_type is not None: + directive += ":{}".format(value_type) + events[address * 2].append(directive) + elif op == DBG_START_LOCAL_EXTENDED: + register = values[0] + name = _safe_string(cm, values[1]) + value_type = _safe_type(cm, values[2]) + signature = _safe_string(cm, values[3]) + directive = " .local v{}".format(register) + if name is not None: + directive += ", {}".format(_quote(name)) + if value_type is not None: + directive += ":{}".format(value_type) + if signature is not None: + directive += " # {}".format(signature) + events[address * 2].append(directive) + elif op == DBG_END_LOCAL: + events[address * 2].append( + " .end local v{}".format(values[0]) + ) + elif op == DBG_RESTART_LOCAL: + events[address * 2].append( + " .restart local v{}".format(values[0]) + ) + elif op in ( + DBG_SET_PROLOGUE_END, + DBG_SET_EPILOGUE_BEGIN, + DBG_SET_FILE, + ): + continue + elif op >= 0x0A: + adjusted = op - 0x0A + address += adjusted // DBG_LINE_RANGE + line += DBG_LINE_BASE + adjusted % DBG_LINE_RANGE + events[address * 2].append(" .line {}".format(line)) + return events + except (AttributeError, IndexError, KeyError, TypeError, ValueError): + # Optional debug metadata must not make otherwise valid bytecode + # impossible to inspect. + return {} + + +def _branch_target(offset: int, instruction): + name = instruction.get_name() + if not ( + name == "goto" + or name.startswith("goto/") + or name.startswith("if-") + or name in ("packed-switch", "sparse-switch", "fill-array-data") + ): + return None + get_ref_off = getattr(instruction, "get_ref_off", None) + if get_ref_off is None: + return None + try: + # Branch references are measured in 16-bit code units, while + # get_instructions_idx() exposes byte offsets. + return ((offset // 2) + get_ref_off()) * 2 + except (AttributeError, TypeError, ValueError): + return None + + +def _label_name_for_branch(instruction, counters) -> str: + name = instruction.get_name() + if name == "goto" or name.startswith("goto/"): + prefix = "goto" + elif name.startswith("if-"): + prefix = "cond" + else: + prefix = "label" + value = counters[prefix] + counters[prefix] += 1 + return "{}_{}".format(prefix, value) + + +def _replace_branch_operand(output: str, label: str) -> str: + match = _BRANCH_OFFSET_RE.search(output) + if match is None: + return output + return "{}:{}".format(output[: match.start()], label) + + +def _exception_data(method, instructions): + code = method.get_code() + if code is None or not code.get_tries_size(): + return {}, {}, {} + + labels = defaultdict(list) + directives = defaultdict(list) + handler_labels = {} + try_items = [] + handlers = code.get_handlers() + handler_by_offset = {} + if handlers is not None: + base = handlers.get_off() + for handler in handlers.get_list(): + handler_by_offset[handler.get_off() - base] = handler + + for index, try_item in enumerate(code.get_tries()): + start = try_item.get_start_addr() * 2 + end = (try_item.get_start_addr() + try_item.get_insn_count()) * 2 + start_label = "try_start_{}".format(index) + end_label = "try_end_{}".format(index) + labels[start].append(start_label) + labels[end].append(end_label) + handler = handler_by_offset.get(try_item.get_handler_off()) + if handler is None: + continue + + catches = [] + for pair in handler.get_handlers(): + address = pair.get_addr() * 2 + if address not in handler_labels: + handler_labels[address] = "catch_{}".format(len(handler_labels)) + labels[address].append(handler_labels[address]) + catches.append( + ( + ".catch", + _safe_type(method.CM, pair.get_type_idx()) + or "Ljava/lang/Throwable;", + handler_labels[address], + ) + ) + if handler.get_size() <= 0: + address = handler.get_catch_all_addr() * 2 + if address not in handler_labels: + handler_labels[address] = "catchall_{}".format( + len(handler_labels) + ) + labels[address].append(handler_labels[address]) + catches.append((".catchall", None, handler_labels[address])) + + for directive, value, catch_label in catches: + if directive == ".catch": + directives[end].append( + " {} {} {{:{} .. :{}}} :{}".format( + directive, + value, + start_label, + end_label, + catch_label, + ) + ) + else: + directives[end].append( + " {} {{:{} .. :{}}} :{}".format( + directive, + start_label, + end_label, + catch_label, + ) + ) + try_items.append((start, end)) + + instruction_offsets = {offset for offset, _ in instructions} + for start, end in try_items: + if start not in instruction_offsets: + labels[start].append("try_start_invalid_{:x}".format(start)) + if end not in instruction_offsets and end != instructions[-1][0] + instructions[-1][1].get_length(): + labels[end].append("try_end_invalid_{:x}".format(end)) + return labels, directives, handler_labels + + +def _smali_instructions( + method, + debug: bool = False, + debuginfo: bool = False, +): + instructions = list(method.get_instructions_idx()) + if not instructions: + return [] + + labels = defaultdict(list) + branch_counters = defaultdict(int) + for offset, instruction in instructions: + target = _branch_target(offset, instruction) + if target is not None and not labels[target]: + labels[target].append( + _label_name_for_branch(instruction, branch_counters) + ) + + exception_labels, exception_directives, _ = _exception_data( + method, instructions + ) + for offset, names in exception_labels.items(): + labels[offset].extend(name for name in names if name not in labels[offset]) + + debug_events = _debug_lines(method) if debuginfo else {} + lines = [] + for offset, instruction in instructions: + regular_labels = [ + label + for label in labels.get(offset, []) + if not label.startswith("catch") + ] + catch_labels = [ + label + for label in labels.get(offset, []) + if label.startswith("catch") + ] + for label in regular_labels: + lines.append(" :{}".format(label)) + lines.extend(exception_directives.get(offset, [])) + for label in catch_labels: + lines.append(" :{}".format(label)) + lines.extend(debug_events.get(offset, [])) + output = _compact_method_descriptors( + instruction.get_output(offset) + ) + target = _branch_target(offset, instruction) + if target is not None and labels.get(target): + output = _replace_branch_operand(output, labels[target][0]) + if debug: + lines.append( + " # {:08x} {}".format(offset, instruction.disasm()) + ) + lines.append(" {} {}".format(instruction.get_name(), output).rstrip()) + + end_offset = instructions[-1][0] + instructions[-1][1].get_length() + regular_labels = [ + label + for label in labels.get(end_offset, []) + if not label.startswith("catch") + ] + catch_labels = [ + label + for label in labels.get(end_offset, []) + if label.startswith("catch") + ] + for label in regular_labels: + lines.append(" :{}".format(label)) + lines.extend(exception_directives.get(end_offset, [])) + for label in catch_labels: + lines.append(" :{}".format(label)) + return lines + + +def _format_field(cm, field, annotations) -> list[str]: + lines = _annotation_lines( + cm, annotations.get(field.get_field_idx(), []), " " + ) + flags = field.get_access_flags_string() + prefix = ".field" + if flags: + prefix += " " + flags + value = "" + init_value = field.get_init_value() + if init_value is not None: + value = " = {}".format(_format_encoded_value(init_value, cm)) + lines.append( + " {} {}:{}{}".format( + prefix, + field.get_name(), + field.get_descriptor(), + value, + ) + ) + return lines + + +def _format_method( + cls, + method, + annotation_data, + debug: bool = False, + debuginfo: bool = False, +) -> list[str]: + descriptor = _compact_descriptor(method.get_descriptor()) + lines = [ + "## METHOD: {} {} {}".format( + method.get_access_flags_string(), + method.get_name(), + descriptor, + ) + ] + flags = method.get_access_flags_string() + if method.get_name() in ("", "") and "constructor" not in flags: + flags = "{} constructor".format(flags).strip() + declaration = ".method" + if flags: + declaration += " " + flags + declaration += " {}{}".format(method.get_name(), descriptor) + lines.append(declaration) + + code = method.get_code() + if code is not None: + lines.append(" .locals {}".format( + max(0, code.get_registers_size() - code.get_ins_size()) + )) + lines.extend( + _annotation_lines( + cls.CM, + annotation_data["methods"].get(method.get_method_idx(), []), + " ", + ) + ) + lines.extend( + _parameter_annotation_lines( + cls.CM, + annotation_data["parameters"].get(method.get_method_idx(), 0), + ) + ) + lines.extend( + _smali_instructions( + method, + debug=debug, + debuginfo=debuginfo, + ) + ) + else: + lines.extend( + _annotation_lines( + cls.CM, + annotation_data["methods"].get(method.get_method_idx(), []), + " ", + ) + ) + lines.append(".end method") + return lines + + +def _format_class( + cls, + debug: bool = False, + debuginfo: bool = False, + method_signature: str | None = None, +) -> list[str]: + lines = ["# CLASS: {}".format(cls.get_name())] + flags = cls.get_access_flags_string() + declaration = ".class" + if flags: + declaration += " " + flags + lines.append("{} {}".format(declaration, cls.get_name())) + superclass = cls.get_superclassname() + if superclass: + lines.append(".super {}".format(superclass)) + source_idx = cls.get_source_file_idx() + source = _safe_string(cls.CM, source_idx) + if source is not None: + lines.append(".source {}".format(_quote(source))) + if cls.get_interfaces(): + lines.append("") + lines.append("# interfaces") + for interface in cls.get_interfaces(): + lines.append(".implements {}".format(interface)) + + annotation_data = _annotation_data(cls) + lines.extend(_annotation_lines(cls.CM, annotation_data["class"], "")) + + fields = cls.get_fields() if method_signature is None else [] + if fields: + lines.append("") + lines.append("# fields") + for field in fields: + lines.extend(_format_field(cls.CM, field, annotation_data["fields"])) + + methods = cls.get_methods() + if method_signature is not None: + methods = [ + method for method in methods + if _method_matches(method, method_signature) + ] + if methods: + lines.append("") + lines.append("# direct/virtual methods") + for method in methods: + lines.extend( + _format_method( + cls, + method, + annotation_data, + debug=debug, + debuginfo=debuginfo, + ) + ) + lines.append("") + return lines + + +def format_dex_disassembly( + dex: DEX, + offset: int = 0, + size: int = 0, + debug: bool = False, + debuginfo: bool = False, + method_signature: str | None = None, +) -> str: + """Return smali-like output for a DEX, or offset disassembly for a range.""" + if offset < 0 or size < 0: + raise ValueError("offset and size must be non-negative") + + if size == 0 and offset == 0: + if method_signature is not None: + method_signature = _normalize_method_signature(method_signature) + if not any( + _method_matches(method, method_signature) + for cls in dex.get_classes() + for method in cls.get_methods() + ): + raise ValueError( + "method not found or signature is not defined in the target DEX: " + + method_signature + ) + lines = [] + for cls in dex.get_classes(): + lines.extend( + _format_class( + cls, + debug=debug, + debuginfo=debuginfo, + method_signature=method_signature, + ) + ) + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + if size == 0: + current = dex.raw.tell() + try: + dex.raw.seek(0, 2) + size = dex.raw.tell() - offset + finally: + dex.raw.seek(current) + + lines = [] + instruction_offset = offset + for index, instruction in enumerate(dex.disassemble(offset, size)): + lines.append(_format_instruction(index, instruction_offset, instruction)) + instruction_offset += instruction.get_length() + return "\n".join(lines) + + +def disassemble_dex_bytes( + dex_bytes: bytes | bytearray | memoryview, + offset: int = 0, + size: int = 0, + verify_checksum: bool = True, + debug: bool = False, + debuginfo: bool = False, + method_signature: str | None = None, +) -> str: + """Parse DEX bytes and return their textual disassembly.""" + return format_dex_disassembly( + DEX(bytes(dex_bytes), verify_checksum=verify_checksum), + offset, + size, + debug=debug, + debuginfo=debuginfo, + method_signature=method_signature, + ) + + +def disassemble_dex_file( + dex_path: str, + offset: int = 0, + size: int = 0, + verify_checksum: bool = True, + debug: bool = False, + debuginfo: bool = False, + method_signature: str | None = None, +) -> str: + """Read a DEX file and return its textual disassembly.""" + with open(dex_path, "rb") as fp: + return disassemble_dex_bytes( + fp.read(), + offset, + size, + verify_checksum=verify_checksum, + debug=debug, + debuginfo=debuginfo, + method_signature=method_signature, + ) diff --git a/droidasc/asc_client/apk/dex_reader.py b/droidasc/asc_client/apk/dex_reader.py index 9bb5377..e736f72 100644 --- a/droidasc/asc_client/apk/dex_reader.py +++ b/droidasc/asc_client/apk/dex_reader.py @@ -2,6 +2,7 @@ _U32_FROM = struct.Struct(" int: @@ -60,6 +61,96 @@ def _find_type_idx(buf, target_bytes : bytes) -> int: return -1 +def _read_string_data_prefix(buf, str_off: int): + """Return (descriptor bytes, end offset), or None when more bytes are needed.""" + ptr = str_off + for _ in range(5): + if ptr >= len(buf): + return None + byte = buf[ptr] + ptr += 1 + if byte < 0x80: + break + else: + raise ValueError("invalid string_data length") + + end = buf.find(b"\x00", ptr) + if end < 0: + return None + return buf[ptr:end], end + 1 + + +def class_defines_prefix_len( + buf, + target_bytes: bytes, + full_size: int, +) -> int: + """Return the uncompressed prefix needed to answer one class query. + + ``full_size`` is returned when the prefix is unsafe to interpret. The + caller must then use the existing full-inflate path. + """ + if full_size <= 0: + full_size = len(buf) + if len(buf) < 0x70: + return min(full_size, 0x70) + if buf[:3] != b"dex" or buf[:8] == _DEX041_MAGIC: + return full_size + + try: + string_ids_size = _U32_FROM(buf, 0x38)[0] + string_ids_off = _U32_FROM(buf, 0x3C)[0] + type_ids_size = _U32_FROM(buf, 0x40)[0] + type_ids_off = _U32_FROM(buf, 0x44)[0] + class_defs_size = _U32_FROM(buf, 0x60)[0] + class_defs_off = _U32_FROM(buf, 0x64)[0] + tables_end = max( + string_ids_off + string_ids_size * 4, + type_ids_off + type_ids_size * 4, + class_defs_off + class_defs_size * 32, + ) + except (IndexError, struct.error, ValueError): + return full_size + + if tables_end > len(buf): + return min(full_size, tables_end) + if string_ids_size == 0 or type_ids_size == 0: + return tables_end + + def need_more(offset: int) -> int: + if offset >= full_size: + return full_size + return min(full_size, max(len(buf) * 2, offset + 4096)) + + left = 0 + right = type_ids_size - 1 + while left <= right: + middle = (left + right) >> 1 + try: + string_idx = _U32_FROM( + buf, type_ids_off + (middle << 2) + )[0] + string_off = _U32_FROM( + buf, string_ids_off + (string_idx << 2) + )[0] + except (IndexError, struct.error): + return full_size + if string_idx >= string_ids_size: + return full_size + + string_data = _read_string_data_prefix(buf, string_off) + if string_data is None: + return need_more(string_off) + descriptor, end = string_data + if descriptor == target_bytes: + return max(tables_end, end) + if descriptor < target_bytes: + left = middle + 1 + else: + right = middle - 1 + return tables_end + + def _class_defs_contains_type_idx(buf, type_idx : int) -> bool: class_defs_size = _U32_FROM(buf, 0x60)[0] class_defs_off = _U32_FROM(buf, 0x64)[0] diff --git a/droidasc/asc_client/apk/loader.py b/droidasc/asc_client/apk/loader.py index 96ed303..403d960 100644 --- a/droidasc/asc_client/apk/loader.py +++ b/droidasc/asc_client/apk/loader.py @@ -118,6 +118,63 @@ def _inflate_deflate_chunks(comp_view, stop_event): return bytes(out) +def _inflate_dex_prefix(mm : mmap.mmap, entry, prefix_need, stop_event = None): + """Inflate only the prefix required by ``prefix_need``. + + Returns ``(data, complete)``. ``complete`` is true for stored entries; + ``None`` means the caller should fall back to the existing full inflate + path because the prefix is not safe or no longer worth streaming. + """ + _name, uncomp_size, comp_size, local_header_off, comp_method = entry + if mm[local_header_off:local_header_off + 4] != _LH_SIG: + raise ValueError("bad local header signature") + + name_len = _U16_FROM(mm, local_header_off + 26)[0] + extra_len = _U16_FROM(mm, local_header_off + 28)[0] + data_off = local_header_off + 30 + name_len + extra_len + comp_view = memoryview(mm)[data_off:data_off + comp_size] + + if comp_method == 0: + return bytes(comp_view), True + if comp_method != 8: + raise ValueError(f"unsupported compression method: {comp_method}") + + decomp = zlib.decompressobj(-15) + out = bytearray() + compressed_pos = 0 + pending = b"" + while True: + if stop_event is not None and stop_event.is_set(): + return None + try: + target = min(int(prefix_need(out, uncomp_size)), uncomp_size) + except (IndexError, TypeError, ValueError, struct.error): + return None + if target >= uncomp_size: + return None + if len(out) >= target: + return bytes(out), False + + if pending: + chunk = pending + pending = b"" + else: + if compressed_pos >= len(comp_view): + # The stream ended before the requested prefix. Let the + # existing full path perform its normal size/error handling. + return None + end = min(compressed_pos + _DEFLATE_CHUNK, len(comp_view)) + chunk = comp_view[compressed_pos:end] + compressed_pos = end + + max_output = max(1, target - len(out)) + produced = decomp.decompress(chunk, max_output) + out.extend(produced) + pending = decomp.unconsumed_tail + if not produced and not pending and compressed_pos >= len(comp_view): + return None + + def _inflate_dex(mm : mmap.mmap, entry, stop_event = None): _name, uncomp_size, comp_size, local_header_off, comp_method = entry if mm[local_header_off:local_header_off + 4] != _LH_SIG: diff --git a/droidasc/asc_client/apk_handler.py b/droidasc/asc_client/apk_handler.py index 891d655..1dc9502 100644 --- a/droidasc/asc_client/apk_handler.py +++ b/droidasc/asc_client/apk_handler.py @@ -6,10 +6,12 @@ from droidasc.asc_client.apk.dex_reader import ( _dex_defines_class as _defines_class, + class_defines_prefix_len, list_dex_classes as _list_classes, ) from droidasc.asc_client.apk.loader import ( _inflate_dex as _load_dex, + _inflate_dex_prefix, _parse_cd_dex_entries as _parse_entries, ) from droidasc.asc_client.dex_container import iter_logical_dex_buffers @@ -30,9 +32,34 @@ def _inflate_and_hit( return False, None, None t0 = time.perf_counter() - data = _load_dex(mm, entry, stop_event) - if data is None: - return False, None, None + prefix = _inflate_dex_prefix( + mm, + entry, + lambda buf, full_size: class_defines_prefix_len( + buf, + target_bytes, + full_size, + ), + stop_event, + ) + if prefix is None: + data = _load_dex(mm, entry, stop_event) + if data is None: + return False, None, None + else: + prefix_data, complete = prefix + try: + prefix_hit = _defines_class(prefix_data, target_bytes) + except (IndexError, ValueError): + prefix_hit = None + if prefix_hit is False: + return False, None, None + if complete: + data = prefix_data + else: + data = _load_dex(mm, entry, stop_event) + if data is None: + return False, None, None t1 = time.perf_counter() if stop_event is not None and stop_event.is_set(): return False, None, None diff --git a/droidasc/asc_core/core/dex/dex_builder.py b/droidasc/asc_core/core/dex/dex_builder.py index c57e234..bda7a5b 100644 --- a/droidasc/asc_core/core/dex/dex_builder.py +++ b/droidasc/asc_core/core/dex/dex_builder.py @@ -209,21 +209,12 @@ def build(self): new_header = bytearray(16) if len(orig_header) == 16: - # IDK why LLM gen this... I reviewed it and remove - """ - regs = int.from_bytes(orig_header[0:2], 'little') - ins = int.from_bytes(orig_header[2:4], 'little') - outs = int.from_bytes(orig_header[4:6], 'little') - new_header[0:2] = regs.to_bytes(2, 'little') - new_header[2:4] = ins.to_bytes(2, 'little') - new_header[4:6] = outs.to_bytes(2, 'little') - new_header[6:8] = tries_size.to_bytes(2, 'little') + new_header = bytearray(orig_header) new_header[8:12] = debug_info_off.to_bytes(4, 'little') new_header[12:16] = insns_size.to_bytes(4, 'little') - """ _, catch_handler_list = self.hlw.code_item_metadata_hlws.get(method.index, (0, None)) - self.out.extend(orig_header) + self.out.extend(new_header) self.out.extend(bc) if tries_size > 0: diff --git a/droidasc/cli.py b/droidasc/cli.py index a0ab8a5..03b7cef 100644 --- a/droidasc/cli.py +++ b/droidasc/cli.py @@ -95,6 +95,68 @@ def _handle_getclass(args): print(source_code) +def _handle_disassemble(args): + from droidasc.asc_client.apk_handler import ApkHandler + from droidasc.asc_core.core.dex.dex_manager import DexManager + from dadguard.disassembler import disassemble_dex_bytes + + t_start = time.perf_counter() + dalvik_class = _format_class_name(args.dalvik_class) + apk_handler = ApkHandler(args.apk_path, debug=args.debug, max_workers=args.threads) + hit = apk_handler.get_class_dex(dalvik_class) + if hit is None: + raise ValueError(f"Class {dalvik_class} not found in APK.") + t_lookup_end = time.perf_counter() + + _dex_name, dex_buf = hit + rebuilt_dex = DexManager(memoryview(dex_buf), debug=args.debug).extract_and_rebuild( + dalvik_class + ) + t_rebuild_end = time.perf_counter() + disassembly = disassemble_dex_bytes( + rebuilt_dex, + verify_checksum=False, + debug=args.debug, + debuginfo=args.debuginfo, + method_signature=args.method_signature, + ) + t_format_end = time.perf_counter() + if args.debug: + print( + "[DEBUG] Disassemble APK lookup: {:.3f} ms".format( + (t_lookup_end - t_start) * 1000 + ), + file=sys.stderr, + ) + print( + "[DEBUG] Disassemble DEX rebuild: {:.3f} ms".format( + (t_rebuild_end - t_lookup_end) * 1000 + ), + file=sys.stderr, + ) + print( + "[DEBUG] Disassemble smali format: {:.3f} ms".format( + (t_format_end - t_rebuild_end) * 1000 + ), + file=sys.stderr, + ) + print( + "[DEBUG] Disassemble total: {:.3f} ms".format( + (t_format_end - t_start) * 1000 + ), + file=sys.stderr, + ) + if args.output: + with open(args.output, "w", encoding="utf-8", newline="\n") as fp: + fp.write(disassembly) + if disassembly and not disassembly.endswith("\n"): + fp.write("\n") + else: + sys.stdout.write(disassembly) + if disassembly and not disassembly.endswith("\n"): + sys.stdout.write("\n") + + def _handle_listclass(args): from droidasc.asc_client.apk_handler import ApkHandler @@ -167,12 +229,6 @@ def _handle_findrefs(args): def _run_gui(argv): parser = argparse.ArgumentParser( description="ASC GUI entry.", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog="""examples: - droidasc app.apk --gui - droidasc app.apk --gui --debug - droidasc app.apk --gui --threads 16 -""", ) parser.add_argument("apk_path", help="Path to the input APK file.") parser.add_argument("--gui", action="store_true", help="Launch GUI.") @@ -230,6 +286,8 @@ def main(): try: if args.command == "getclass": _handle_getclass(args) + elif args.command == "disassemble": + _handle_disassemble(args) elif args.command == "listclass": _handle_listclass(args) elif args.command == "getmanifest": @@ -253,12 +311,23 @@ def _build_main_parser(show_version=False): else "ASC tooling entry." ), formatter_class=argparse.RawDescriptionHelpFormatter, - epilog="""examples: + epilog="""common: + Class names accept both com.poc.Main and Lcom/poc/Main;. + Commands with -o/--output write to stdout when the option is omitted. + --threads N controls parallel APK scanning; --debug enables timing output. + +examples: droidasc app.apk --gui droidasc getclass app.apk Lcom/poc/Main; -o Main.java droidasc getclass app.apk com.poc.Main --threads 16 + droidasc disassemble app.apk Lcom/poc/Main; -o Main.smali + droidasc disassemble app.apk com.poc.Main --debug + droidasc disassemble app.apk com.poc.Main --debuginfo + droidasc disassemble app.apk com.poc.Main --method 'run(I)V' + droidasc listclass app.apk droidasc listclass app.apk -o classes.txt droidasc listclass app.apk --prefix com.poc + droidasc getmanifest app.apk droidasc getmanifest app.apk -o AndroidManifest.xml droidasc findrefs app.apk string token -o string_refs.txt droidasc findrefs app.apk type com.poc.Main @@ -286,7 +355,45 @@ def _build_main_parser(show_version=False): getclass_parser.add_argument("--threads", "--thread", type=int, default=8, help="Worker thread count.") getclass_parser.add_argument("-o", "--output", help="Also write decompiled output to this file.") getclass_parser.add_argument("apk_path", help="Path to the input APK file.") - getclass_parser.add_argument("dalvik_class", help="The Dalvik format class name to extract (e.g., Lcom/poc/Main;).") + getclass_parser.add_argument( + "dalvik_class", + help="Class name in dotted or Dalvik form, e.g. com.poc.Main or Lcom/poc/Main;.", + ) + + disassemble_parser = subparsers.add_parser( + "disassemble", + help="Extract a class, rebuild a small DEX, then disassemble it with dadguard.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""examples: + droidasc disassemble app.apk Lcom/poc/Main; + droidasc disassemble app.apk com.poc.Main -o Main.smali + droidasc disassemble app.apk com.poc.Main --threads 16 --debug + droidasc disassemble app.apk com.poc.Main --debuginfo + droidasc disassemble app.apk com.poc.Main --method 'run(I)V' +""", + ) + disassemble_parser.add_argument("--debug", action="store_true", help="Enable debug profiling output.") + disassemble_parser.add_argument( + "--debuginfo", + action="store_true", + help="Include DEX line and local-variable debug directives.", + ) + disassemble_parser.add_argument( + "--method", + dest="method_signature", + help="Render one exact method signature, e.g. run(I)V.", + ) + disassemble_parser.add_argument("--threads", "--thread", type=int, default=8, help="Worker count.") + disassemble_parser.add_argument( + "-o", + "--output", + help="Write disassembly to this file; default is stdout.", + ) + disassemble_parser.add_argument("apk_path", help="Path to the input APK file.") + disassemble_parser.add_argument( + "dalvik_class", + help="Class name in dotted or Dalvik form, e.g. com.poc.Main or Lcom/poc/Main;.", + ) listclass_parser = subparsers.add_parser( "listclass", @@ -376,7 +483,10 @@ def _build_main_parser(show_version=False): # parser) is not reset by the child's own default. extends_parser.add_argument("--debug", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS) extends_parser.add_argument("--threads", "--thread", type=int, default=argparse.SUPPRESS, help=argparse.SUPPRESS) - extends_parser.add_argument("value", help="Target class, e.g. android.app.Activity or Lcom/poc/Base;") + extends_parser.add_argument( + "value", + help="Target class in dotted or Dalvik form.", + ) impl_parser = find_subparsers.add_parser( "impl", @@ -392,7 +502,10 @@ def _build_main_parser(show_version=False): # parser) is not reset by the child's own default. impl_parser.add_argument("--debug", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS) impl_parser.add_argument("--threads", "--thread", type=int, default=argparse.SUPPRESS, help=argparse.SUPPRESS) - impl_parser.add_argument("value", help="Target interface, e.g. android.os.IInterface or Lcom/poc/Service;") + impl_parser.add_argument( + "value", + help="Target interface in dotted or Dalvik form.", + ) method_parser = find_subparsers.add_parser( "method", diff --git a/pyproject.toml b/pyproject.toml index c40ecaf..124a6f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "droidasc" -version = "0.1.2-post2" +version = "0.1.2-post3" description = "ASC is a super FAST python Android Decompiler for Agents/Mobile Researchers" readme = "README.md" license = "Apache-2.0" diff --git a/tests/dex_fixture.py b/tests/dex_fixture.py index cdfbf2e..0f3ce03 100644 --- a/tests/dex_fixture.py +++ b/tests/dex_fixture.py @@ -266,3 +266,170 @@ def section(kind, size, data, align=4): buf[12:32] = hashlib.sha1(buf[32:]).digest() struct.pack_into('a:Lexample/Target;", output) + self.assertNotIn("Lexample/Test;->a L", output) + + def test_invoke_registers_use_smali_braces(self): + output = disassemble_dex_bytes(make_invoke_dex()) + chain_output = disassemble_dex_bytes(make_field_invoke_chain_dex()) + + self.assertIn( + "invoke-static {}, Lexample/Target;->callToMethod()V", + output, + ) + self.assertIn( + "invoke-virtual {v0}, Lexample/Target;->callToMethod()V", + chain_output, + ) + + def test_range_invoke_registers_use_smali_range_braces(self): + dex = DEX(make_dex()) + instruction = Instruction3rc( + dex.CM, + b"\x77\x03\x00\x00\x02\x00", + ) + + self.assertEqual( + "{v2 .. v4}, Lexample/Test;->first()V", + instruction.get_output(), + ) + + def test_full_disassembly_renders_smali_metadata_debug_and_exception_labels(self): + output = disassemble_dex_bytes(make_rich_dex()) + + self.assertIn(".class public Lexample/Rich;", output) + self.assertIn(".super Ljava/lang/Object;", output) + self.assertIn(".implements Lexample/Marker;", output) + self.assertIn('.source "Rich.java"', output) + self.assertIn(".field public value:I", output) + self.assertIn(".method public run(I)I", output) + self.assertIn(" .locals 1", output) + self.assertIn(".annotation runtime Ldalvik/annotation/Deprecated;", output) + self.assertNotIn(" .line ", output) + self.assertNotIn(" .local ", output) + self.assertNotIn(" .end local ", output) + self.assertIn(" if-nez v0, :cond_0", output) + self.assertIn(" goto :goto_0", output) + self.assertIn(" :try_start_0", output) + self.assertIn(" :try_end_0", output) + self.assertIn( + " .catch Ljava/lang/Exception; {:try_start_0 .. :try_end_0} :catch_0", + output, + ) + self.assertIn(" :catch_0", output) + + debug_output = disassemble_dex_bytes(make_rich_dex(), debug=True) + self.assertIn(" # 00000000 ", debug_output) + self.assertNotIn(" .line ", debug_output) + + debuginfo_output = disassemble_dex_bytes( + make_rich_dex(), + debuginfo=True, + ) + self.assertIn(" .line 10", debuginfo_output) + self.assertIn(' .local v0, "arg":I', debuginfo_output) + self.assertIn(" .end local v0", debuginfo_output) + + def test_rebuilt_class_keeps_debug_info_for_smali_output(self): + rebuilt = DexManager(memoryview(make_rich_dex())).extract_and_rebuild( + "Lexample/Rich;" + ) + output = disassemble_dex_bytes( + rebuilt, + verify_checksum=False, + debuginfo=True, + ) + + self.assertIn(" .line 10", output) + self.assertIn(' .local v0, "arg":I', output) + self.assertIn(" .end local v0", output) + self.assertIn( + " .catch Ljava/lang/Exception; {:try_start_0 .. :try_end_0} :catch_0", + output, + ) + + def test_cli_extracts_rebuilds_and_disassembles_target_class_from_apk(self): + with tempfile.TemporaryDirectory() as directory: + apk_path = Path(directory) / "fixture.apk" + with zipfile.ZipFile(apk_path, "w") as archive: + archive.writestr("classes.dex", make_dex()) + result = subprocess.run( + [ + sys.executable, + str(ROOT / "main.py"), + "disassemble", + str(apk_path), + "example.Test", + ], + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("# CLASS: Lexample/Test;", result.stdout) + self.assertIn("const-string", result.stdout) + self.assertNotIn("class Test", result.stdout) + + def test_cli_debug_reports_disassemble_stage_timings(self): + with tempfile.TemporaryDirectory() as directory: + apk_path = Path(directory) / "fixture.apk" + with zipfile.ZipFile(apk_path, "w") as archive: + archive.writestr("classes.dex", make_dex()) + result = subprocess.run( + [ + sys.executable, + str(ROOT / "main.py"), + "disassemble", + str(apk_path), + "example.Test", + "--debug", + ], + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("[DEBUG] Disassemble APK lookup:", result.stderr) + self.assertIn("[DEBUG] Disassemble DEX rebuild:", result.stderr) + self.assertIn("[DEBUG] Disassemble smali format:", result.stderr) + self.assertIn("[DEBUG] Disassemble total:", result.stderr) + + def test_cli_method_filter_uses_exact_signature(self): + with tempfile.TemporaryDirectory() as directory: + apk_path = Path(directory) / "fixture.apk" + with zipfile.ZipFile(apk_path, "w") as archive: + archive.writestr("classes.dex", make_dex()) + result = subprocess.run( + [ + sys.executable, + str(ROOT / "main.py"), + "disassemble", + str(apk_path), + "example.Test", + "--method", + "first()V", + ], + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(".method public static first()V", result.stdout) + self.assertNotIn(".method public static second()V", result.stdout) + + def test_cli_debuginfo_enables_line_directives(self): + with tempfile.TemporaryDirectory() as directory: + apk_path = Path(directory) / "fixture.apk" + with zipfile.ZipFile(apk_path, "w") as archive: + archive.writestr("classes.dex", make_rich_dex()) + result = subprocess.run( + [ + sys.executable, + str(ROOT / "main.py"), + "disassemble", + str(apk_path), + "example.Rich", + "--debuginfo", + ], + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(" .line 10", result.stdout) + self.assertIn(' .local v0, "arg":I', result.stdout) + + +if __name__ == "__main__": + unittest.main()