diff --git a/README.md b/README.md index 8717a69..9342d83 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,54 @@ We will demonstrate this architecture live against a 352MB commercial APK. Droid https://github.com/user-attachments/assets/4c4a6813-8561-490c-a573-ef113da861b6 +Raw absolute values, measured with 10 threads across four APK targets of +increasing size: + +**Global string xref search** + +| Tool | TelegramX (59MB) | WhatsApp (130MB) | Grab (228MB) | Lark (352MB) | +| --- | --- | --- | --- | --- | +| ASC | 322ms | 418ms | 806ms | 1.597s | +| JADX | 20s | 32s | 2m14s | 8m2s | + +ASC is 62x to 302x faster than JADX for global string xref search. + +**Single-class decompile** + +| Tool | TelegramX (59MB) | WhatsApp (130MB) | Grab (228MB) | Lark (352MB) | +| --- | --- | --- | --- | --- | +| ASC | 157ms | 141ms | 165ms | 303ms | +| JADX | 6s | 15s | 37s | 1m32s | + +ASC is 38x to 304x faster than JADX for single-class decompile. + +**CLI memory usage** + +| Tool | TelegramX (59MB) | WhatsApp (130MB) | Grab (228MB) | Lark (352MB) | +| --- | --- | --- | --- | --- | +| ASC | 36MB | 36MB | 58MB | 141MB | +| JADX | 1,016MB | 2.1GB | 7.1GB | 13.2GB | + +ASC uses 28x to 125x less memory than JADX for CLI operation. + +**Disk cache usage** + +| Tool | TelegramX (59MB) | WhatsApp (130MB) | Grab (228MB) | Lark (352MB) | +| --- | --- | --- | --- | --- | +| ASC | 0MB | 0MB | 0MB | 0MB | +| JADX | 119MB | 30MB | 127MB | 322MB | + +ASC stays fully in memory with zero disk cache, while JADX writes up to 322MB. + +Note: JADX runs out of memory on the largest target (Lark) during the global +search (~49% searched), while ASC completes every target. + +As the benchmark shows, ASC is 62x faster than JADX on ~50MB APKs, and 302x +faster on ~300MB APKs, with a tiny memory footprint. Agents can pair with ASC +to analyze dozens or even hundreds of APKs at once, even on weak machines. +The JADX MCP has a harder time here: JADX alone uses several GB just to +preload, which makes switching between hundreds of APKs impractical. + # Install ```bash # from PyPI @@ -26,14 +74,18 @@ After installation, the `droidasc` CLI command is available globally: ``` usage: droidasc [-h] {getclass,listclass,getmanifest,findrefs} ... -ASC tooling entry. +ASC tooling entry (v0.1.2.post1). positional arguments: {getclass,listclass,getmanifest,findrefs} - getclass Locate the target class in APK, extract one DEX in memory, then decompile. - listclass List classes defined across all DEX entries in APK. - getmanifest Decode AndroidManifest.xml from APK and print it as XML. - findrefs Find code references for string/type/method/field across all DEX entries in APK. + getclass Locate the target class in APK, extract one DEX in + memory, then decompile. + 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 + XML. + findrefs Find code references for string/type/method/field + across all DEX entries in APK. options: -h, --help show this help message and exit @@ -50,8 +102,18 @@ examples: droidasc findrefs app.apk method onCreate --class com.poc.Main droidasc findrefs app.apk method notify --class MainActivity --fuzzy-class -o method_refs.txt droidasc findrefs app.apk field apiKey -o field_refs.txt + droidasc findrefs app.apk extends java.lang.Object + droidasc findrefs app.apk extends android.app.Activity -o subclasses.txt + droidasc findrefs app.apk impl android.os.IInterface -o impls.txt ``` +`-V`/`--version` prints the installed package version. + +`findrefs` searches for code references (string/type/method/field) across all +DEX entries; `-o` writes results to the selected file instead of stdout, and +`--threads N` controls the worker pool. `extends`/`impl` locate classes that +extend a target class or implement a target interface, respectively. + `listclass` prints Dalvik class descriptors in APK/DEX definition order, one per 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 diff --git a/docs/benchmark_all_en.png b/docs/benchmark_all_en.png index 4e6632d..570c7d8 100644 Binary files a/docs/benchmark_all_en.png and b/docs/benchmark_all_en.png differ diff --git a/droidasc/asc_client/asc_handler.py b/droidasc/asc_client/asc_handler.py index 0ad2427..eb08346 100644 --- a/droidasc/asc_client/asc_handler.py +++ b/droidasc/asc_client/asc_handler.py @@ -103,6 +103,20 @@ def findrefs(self, dex_name : str, dex_buf : bytes, find_type : str, find : dict dex = DEX.parse(memoryview(dex_buf), dex_name) ref_manager = FindRefManager(dex) + # extends/impl short-circuit in find_ref: they return class indexes, not + # containing-method indexes, so format them directly. find must be copied + # too -- find_ref rewrites the target key in place and workers reuse it. + if find_type in ("extends", "impl"): + target = find[find_type] + # scan_extends/scan_implements hand back type_id indexes (a class's + # class_idx field in class_def_item), not class_def positions, so + # resolve each directly through the type table. + class_idxs = ref_manager.find_ref(copy.deepcopy(find), True) + verb = "extends" if find_type == "extends" else "implements" + return [ + f"{dex_name} | {dex.get_type(class_idx).descriptor} {verb} {target}" + for class_idx in class_idxs + ] # find_ref rewrites the selected query list with containing method # indexes. Keep the caller's query reusable across DEX workers. query = copy.deepcopy(find) diff --git a/droidasc/asc_core/findrefs/findrefs_manager.py b/droidasc/asc_core/findrefs/findrefs_manager.py index 4444c6c..d8fa527 100644 --- a/droidasc/asc_core/findrefs/findrefs_manager.py +++ b/droidasc/asc_core/findrefs/findrefs_manager.py @@ -91,7 +91,11 @@ def _get_class_def_item_scanner(self): # find struct: {"string" : "foo", "type" : "bar", "method" : {...}, "field" : {...}, "extends" : "Lfoo;"} # locate result will overwrite input find in-place, then scanner writes ref mids back into each key # extends writes class idx back into find and returns directly, dont scan code item - def find_ref(self, find : dict, mark = False): + def find_ref(self, find : dict, mark = False, group = False): + if group: + return self._find_ref_grouped(find, mark) + # non-group contract: extends/impl are class-def lookups that short-circuit + # the opcode scan, so a plain call sees only that subclass result if "extends" in find: find["extends"] = self._get_class_def_item_scanner().scan_extends(find["extends"]) return find["extends"] @@ -99,24 +103,10 @@ def find_ref(self, find : dict, mark = False): find["impl"] = self._get_class_def_item_scanner().scan_implements(find["impl"]) return find["impl"] - has_idx = False - if "string" in find: - find["string"] = self._get_str_locator(True).locate(find["string"]) - if find["string"]: - has_idx = True - if "type" in find: - find["type"] = self._get_type_locator(True).locate(find["type"]) - if find["type"]: - has_idx = True - if "method" in find: - find["method"] = self._get_method_locator(True).locate(find["method"]) - if find["method"]: - has_idx = True - if "field" in find: - find["field"] = self._get_field_locator(True).locate(find["field"]) - if find["field"]: - has_idx = True - + # non-group keeps the original contract: located idxs are written back + # into find in place, and if nothing is located every ref key becomes [] + refs, has_idx = self._collect_refs(find) + find.update(refs) if not find: return if not has_idx: @@ -126,3 +116,37 @@ def find_ref(self, find : dict, mark = False): self._get_code_scanner().scan(find, mark) return self._get_code_scanner().mark_idx + + # group=True: every queried key comes back as one list in the result dict, + # 1:1 with the input. extends/impl resolve the subclass set from class_def_item; + # the rest are opcode references. Only ref keys ever enter the code-item scan. + def _find_ref_grouped(self, find : dict, mark = False): + result = {} + if "extends" in find: + result["extends"] = self._get_class_def_item_scanner().scan_extends(find["extends"]) + if "impl" in find: + result["impl"] = self._get_class_def_item_scanner().scan_implements(find["impl"]) + + refs, has_idx = self._collect_refs(find) + if not has_idx: + # no located idx anywhere -> every ref key is an empty list + result.update({key: [] for key in refs}) + return result + self._get_code_scanner().scan(refs, mark) + result.update(refs) + return result + + # locate each opcode-ref key into {type: idx_set}; returns the located keys + # and whether any of them hit. find is not modified here. + def _collect_refs(self, find : dict): + refs = {} + if "string" in find: + refs["string"] = self._get_str_locator(True).locate(find["string"]) + if "type" in find: + refs["type"] = self._get_type_locator(True).locate(find["type"]) + if "method" in find: + refs["method"] = self._get_method_locator(True).locate(find["method"]) + if "field" in find: + refs["field"] = self._get_field_locator(True).locate(find["field"]) + has_idx = any(refs.values()) + return refs, has_idx diff --git a/droidasc/cli.py b/droidasc/cli.py index 775b234..a0ab8a5 100644 --- a/droidasc/cli.py +++ b/droidasc/cli.py @@ -6,6 +6,14 @@ t_start = time.perf_counter() +def _version(): + from importlib import metadata as _pkg_metadata + + try: + return _pkg_metadata.version("droidasc") + except _pkg_metadata.PackageNotFoundError: + return "unknown" + def _format_class_name(name : str) -> str: if not name: @@ -48,6 +56,9 @@ def _get_find_query(args) -> tuple: return "string", {"string": args.value} if args.find_type == "type": return "type", {"type": args.value} + if args.find_type in ("extends", "impl"): + # scan_extends/scan_implements need the precise dalvik class form + return args.find_type, {args.find_type: _format_class_name(args.value)} if args.find_type == "method": return "method", _build_member_find("method", args.class_name, args.fuzzy_class, args.name) return "field", _build_member_find("field", args.class_name, args.fuzzy_class, args.name) @@ -202,14 +213,18 @@ def _run_gui(argv): def main(): + argv = sys.argv[1:] if len(sys.argv) == 1: - _build_main_parser().print_help() + _build_main_parser(show_version=True).print_help() + return + if argv[0] in ("-v", "-V", "--version"): + print(_version()) return - if "--gui" in sys.argv[1:]: - _run_gui(sys.argv[1:]) + if "--gui" in argv: + _run_gui(argv) return - parser = _build_main_parser() + parser = _build_main_parser(show_version=("-h" in argv or "--help" in argv)) args = parser.parse_args() try: @@ -229,9 +244,14 @@ def main(): sys.exit(1) -def _build_main_parser(): +def _build_main_parser(show_version=False): parser = argparse.ArgumentParser( - description="ASC tooling entry.", + prog="droidasc", + description=( + f"ASC tooling entry (v{_version()})." + if show_version + else "ASC tooling entry." + ), formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""examples: droidasc app.apk --gui @@ -245,6 +265,9 @@ def _build_main_parser(): droidasc findrefs app.apk method onCreate --class com.poc.Main droidasc findrefs app.apk method notify --class MainActivity --fuzzy-class -o method_refs.txt droidasc findrefs app.apk field apiKey -o field_refs.txt + droidasc findrefs app.apk extends java.lang.Object + droidasc findrefs app.apk extends android.app.Activity -o subclasses.txt + droidasc findrefs app.apk impl android.os.IInterface -o impls.txt """, ) subparsers = parser.add_subparsers(dest="command", required=True) @@ -339,6 +362,38 @@ def _build_main_parser(): type_parser.add_argument("-o", "--output", help="Also write reference search output to this file.") type_parser.add_argument("value", help="Fuzzy type pattern.") + extends_parser = find_subparsers.add_parser( + "extends", + help="Find classes that extend the target class.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""examples: + droidasc findrefs app.apk extends android.app.Activity + droidasc findrefs app.apk extends com.poc.Base -o subclasses.txt +""", + ) + extends_parser.add_argument("-o", "--output", help="Also write subclass output to this file.") + # SUPPRESS so a flag given before the sub-command (handled by the parent + # 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;") + + impl_parser = find_subparsers.add_parser( + "impl", + help="Find classes that implement the target interface.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""examples: + droidasc findrefs app.apk impl android.os.IInterface + droidasc findrefs app.apk impl com.poc.Service -o impls.txt +""", + ) + impl_parser.add_argument("-o", "--output", help="Also write implementor output to this file.") + # SUPPRESS so a flag given before the sub-command (handled by the parent + # 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;") + method_parser = find_subparsers.add_parser( "method", help="Find references to methods.", diff --git a/pyproject.toml b/pyproject.toml index 0f7e6fe..c40ecaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "droidasc" -version = "0.1.1.post5" +version = "0.1.2-post2" 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_references.py b/tests/test_references.py index cef4548..5711e15 100644 --- a/tests/test_references.py +++ b/tests/test_references.py @@ -67,6 +67,51 @@ def test_search_does_not_require_androguard(self): self.assertTrue(any('->first' in line for line in lines)) self.assertTrue(any('->second' in line for line in lines)) + def test_group_returns_every_queried_type_keyed(self): + from droidasc.asc_core.findrefs.findrefs_manager import FindRefManager + dex = DEX.parse(memoryview(make_dex()), 'fixture.dex') + # every queried key comes back in the result dict, 1:1 with the input + query = {'string': 'token', 'type': 'Lexample/Test;', 'method': {'class': None, 'method': 'first'}} + result = FindRefManager(dex).find_ref(query, True, group=True) + self.assertEqual(set(result), {'string', 'type', 'method'}) + # scan writes through into the returned group; the fixture hosts a token + # string reference, so at least that group is non-empty + self.assertTrue(result['string']) + # non-group keeps the previous contract (flat mark_idx) + mark_idx = FindRefManager(dex).find_ref({'string': 'token'}, True) + self.assertIsNotNone(mark_idx) + + def test_group_merges_extends_with_ref_keys(self): + from droidasc.asc_core.findrefs.findrefs_manager import FindRefManager + dex = DEX.parse(memoryview(make_dex()), 'fixture.dex') + query = {'extends': 'Ljava/lang/Object;', 'string': 'token'} + result = FindRefManager(dex).find_ref(query, True, group=True) + # extends joins the group result and stays a separate key from the ref scan + self.assertEqual(set(result), {'extends', 'string'}) + self.assertTrue(result['extends']) + self.assertTrue(result['string']) + # a plain call still short-circuits to the subclass list only + plain = FindRefManager(dex).find_ref({'extends': 'Ljava/lang/Object;'}) + self.assertEqual(plain, result['extends']) + + def test_cli_extends_and_impl_format(self): + # Scan the fixture's Test class through the CLI-style findrefs wrapper: + # output must carry the dex name, the type_id fullname, and the verb. + extends = AscHandler().findrefs( + 'fixture.dex', make_dex(), 'extends', {'extends': 'Ljava/lang/Object;'} + ) + self.assertTrue(extends) + self.assertTrue(any( + line.startswith('fixture.dex | ') + and ' extends Ljava/lang/Object;' in line and 'Lexample/Test;' in line + for line in extends + ), extends) + # impl of a target no class implements yields no rows, but no crash + impl = AscHandler().findrefs( + 'fixture.dex', make_dex(), 'impl', {'impl': 'Ljava/io/Serializable;'} + ) + self.assertEqual(impl, []) + class InsnVerifyPatternTests(unittest.TestCase): """INSN_VERIFY must compile on every Python the project declares support for."""