From 7e44aa4393c86b60df8e2ad4be6a7d781ac4b44e Mon Sep 17 00:00:00 2001 From: MG1937 <2586364982@qq.com> Date: Wed, 21 Oct 2026 15:03:49 +0800 Subject: [PATCH 1/4] feat: improve smali disassembly and class lookup - Add dadguard smali rendering for class metadata, fields, annotations, locals, labels, try/catch and optional debuginfo output. - Normalize field references, method descriptors, invoke register braces and range invoke syntax so output is smali-assemblable. - Fix rebuilt DEX debug_info_off handling and add --debug phase timing. - Add prefix-only raw-deflate inflation for class lookup, with DEX 041, stored-entry and malformed-prefix fallback to full inflation. - Add regression fixtures and tests for disassembly, invoke/field syntax, prefix inflation and CLI behavior. - Measured NativePrefetch lookup improvement: 727ms median to 307ms; total pipeline 769ms to 342ms. --- README.md | 20 +- dadguard/core/dex/__init__.py | 107 ++-- dadguard/disassembler.py | 736 ++++++++++++++++++++++ droidasc/asc_client/apk/dex_reader.py | 91 +++ droidasc/asc_client/apk/loader.py | 57 ++ droidasc/asc_client/apk_handler.py | 33 +- droidasc/asc_core/core/dex/dex_builder.py | 13 +- droidasc/cli.py | 125 +++- tests/dex_fixture.py | 167 +++++ tests/test_apk_modules.py | 56 ++ tests/test_disassembler.py | 200 ++++++ 11 files changed, 1522 insertions(+), 83 deletions(-) create mode 100644 dadguard/disassembler.py create mode 100644 tests/test_disassembler.py diff --git a/README.md b/README.md index 5799730..31ef398 100644 --- a/README.md +++ b/README.md @@ -77,14 +77,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 +101,13 @@ 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 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 +131,13 @@ 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. 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 +157,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..20f658d 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,10 @@ 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() + if hasattr(method, "get_real_descriptor"): + descriptor = method.get_real_descriptor() + else: + descriptor = "".join(method.get_descriptor().split()) return "{}->{}{}".format(class_name, name, descriptor) @@ -4486,7 +4491,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 +4515,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 +5155,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 +5952,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 +6551,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 +6587,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 +8286,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 +8299,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/disassembler.py b/dadguard/disassembler.py new file mode 100644 index 0000000..60e01d7 --- /dev/null +++ b/dadguard/disassembler.py @@ -0,0 +1,736 @@ +"""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$") + + +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 _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 = 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, +) -> 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 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 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, +) -> 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: + lines = [] + for cls in dex.get_classes(): + lines.extend( + _format_class( + cls, + debug=debug, + debuginfo=debuginfo, + ) + ) + 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, +) -> 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, + ) + + +def disassemble_dex_file( + dex_path: str, + offset: int = 0, + size: int = 0, + verify_checksum: bool = True, + debug: bool = False, + debuginfo: bool = False, +) -> 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, + ) 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..3a2f802 100644 --- a/droidasc/cli.py +++ b/droidasc/cli.py @@ -95,6 +95,67 @@ 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, + ) + 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 +228,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 +285,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 +310,22 @@ 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 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 +353,39 @@ 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 +""", + ) + 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("--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 +475,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 +494,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/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_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() From 61ddfd7f2e6812b6b898f192b16b84b408d8b6dd Mon Sep 17 00:00:00 2001 From: MG1937 <2586364982@qq.com> Date: Wed, 21 Oct 2026 16:24:41 +0800 Subject: [PATCH 2/4] fix: preserve parameters for code-less methods Keep declared parameters visible for abstract and native methods without code items, and add a regression test. --- dadguard/decompiler/writer.py | 2 ++ tests/test_decompiler.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) 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/tests/test_decompiler.py b/tests/test_decompiler.py index 77abd4d..d6c482e 100644 --- a/tests/test_decompiler.py +++ b/tests/test_decompiler.py @@ -12,10 +12,30 @@ from droidasc.asc_core.core.dex.dex_manager import DexManager from droidasc.asc_core.utils.dex_parser import parse_encoded_array from droidasc.asc_core.utils.tinydex import DEX +from dadguard.decompiler.writer import Writer ROOT = Path(__file__).resolve().parents[1] +class WriterTests(unittest.TestCase): + def test_abstract_method_keeps_declared_parameters_without_code(self): + class Method: + access = ['public', 'abstract'] + cls_name = 'Lexample/Test;' + name = 'run' + type = 'V' + lparams = [] + params_type = ['I', 'Ljava/lang/String;'] + + writer = Writer(None, Method()) + writer.write_method() + + self.assertIn( + 'public abstract void run(int p0, String p1);', + str(writer), + ) + + class RebuildTests(unittest.TestCase): def test_rebuilt_dex_keeps_signature_and_checksum_zero(self): data = DexManager(make_dex()).extract_and_rebuild('Lexample/Test;') From c3497745888ff9535878d10c1bfb5899ddc386b3 Mon Sep 17 00:00:00 2001 From: MG1937 <2586364982@qq.com> Date: Wed, 21 Oct 2026 17:04:51 +0800 Subject: [PATCH 3/4] feat: filter disassembly by exact method signature Add --method filtering for exact method descriptors, update CLI documentation and version, and cover API and CLI regression cases. --- README.md | 10 +++++--- dadguard/disassembler.py | 50 +++++++++++++++++++++++++++++++++++++- droidasc/cli.py | 8 ++++++ pyproject.toml | 2 +- tests/test_disassembler.py | 41 +++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 31ef398..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 @@ -104,6 +106,7 @@ examples: 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 @@ -135,8 +138,9 @@ line. With `-o`, output is written to the selected file instead of stdout. 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. All commands that support `-o` write to stdout when `-o` -is omitted. +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. diff --git a/dadguard/disassembler.py b/dadguard/disassembler.py index 60e01d7..b9c85cd 100644 --- a/dadguard/disassembler.py +++ b/dadguard/disassembler.py @@ -89,6 +89,31 @@ def _compact_descriptor(descriptor) -> str: return "".join(str(descriptor).split()) +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: @@ -610,6 +635,7 @@ 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() @@ -633,7 +659,7 @@ def _format_class( annotation_data = _annotation_data(cls) lines.extend(_annotation_lines(cls.CM, annotation_data["class"], "")) - fields = cls.get_fields() + fields = cls.get_fields() if method_signature is None else [] if fields: lines.append("") lines.append("# fields") @@ -641,6 +667,11 @@ def _format_class( 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") @@ -664,12 +695,24 @@ def format_dex_disassembly( 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( @@ -677,6 +720,7 @@ def format_dex_disassembly( cls, debug=debug, debuginfo=debuginfo, + method_signature=method_signature, ) ) lines.append("") @@ -705,6 +749,7 @@ def disassemble_dex_bytes( 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( @@ -713,6 +758,7 @@ def disassemble_dex_bytes( size, debug=debug, debuginfo=debuginfo, + method_signature=method_signature, ) @@ -723,6 +769,7 @@ def disassemble_dex_file( 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: @@ -733,4 +780,5 @@ def disassemble_dex_file( verify_checksum=verify_checksum, debug=debug, debuginfo=debuginfo, + method_signature=method_signature, ) diff --git a/droidasc/cli.py b/droidasc/cli.py index 3a2f802..03b7cef 100644 --- a/droidasc/cli.py +++ b/droidasc/cli.py @@ -118,6 +118,7 @@ def _handle_disassemble(args): verify_checksum=False, debug=args.debug, debuginfo=args.debuginfo, + method_signature=args.method_signature, ) t_format_end = time.perf_counter() if args.debug: @@ -322,6 +323,7 @@ def _build_main_parser(show_version=False): 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 @@ -367,6 +369,7 @@ def _build_main_parser(show_version=False): 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.") @@ -375,6 +378,11 @@ def _build_main_parser(show_version=False): 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", 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/test_disassembler.py b/tests/test_disassembler.py index c923dad..7a016ef 100644 --- a/tests/test_disassembler.py +++ b/tests/test_disassembler.py @@ -36,6 +36,22 @@ def test_range_disassembly_uses_instruction_offsets(self): self.assertIn("const-string", output) self.assertIn("return-void", output) + def test_method_filter_requires_exact_full_signature(self): + output = disassemble_dex_bytes( + make_dex(), + method_signature="first()V", + ) + + self.assertIn(".method public static first()V", output) + self.assertNotIn(".method public static second()V", output) + with self.assertRaises(ValueError): + disassemble_dex_bytes(make_dex(), method_signature="first") + with self.assertRaises(ValueError): + disassemble_dex_bytes( + make_dex(), + method_signature="missing()V", + ) + def test_field_references_use_smali_colon_separator(self): output = disassemble_dex_bytes(make_instance_field_dex()) @@ -171,6 +187,31 @@ def test_cli_debug_reports_disassemble_stage_timings(self): 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" From b9cba55af006ed8fbbfa0aefadc7511782f7d69e Mon Sep 17 00:00:00 2001 From: MG1937 <2586364982@qq.com> Date: Wed, 21 Oct 2026 17:11:12 +0800 Subject: [PATCH 4/4] perf: keep method descriptor formatting out of decompiler hot path Restore the cached core descriptor path for decompiler instruction rendering and compact method descriptors only in smali output, preserving valid syntax without the getclass slowdown. --- dadguard/core/dex/__init__.py | 5 +---- dadguard/disassembler.py | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/dadguard/core/dex/__init__.py b/dadguard/core/dex/__init__.py index 20f658d..ead8584 100644 --- a/dadguard/core/dex/__init__.py +++ b/dadguard/core/dex/__init__.py @@ -4475,10 +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() - if hasattr(method, "get_real_descriptor"): - descriptor = method.get_real_descriptor() - else: - descriptor = "".join(method.get_descriptor().split()) + descriptor = method.get_descriptor() return "{}->{}{}".format(class_name, name, descriptor) diff --git a/dadguard/disassembler.py b/dadguard/disassembler.py index b9c85cd..8761e96 100644 --- a/dadguard/disassembler.py +++ b/dadguard/disassembler.py @@ -51,6 +51,7 @@ 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: @@ -89,6 +90,17 @@ 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( @@ -518,7 +530,9 @@ def _smali_instructions( for label in catch_labels: lines.append(" :{}".format(label)) lines.extend(debug_events.get(offset, [])) - output = instruction.get_output(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])