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
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
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`, `listmethod`, `findrefs`, and `export`:
Thread control is consistent across `getclass`, `listclass`, `listmethod`, `liststring`, `findrefs`, and `export`:

- Use only `--thread <N>`.
- Put `--thread` immediately after the subcommand.
Expand Down Expand Up @@ -93,6 +93,11 @@ 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

# List string pool values, optionally filtered by substring.
rust_ASC liststring --thread 10 app.apk
rust_ASC liststring -f https app.apk
rust_ASC liststring app.apk -o strings.txt

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

Expand All @@ -102,14 +107,18 @@ 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.

`liststring` prints one string pool value per line, read straight from the DEX string table rather than from class or method data: `findrefs string` reports who references a literal, `liststring` reports which literals exist, including the ones nothing references, so an empty `findrefs` result means no reference rather than not present. `--filter` / `-f` matches a case insensitive substring anywhere in the value and uses the same predicate as `findrefs string`, so a listed string and a located string agree on what matches. Order is DEX central-directory order followed by string index, which is the index a `findrefs` row resolves through; duplicates across DEX files are preserved. Like `listclass`, `-o` writes the list instead of printing it, and no matches produce an empty list. A pool holds arbitrary payloads, so a value that contains a newline spans several lines here, exactly as a `findrefs` matched payload does; `-o` uses the same framing.

`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`, `listmethod`, `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`, `liststring`, `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.

The `liststring` tool accepts `apk_path`, optional `filter`, `thread`, and `output`, with the same stdout and `output` semantics.

Configure it in your MCP client:

```json
Expand Down
64 changes: 64 additions & 0 deletions src/asc_client/apk/dex_reader.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Auth: MG1937
// Strict 1:1 translation from Python apk/dex_reader.py

use crate::asc_core::findrefs::locator::string_locator::matches_name;
use crate::asc_core::utils::structs::_STRUCT_I;
use crate::asc_core::utils::tinydex::_decode_mutf8_fallback;

Expand Down Expand Up @@ -398,3 +399,66 @@ pub(crate) fn list_dex_classes(
}
Ok(names)
}

/// Walk the string pool in table order, handing every value that passes
/// `filter` to `sink`. String index is what a `findrefs` row resolves through,
/// so table order is the only order this reports.
fn for_each_dex_string<S>(buf: &[u8], filter: Option<&str>, mut sink: S) -> Result<usize, String>
where
S: FnMut(&str) -> Result<(), String>,
{
if buf.len() < 0x70 || buf[..3] != *b"dex" {
return Err("invalid DEX header".to_owned());
}

let string_ids_size = _STRUCT_I::unpack_from(buf, 0x38) as usize;
let string_ids_off = _STRUCT_I::unpack_from(buf, 0x3C) as usize;
if string_ids_size == 0 {
return Ok(0);
}
let ids_end = string_ids_off
.checked_add(string_ids_size.saturating_mul(4))
.filter(|&end| end <= buf.len())
.ok_or_else(|| "bad string_ids range".to_owned())?;

let mut count = 0;
let mut off = string_ids_off;
while off < ids_end {
let string_off = _STRUCT_I::unpack_from(buf, off) as usize;
off += 4;
if string_off >= buf.len() {
return Err("bad string_data_off".to_owned());
}
let raw = _read_string_data_bytes(buf, string_off)?;
if !filter.is_none_or(|filter| matches_name(raw, filter)) {
continue;
}
sink(&_decode_name(raw))?;
count += 1;
}
Ok(count)
}

/// Write string values one per line, skipping the ones `filter` rejects.
/// `list_dex_strings` is the record level API that keeps embedded newlines.
pub(crate) fn write_dex_strings(
buf: &[u8],
filter: Option<&str>,
out: &mut Vec<u8>,
) -> Result<usize, String> {
for_each_dex_string(buf, filter, |value| {
out.reserve(value.len() + 1);
out.extend_from_slice(value.as_bytes());
out.push(b'\n');
Ok(())
})
}

pub(crate) fn list_dex_strings(buf: &[u8], filter: Option<&str>) -> Result<Vec<String>, String> {
let mut values = Vec::new();
for_each_dex_string(buf, filter, |value| {
values.push(value.to_owned());
Ok(())
})?;
Ok(values)
}
47 changes: 46 additions & 1 deletion src/asc_client/apk_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ use memmap2::{Mmap, MmapOptions};

use crate::asc_client::apk::dex_reader::{
_dex_defines_class as _defines_class, class_defines_prefix_len, class_names_prefix_len,
list_dex_classes, write_dex_class_names as _write_classes,
list_dex_classes, list_dex_strings, write_dex_class_names as _write_classes,
write_dex_strings as _write_strings,
};
use crate::asc_client::apk::loader::{
_inflate_dex as _load_dex, _inflate_dex_into, _inflate_dex_into_with,
Expand Down Expand Up @@ -630,6 +631,50 @@ impl ApkHandler {
)
}

/// List string pool values as individual records, preserving the row API.
/// CLI consumers can use `for_each_string_chunk` to avoid collecting rows.
pub fn list_strings(&self, filter: Option<&str>) -> Result<Vec<String>, String> {
let filter = filter.map(str::to_lowercase);
let mut rows = Vec::new();
self._stream_dex_items(
"string",
|buf| {
// One record per value: a pool can hold strings with newlines,
// and the row API must not reframe them as CLI lines.
let values = list_dex_strings(buf, filter.as_deref())?;
let count = values.len();
Ok((values.into_iter().map(String::into_bytes).collect(), count))
},
// Every string is potentially needed, so entries inflate in full.
None,
|bytes| {
let value = String::from_utf8(bytes)
.map_err(|error| format!("invalid UTF-8 string value: {error}"))?;
rows.push(value);
Ok(())
},
)?;
Ok(rows)
}

/// Stream string values (one per line) in DEX table order.
pub fn for_each_string_chunk<E>(&self, filter: Option<&str>, mut emit: E) -> Result<usize, String>
where
E: FnMut(&[u8]) -> Result<(), String>,
{
let filter = filter.map(str::to_lowercase);
self._stream_dex_items(
"string",
|buf| {
let mut out = Vec::new();
let count = _write_strings(buf, filter.as_deref(), &mut out)?;
Ok((vec![out], count))
},
None,
|chunk| emit(&chunk),
)
}

pub fn for_each_findrefs(
&self,
find_type: &str,
Expand Down
5 changes: 4 additions & 1 deletion src/asc_core/findrefs/locator/string_locator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ use memchr::memmem::Finder;
use std::collections::HashSet;
use std::time::Instant;

fn matches_name(raw: &[u8], query: &str) -> bool {
/// Case insensitive substring match over a raw string_data_item payload.
/// Shared with `liststring` so both agree on what the filter matches; `query`
/// is expected to be lowercased by the caller.
pub(crate) fn matches_name(raw: &[u8], query: &str) -> bool {
if query.is_empty() {
return true;
}
Expand Down
50 changes: 50 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,37 @@ fn _handle_listmethod(args: &ArgMatches) -> Result<(), String> {
Ok(())
}

fn _handle_liststring(args: &ArgMatches) -> Result<(), String> {
let t_start = Instant::now();
let apk_path = args.get_one::<String>("apk_path").unwrap();
let debug = args.get_flag("debug");
let thread = _get_worker_count(args)?;
let filter = args.get_one::<String>("filter").map(String::as_str);
let handler = ApkHandler::new(apk_path, debug, thread);
if let Some(output) = args.get_one::<String>("output") {
let mut file = File::create(output)
.map_err(|error| format!("cannot write {output}: {error}"))?;
handler.for_each_string_chunk(filter, |chunk| {
file.write_all(chunk)
.map_err(|error| format!("cannot write {output}: {error}"))
})?;
} else {
let stdout = io::stdout();
let mut out = stdout.lock();
handler.for_each_string_chunk(filter, |chunk| {
out.write_all(chunk)
.map_err(|error| format!("cannot write stdout: {error}"))
})?;
}
if debug {
println!(
"[DEBUG] Total Execution Time: {:.2} us",
t_start.elapsed().as_secs_f64() * 1_000_000.0
);
}
Ok(())
}

fn _export_progress(progress: ExportProgress) {
match progress {
ExportProgress::Activity { .. } | ExportProgress::DexInflated { .. } => {}
Expand Down Expand Up @@ -434,6 +465,7 @@ pub fn _build_main_parser() -> Command {
rust_ASC getclass --thread 16 app.apk com.poc.Main\n \
rust_ASC listclass app.apk -o classes.txt\n \
rust_ASC listclass --prefix com.poc app.apk\n \
rust_ASC liststring app.apk -f https -o strings.txt\n \
rust_ASC export --thread 16 app.apk output\n \
rust_ASC getmanifest app.apk -o AndroidManifest.xml\n \
rust_ASC findrefs app.apk string token -o string_refs.txt\n \
Expand Down Expand Up @@ -497,6 +529,23 @@ pub fn _build_main_parser() -> Command {
.help("Write method descriptors to this file instead of stdout."),
),
))
.subcommand(common_args(
Command::new("liststring")
.about("List string pool values across all DEX entries; use --filter to match a substring.")
.arg(Arg::new("apk_path").required(true).help("Path to the input APK file."))
.arg(
Arg::new("filter")
.short('f')
.long("filter")
.help("Case insensitive substring filter over string values."),
)
.arg(
Arg::new("output")
.short('o')
.long("output")
.help("Write string values to this file instead of stdout."),
),
))
.subcommand(common_args(
Command::new("export")
.about("Export pseudocode for every class in an APK into an output directory.")
Expand Down Expand Up @@ -544,6 +593,7 @@ where
Some(("getclass", args)) => _handle_getclass(args),
Some(("listclass", args)) => _handle_listclass(args),
Some(("listmethod", args)) => _handle_listmethod(args),
Some(("liststring", args)) => _handle_liststring(args),
Some(("export", args)) => _handle_export(args),
Some(("getmanifest", args)) => _handle_getmanifest(args),
Some(("findrefs", args)) => _handle_findrefs(args),
Expand Down
30 changes: 30 additions & 0 deletions src/mcp/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ struct ListMethod {
output: Option<String>,
}

#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ListString {
/// Path to the APK to analyze.
apk_path: String,
/// Optional case insensitive substring filter over string values.
filter: Option<String>,
/// Positive worker count. Defaults to available CPU count.
thread: Option<usize>,
/// Optional string list output file. When set, stdout is empty. Existing files are overwritten.
output: Option<String>,
}

#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct GetManifest {
Expand Down Expand Up @@ -127,6 +140,10 @@ pub(super) fn tools() -> Vec<Tool> {
"listmethod",
"List defined method descriptors from every DEX in an APK.",
),
tool::<ListString>(
"liststring",
"List string pool values from every DEX in an APK.",
),
tool::<GetManifest>(
"getmanifest",
"Decode AndroidManifest.xml to formatted XML.",
Expand All @@ -148,6 +165,7 @@ impl Query {
"getclass" => getclass(serde_json::from_value(args).map_err(invalid_args)?),
"listclass" => listclass(serde_json::from_value(args).map_err(invalid_args)?),
"listmethod" => listmethod(serde_json::from_value(args).map_err(invalid_args)?),
"liststring" => liststring(serde_json::from_value(args).map_err(invalid_args)?),
"getmanifest" => getmanifest(serde_json::from_value(args).map_err(invalid_args)?),
"findrefs" => findrefs(serde_json::from_value(args).map_err(invalid_args)?),
_ => return Err("unknown tool".to_owned()),
Expand Down Expand Up @@ -203,6 +221,18 @@ fn listmethod(input: ListMethod) -> Vec<OsString> {
args
}

fn liststring(input: ListString) -> Vec<OsString> {
let mut args = vec!["liststring".into()];
push_thread(&mut args, input.thread);
if let Some(filter) = input.filter {
args.push(format!("--filter={filter}").into());
}
push_output(&mut args, input.output);
args.push("--".into());
args.push(input.apk_path.into());
args
}

fn getmanifest(input: GetManifest) -> Vec<OsString> {
let mut args = vec!["getmanifest".into(), input.apk_path.into()];
push_output(&mut args, input.output);
Expand Down
33 changes: 33 additions & 0 deletions tests/dex_fixture/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -525,3 +525,36 @@ pub fn make_dex041_container() -> Vec<u8> {
put_u32(&mut data, second_off + 0x70, total);
data
}

/// A DEX whose string pool holds arbitrary values and defines no classes.
///
/// `liststring` walks the string table rather than `class_defs`, so its fixture
/// needs values that are not class descriptors: resource URLs, SQL, UTF-8 text.
pub fn make_strings_dex(values: &[&str]) -> Vec<u8> {
let header_size = 0x70;
let mut buf = vec![0; header_size];
let string_ids = buf.len();
buf.resize(buf.len() + 4 * values.len(), 0);
let data_off = buf.len();

for (index, value) in values.iter().enumerate() {
let string_off = buf.len();
put_u32(&mut buf, string_ids + index * 4, string_off as u32);
// The leading uleb128 stores UTF-16 code units, not byte length.
buf.extend_from_slice(&uleb(value.encode_utf16().count()));
buf.extend_from_slice(value.as_bytes());
buf.push(0);
}

let file_size = buf.len();
buf[..4].copy_from_slice(b"dex\n");
buf[4..7].copy_from_slice(b"035");
put_u32(&mut buf, 0x20, file_size as u32);
put_u32(&mut buf, 0x24, header_size as u32);
// Only the string section is populated; the other id tables stay zero.
put_u32(&mut buf, 0x38, values.len() as u32);
put_u32(&mut buf, 0x3c, string_ids as u32);
put_u32(&mut buf, 0x68, (file_size - data_off) as u32);
put_u32(&mut buf, 0x6c, data_off as u32);
buf
}
Loading
Loading