From 752477e8a374860853c9fcfd6e6604c0db9dcfa7 Mon Sep 17 00:00:00 2001 From: MG1937 <2586364982@qq.com> Date: Thu, 17 Sep 2026 02:13:21 +0800 Subject: [PATCH 1/2] add listclass command --- README.md | 12 +- droidasc/asc_client/apk_handler.py | 94 ++++++++++++++ droidasc/cli.py | 54 +++++++- pyproject.toml | 2 +- tests/TESTING.md | 6 +- tests/test_listclass.py | 196 +++++++++++++++++++++++++++++ tests/test_release.py | 3 +- 7 files changed, 360 insertions(+), 7 deletions(-) create mode 100644 tests/test_listclass.py diff --git a/README.md b/README.md index 6e0a414..8c89cbc 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,14 @@ pip install . After installation, the `droidasc` CLI command is available globally: ``` -usage: droidasc [-h] {getclass,getmanifest,findrefs} ... +usage: droidasc [-h] {getclass,listclass,getmanifest,findrefs} ... ASC tooling entry. positional arguments: - {getclass,getmanifest,findrefs} + {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. @@ -41,6 +42,8 @@ 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 listclass app.apk -o classes.txt + droidasc listclass app.apk --prefix com.poc 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 @@ -49,4 +52,9 @@ examples: droidasc findrefs app.apk field apiKey -o field_refs.txt ``` +`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 +`Lcom/poc` is kept unchanged. + You can also use `python main.py` as before — it delegates to the same entry point. diff --git a/droidasc/asc_client/apk_handler.py b/droidasc/asc_client/apk_handler.py index f13af81..39376bc 100644 --- a/droidasc/asc_client/apk_handler.py +++ b/droidasc/asc_client/apk_handler.py @@ -414,6 +414,100 @@ def get_class_dex(self, dalvik_class : str): mm.close() fp.close() + def list_classes(self, prefix : str = None): + if self.max_workers <= 0: + raise ValueError("Worker count must be greater than zero") + if prefix is not None: + prefix = prefix.strip() + if not prefix: + raise ValueError("Class prefix cannot be empty") + if not prefix.startswith("L"): + prefix = "L" + prefix.replace(".", "/") + prefix_bytes = prefix.encode("ascii") if prefix and prefix.isascii() else None + + def list_dex_classes(buf): + if len(buf) < 0x70 or buf[:3] != b"dex": + raise ValueError("invalid DEX header") + + 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] + + if class_defs_size == 0: + return [] + if string_ids_off + string_ids_size * 4 > len(buf): + raise ValueError("bad string_ids range") + if type_ids_off + type_ids_size * 4 > len(buf): + raise ValueError("bad type_ids range") + if class_defs_off + class_defs_size * 32 > len(buf): + raise ValueError("bad class_defs range") + + names = [] + append = names.append + for class_def_off in range(class_defs_off, class_defs_off + class_defs_size * 32, 32): + type_idx = _U32_FROM(buf, class_def_off)[0] + if type_idx >= type_ids_size: + raise ValueError("bad class_def->type_idx") + string_idx = _U32_FROM(buf, type_ids_off + (type_idx << 2))[0] + if string_idx >= string_ids_size: + raise ValueError("bad type_id->string_idx") + string_off = _U32_FROM(buf, string_ids_off + (string_idx << 2))[0] + if string_off >= len(buf): + raise ValueError("bad string_data_off") + try: + raw_name = _read_string_data_bytes(buf, string_off) + except IndexError as error: + raise ValueError("bad string_data_off") from error + + if prefix_bytes is not None: + if raw_name.startswith(prefix_bytes): + append(raw_name.decode("utf-8", errors="replace")) + else: + name = raw_name.decode("utf-8", errors="replace") + if prefix is None or name.startswith(prefix): + append(name) + return names + + t_start = time.perf_counter() + fp, mm = self._open_apk() + try: + entries = _parse_cd_dex_entries(mm) + if not entries: + return [] + + def list_entry(entry): + data = _inflate_dex(mm, entry) + names = [] + for dex_name, dex_buf in iter_logical_dex_buffers(entry[0], data): + names.extend(list_dex_classes(dex_buf)) + return entry[0], names + + workers = min(len(entries), self.max_workers) + if workers == 1: + results = [list_entry(entry) for entry in entries] + else: + with ThreadPoolExecutor(max_workers=workers) as ex: + results = list(ex.map(list_entry, entries)) + + names = [] + for dex_name, dex_names in results: + names.extend(dex_names) + self._log(f"[APK] '{dex_name}' class_count={len(dex_names)}") + + if self.debug: + t_end = time.perf_counter() + self._log( + f"[APK] listclass total={(t_end - t_start) * 1000000:.2f} us " + f"count={len(names)} workers={workers}" + ) + return names + finally: + mm.close() + fp.close() + def for_each_findrefs(self, find_type : str, find : dict): # imported before the timer so this once-per-process import is not charged to a # single search; it used to happen at apk_handler import time, and keeping the diff --git a/droidasc/cli.py b/droidasc/cli.py index bec7ba9..775b234 100644 --- a/droidasc/cli.py +++ b/droidasc/cli.py @@ -84,6 +84,32 @@ def _handle_getclass(args): print(source_code) +def _handle_listclass(args): + from droidasc.asc_client.apk_handler import ApkHandler + + names = ApkHandler(args.apk_path, debug=args.debug, max_workers=args.threads).list_classes( + args.prefix + ) + + output_fp = ( + open(args.output, "w", encoding="utf-8", errors="replace", newline="\n") + if args.output + else None + ) + out = output_fp if output_fp is not None else sys.stdout + try: + for start in range(0, len(names), 8192): + text = "\n".join(names[start:start + 8192]) + "\n" + out.write(text) + finally: + if output_fp is not None: + output_fp.close() + + if args.debug: + t_end = time.perf_counter() + print(f"[DEBUG] Total Execution Time: {(t_end - t_start) * 1000000:.2f} us") + + def _handle_getmanifest(args): from droidasc.asc_client.manifest_handler import get_manifest_xml @@ -189,9 +215,11 @@ def main(): try: if args.command == "getclass": _handle_getclass(args) + elif args.command == "listclass": + _handle_listclass(args) elif args.command == "getmanifest": _handle_getmanifest(args) - else: + elif args.command == "findrefs": _handle_findrefs(args) except Exception as e: print(f"Error: {e}", file=sys.stderr) @@ -209,6 +237,8 @@ def _build_main_parser(): droidasc app.apk --gui droidasc getclass app.apk Lcom/poc/Main; -o Main.java droidasc getclass app.apk com.poc.Main --threads 16 + droidasc listclass app.apk -o classes.txt + droidasc listclass app.apk --prefix com.poc 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 @@ -235,6 +265,26 @@ def _build_main_parser(): 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;).") + listclass_parser = subparsers.add_parser( + "listclass", + help="List classes across all DEX entries; use --prefix to filter by package/class prefix.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""examples: + droidasc listclass app.apk + droidasc listclass app.apk --prefix com.poc + droidasc listclass app.apk -o classes.txt + droidasc listclass app.apk --threads 16 --debug +""", + ) + listclass_parser.add_argument("--debug", action="store_true", help="Enable debug profiling output.") + listclass_parser.add_argument("--threads", "--thread", type=int, default=8, help="Worker thread count.") + listclass_parser.add_argument( + "--prefix", + help="Only list classes with this package/class prefix (e.g., com.poc or Lcom/poc).", + ) + listclass_parser.add_argument("-o", "--output", help="Write class names to this file instead of stdout.") + listclass_parser.add_argument("apk_path", help="Path to the input APK file.") + getmanifest_parser = subparsers.add_parser( "getmanifest", help="Decode AndroidManifest.xml from APK and print it as XML.", @@ -322,4 +372,4 @@ def _build_main_parser(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/pyproject.toml b/pyproject.toml index 8fe667c..4d897bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "droidasc" -version = "0.1.0" +version = "0.1.1-dev0" description = "ASC is a super FAST python Android Decompiler for Agents/Mobile Researchers" readme = "README.md" license = "Apache-2.0" diff --git a/tests/TESTING.md b/tests/TESTING.md index 80800c3..3d76c78 100644 --- a/tests/TESTING.md +++ b/tests/TESTING.md @@ -12,7 +12,11 @@ The regression runner fails if Androguard is missing or any test is skipped in strict mode. It covers index zero, empty/fallback instruction maps, string layout, zeroed reconstructed DEX signatures/checksums, dummy imports, the MUTF-8 shim, CLI stored/Deflate multidex operation, GUI data-store reuse, and reference searches -without site packages. It does not open GUI windows. +without site packages. `listclass` coverage includes local class-table +enumeration, stable DEX definition order, normalized package-prefix filtering, +stored/Deflate multidex APKs, DEX 041 containers, malformed indices, operation +without site packages, and a large-tail Deflate fixture. It does not open GUI +windows. The synthetic startup gate measures `getclass --debug` in nine fresh interpreters. Its median budgets are 100 ms internal time and 250 ms wall time on Linux CI. diff --git a/tests/test_listclass.py b/tests/test_listclass.py new file mode 100644 index 0000000..7299ae8 --- /dev/null +++ b/tests/test_listclass.py @@ -0,0 +1,196 @@ +from pathlib import Path +import random +import struct +import subprocess +import sys +import tempfile +import unittest +import zipfile + +from dex_fixture import make_dex, make_static_field_dex + + +ROOT = Path(__file__).resolve().parents[1] + + +def make_class_only_dex(descriptors, base=0, version=b"035"): + header_size = 0x78 if version == b"041" else 0x70 + encoded = [name.encode("utf-8") for name in descriptors] + buf = bytearray(header_size) + + string_ids = len(buf) + buf.extend(bytes(4 * len(encoded))) + type_ids = len(buf) + buf.extend(bytes(4 * len(encoded))) + class_defs = len(buf) + buf.extend(bytes(32 * len(encoded))) + data_off = len(buf) + + for index, value in enumerate(encoded): + string_off = base + len(buf) + struct.pack_into("type_idx", result.stderr) + + def test_worker_count_must_be_positive(self): + with tempfile.TemporaryDirectory() as directory: + apk = Path(directory) / "fixture.apk" + with zipfile.ZipFile(apk, "w") as archive: + archive.writestr("classes.dex", make_dex()) + result = self.run_cli(apk, "--threads", "0") + self.assertEqual(result.returncode, 1) + self.assertIn("Worker count must be greater than zero", result.stderr) + + def test_empty_prefix_is_a_clean_error(self): + with tempfile.TemporaryDirectory() as directory: + apk = Path(directory) / "fixture.apk" + with zipfile.ZipFile(apk, "w") as archive: + archive.writestr("classes.dex", make_dex()) + result = self.run_cli(apk, "--prefix", " ") + self.assertEqual(result.returncode, 1) + self.assertIn("Class prefix cannot be empty", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release.py b/tests/test_release.py index 0d21b39..b859941 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -36,7 +36,8 @@ def test_archive_is_reproducible_and_runs_outside_checkout(self): apk = root / 'fixture.apk' with zipfile.ZipFile(apk, 'w', compression=zipfile.ZIP_DEFLATED) as package: package.writestr('classes.dex', make_dex()) - for args, expected in ((['findrefs', str(apk), 'string', 'token'], 'token'), + for args, expected in ((['listclass', str(apk), '--prefix', 'example'], 'Lexample/Test;'), + (['findrefs', str(apk), 'string', 'token'], 'token'), (['getclass', str(apk), 'example.Test'], 'class Test')): result = subprocess.run([sys.executable, str(app / 'main.py'), *args], cwd=root, capture_output=True, text=True, timeout=30) From 382c4c5a1dcebaef714306718b4487721ac3e988 Mon Sep 17 00:00:00 2001 From: MG1937 <2586364982@qq.com> Date: Thu, 17 Sep 2026 02:52:37 +0800 Subject: [PATCH 2/2] fix version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4d897bb..9facea4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "droidasc" -version = "0.1.1-dev0" +version = "0.1.1.post1" description = "ASC is a super FAST python Android Decompiler for Agents/Mobile Researchers" readme = "README.md" license = "Apache-2.0"