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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ https://github.com/user-attachments/assets/f1f71a75-1eca-4647-89ce-406ed2c63c6b

- Search code references for strings, types, methods, and fields across every DEX in an APK.
- Locate and decompile one class directly from an APK.
- List classes and decode `AndroidManifest.xml`.
- List classes and defined methods, and decode `AndroidManifest.xml`.
- Export pseudocode for every class in an APK with the public API of [MG1937/radguard](https://github.com/MG1937/radguard).
- Handle multi-DEX APKs and DEX 041 logical containers.
- Use memory-mapped APK access and no on-disk cache.
Expand Down Expand Up @@ -65,7 +65,7 @@ On Windows, the executable name is `rust_ASC.exe`.

## Command Line Usage

Thread control is consistent across `getclass`, `listclass`, `findrefs`, and `export`:
Thread control is consistent across `getclass`, `listclass`, `listmethod`, `findrefs`, and `export`:

- Use only `--thread <N>`.
- Put `--thread` immediately after the subcommand.
Expand All @@ -88,18 +88,27 @@ rust_ASC getclass --thread 10 app.apk com.example.Main -o Main.java
rust_ASC listclass --thread 10 app.apk
rust_ASC listclass --thread 10 --prefix com.example app.apk

# List method definitions without decompiling, optionally filtered by class/name.
rust_ASC listmethod --thread 10 app.apk
rust_ASC listmethod --class com.example.Main -f onCreate app.apk
rust_ASC listmethod app.apk -o methods.txt

# Decode AndroidManifest.xml.
rust_ASC getmanifest app.apk -o AndroidManifest.xml

# Export pseudocode for every class to the required output directory.
rust_ASC export --thread 10 --debug app.apk output
```

`listmethod` prints one full Dalvik method descriptor per line, for example `Lcom/example/Main;->onCreate(Landroid/os/Bundle;)V`. It lists only methods defined in class data, including native and abstract methods, not external methods that are merely referenced. `--class` matches an exact dotted name or Dalvik descriptor; `--filter` / `-f` matches a case insensitive method name substring. Results retain DEX/class/declaration order and duplicate definitions across DEX files. Like `listclass`, `-o` writes the list instead of printing it; no matches produce an empty list.

`export` writes one `.java` pseudocode file per class under package-relative paths in the output directory. Its multi-threaded APK export pipeline is supplied by [MG1937/radguard](https://github.com/MG1937/radguard). With `--debug`, it reports the APK export lifecycle and worker activity.

## MCP

`rust_ASC mcp` runs a stdio MCP server that exposes the CLI commands as tools: `getclass`, `listclass`, `getmanifest`, and `findrefs`. Each call spawns a fresh CLI process, so behavior always matches the command line.
`rust_ASC mcp` runs a stdio MCP server that exposes the CLI commands as tools: `getclass`, `listclass`, `listmethod`, `getmanifest`, and `findrefs`. Each call spawns a fresh CLI process, so behavior always matches the command line.

The `listmethod` tool accepts `apk_path`, optional `class_name`, `filter`, `thread`, and `output`. It returns exactly the CLI stdout; with `output`, it writes the method list to that file and returns empty text.

Configure it in your MCP client:

Expand Down
133 changes: 88 additions & 45 deletions src/asc_client/apk_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Strict 1:1 translation from Python apk_handler.py

use std::fs::File;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Instant;
Expand All @@ -16,8 +16,9 @@ use crate::asc_client::apk::dex_reader::{
use crate::asc_client::apk::loader::{
_inflate_dex as _load_dex, _parse_cd_dex_entries as _parse_entries, DexEntry,
};
use crate::asc_client::dex_container::iter_logical_dex_buffers;
use crate::asc_client::dex_container::{into_logical_dex_buffers, iter_logical_dex_buffers};
use crate::asc_core::findrefs::findrefs_manager::FindRefInput;
use crate::asc_core::findrefs::scan::method_id_scan::render_dex_methods;

fn _inflate_and_hit(
mm: &[u8],
Expand Down Expand Up @@ -242,6 +243,58 @@ impl ApkHandler {
.filter(|prefix| prefix.is_ascii())
.map(str::as_bytes);

self._list_dex_items("class", |buf| {
let names = _list_classes(buf, prefix.as_deref(), prefix_bytes)?;
let count = names.len();
Ok((names, count))
})
}

/// List defined method signatures in DEX declaration order.
pub fn list_methods(
&self,
class: Option<&str>,
filter: Option<&str>,
) -> Result<Vec<String>, String> {
Ok(self
.list_method_chunks(class, filter)?
.iter()
.flat_map(|text| text.lines().map(str::to_owned))
.collect())
}

pub(crate) fn list_method_chunks(
&self,
class: Option<&str>,
filter: Option<&str>,
) -> Result<Vec<String>, String> {
let class = class
.map(str::trim)
.map(|name| {
if name.is_empty() {
return Err("Class name cannot be empty".to_owned());
}
Ok(if name.starts_with('L') && name.ends_with(';') {
name.to_owned()
} else {
format!("L{};", name.replace('.', "/"))
})
})
.transpose()?;
let filter = filter.map(str::to_lowercase);
self._list_dex_items("method", |buf| {
let (text, count) = render_dex_methods(buf, class.as_deref(), filter.as_deref())?;
Ok((vec![text], count))
})
}

fn _list_dex_items<F>(&self, kind: &str, list: F) -> Result<Vec<String>, String>
where
F: Fn(&[u8]) -> Result<(Vec<String>, usize), String> + Sync,
{
if self.max_workers == 0 {
return Err("Worker count must be greater than zero".to_owned());
}
let t_start = Instant::now();
let mm = self._open_apk()?;
let entries = _parse_entries(&mm);
Expand All @@ -250,61 +303,51 @@ impl ApkHandler {
}

let workers = entries.len().min(self.max_workers);
let entries = Arc::new(entries);
let work_queue = Arc::new(Mutex::new(0usize));
let results = Arc::new(Mutex::new(vec![None; entries.len()]));
let next = AtomicUsize::new(0);
let results = Mutex::new(vec![None; entries.len()]);
thread::scope(|scope| {
for _ in 0..workers {
let entries = Arc::clone(&entries);
let work_queue = Arc::clone(&work_queue);
let results = Arc::clone(&results);
let prefix = prefix.as_deref();
let prefix_bytes = prefix_bytes;
let mm = &mm;
scope.spawn(move || loop {
let index = {
let mut next = work_queue.lock().unwrap();
if *next >= entries.len() {
return;
}
let index = *next;
*next += 1;
index
};
let entry = &entries[index];
let result = _load_dex(mm, entry, None).and_then(|data| {
let data = data.ok_or_else(|| "inflate stopped".to_owned())?;
let mut names = Vec::new();
for (_dex_name, dex_buf) in iter_logical_dex_buffers(&entry.name, &data) {
names.extend(_list_classes(&dex_buf, prefix, prefix_bytes)?);
}
Ok((entry.name.clone(), names))
});
results.lock().unwrap()[index] = Some(result);
scope.spawn(|| {
loop {
let index = next.fetch_add(1, Ordering::Relaxed);
let Some(entry) = entries.get(index) else {
break;
};
let result = _load_dex(&mm, entry, None).and_then(|data| {
let data = data.ok_or_else(|| "inflate stopped".to_owned())?;
let mut items = Vec::new();
let mut count = 0;
for (_, dex_buf) in into_logical_dex_buffers(&entry.name, data) {
let (dex_items, dex_count) = list(&dex_buf)?;
items.extend(dex_items);
count += dex_count;
}
Ok((items, count))
});
results.lock().unwrap()[index] = Some(result);
}
});
}
});

let mut names = Vec::new();
let mut results = results.lock().unwrap();
for result in results.iter_mut() {
let (dex_name, dex_names) = result
.take()
.ok_or_else(|| "listclass worker did not return a result".to_owned())??;
names.extend(dex_names.iter().cloned());
self._log(&format!(
"[APK] '{dex_name}' class_count={}",
dex_names.len()
));
let mut items = Vec::new();
let mut total_count = 0;
for (entry, result) in entries.iter().zip(results.into_inner().unwrap()) {
let (dex_items, count) =
result.ok_or_else(|| format!("list{kind} worker did not return a result"))??;
if self.debug {
self._log(&format!("[APK] '{}' {kind}_count={count}", entry.name));
}
items.extend(dex_items);
total_count += count;
}
if self.debug {
self._log(&format!(
"[APK] listclass total={:.2} us count={} workers={workers}",
"[APK] list{kind} total={:.2} us count={total_count} workers={workers}",
t_start.elapsed().as_secs_f64() * 1_000_000.0,
names.len(),
));
}
Ok(names)
Ok(items)
}

pub fn for_each_findrefs(
Expand Down
76 changes: 75 additions & 1 deletion src/asc_core/findrefs/locator/string_locator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,30 @@

use crate::asc_core::findrefs::locator::base_locator::{_debug_log, BaseLocator};
use crate::asc_core::utils::structs::_STRUCT_I;
use crate::asc_core::utils::tinydex::DEX;
use crate::asc_core::utils::tinydex::{DEX, _decode_mutf8_fallback};
use memchr::memmem::Finder;
use std::collections::HashSet;
use std::time::Instant;

fn matches_name(raw: &[u8], query: &str) -> bool {
if query.is_empty() {
return true;
}
if !query.is_ascii() || !raw.is_ascii() {
return _decode_mutf8_fallback(raw).to_lowercase().contains(query);
}
let pattern = query.as_bytes();
memchr::memchr2_iter(
pattern[0].to_ascii_lowercase(),
pattern[0].to_ascii_uppercase(),
raw,
)
.any(|start| {
raw.get(start..start + pattern.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(pattern))
})
}

pub struct StringLocator<'a> {
pub base: BaseLocator<'a>,
pub stridx_map: Vec<u64>, // [(string_data_off << 32) | string_idx, ...]
Expand Down Expand Up @@ -150,6 +169,61 @@ impl<'a> StringLocator<'a> {
located_idx
}

/// Match each distinct method-name index once, regardless of physical string order.
pub(crate) fn locate_name_indices(
buf: &[u8],
name_indices: impl Iterator<Item = u32>,
query: &str,
) -> Result<HashSet<u32>, String> {
if buf.len() < 0x70 {
return Err("invalid DEX header".to_owned());
}
let count = _STRUCT_I::unpack_from(buf, 0x38) as usize;
let offset = _STRUCT_I::unpack_from(buf, 0x3c) as usize;
let end = count
.checked_mul(4)
.and_then(|size| offset.checked_add(size))
.ok_or("DEX string table overflow")?;
if end > buf.len() {
return Err("DEX string table outside input".to_owned());
}
let query = query.to_lowercase();
let mut seen = vec![false; count];
let mut matches = HashSet::new();
for index in name_indices {
let index_usize = index as usize;
let visited = seen
.get_mut(index_usize)
.ok_or("DEX string index out of range")?;
if *visited {
continue;
}
*visited = true;
let data = _STRUCT_I::unpack_from(buf, offset + index_usize * 4) as usize;
let raw = Self::string_data_bytes(buf, data)?;
if matches_name(raw, &query) {
matches.insert(index);
}
}
Ok(matches)
}

pub(crate) fn string_data_bytes(buf: &[u8], mut offset: usize) -> Result<&[u8], String> {
for shift in (0..35).step_by(7) {
let byte = *buf.get(offset).ok_or("truncated DEX string length")?;
offset += 1;
if shift == 28 && byte > 0x0f {
return Err("DEX string length overflow".to_owned());
}
if byte & 0x80 == 0 {
let tail = &buf[offset..];
let end = memchr::memchr(0, tail).ok_or("unterminated DEX string")?;
return Ok(&tail[..end]);
}
}
Err("DEX string length overflow".to_owned())
}

pub fn locate(&self, string: &str) -> HashSet<u32> {
let t_start = if self.base.debug { Some(Instant::now()) } else { None };
// Note: _build_map needs &mut self but locate takes &self
Expand Down
Loading
Loading