Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 67 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Binary file modified docs/benchmark_all_en.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions droidasc/asc_client/asc_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
62 changes: 43 additions & 19 deletions droidasc/asc_core/findrefs/findrefs_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,32 +91,22 @@ 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"]
if "impl" in find:
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:
Expand All @@ -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
67 changes: 61 additions & 6 deletions droidasc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
45 changes: 45 additions & 0 deletions tests/test_references.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading