jvm android - 1-3-7/disrobe GitHub Wiki

JVM and Android

disrobe decompiles JVM classfiles and Android DEX through a unified command, adding protector analysis, ProGuard/R8 mapping reports, and chain auto-detection, with headless wrappers for FOSS decompilers selected by the format's default routing or --backend.

At a glance

Surface Support
Inputs .class, .jar, .dex, .apk, .aab; ODEX, single-DEX OAT, JDK .jmod and lib/modules jimage containers; the classfile itself validated in-house (format 1.0.2-25)
Decompilers In-house classfile and Dalvik decompilers, the Dalvik one default on .dex and .apk; CFR, Vineflower, Procyon, JADX, and others via --backend
Language surface Records, sealed types, pattern matching, enum constant bodies, declaration and member annotations, enhanced for, multi-catch, plus Kotlin and Scala idioms
Obfuscator handling String recovery for supported Zelix KlassMaster, Allatori, Stringer, and DashO patterns; JBCO and Skidfuscator flow obfuscation folded before structuring; DexGuard and BlackObfuscator control-flow analysis; ProGuard/R8 name reports from mapping.txt
Detection and routing roster (10) ProGuard/R8, Zelix KlassMaster, Allatori, Stringer, DashO, DexGuard, BlackObfuscator, yGuard, SkidSuite2, JBCO (the last three have no protector-specific pass)
RASP vendors (8) Promon SHIELD, Guardsquare DexGuard RASP and ThreatCast, Appdome, OneSpan, Arxan/Digital.ai, Zimperium zShield, Licel DexProtector. No command runs this detector yet; behavior and auto report the runtime markers of Promon, Appdome, Zimperium, Guardsquare, and DexProtector
Signatures v1 signing-material inventory; v2, v3, and v3.1 content-digest verification; v4 .idsig parsing with APK-digest matching to v2 or v3

Commands

disrobe jvm decompile App.class --emit source --out src/          # write the in-house Java source
disrobe jvm decompile app.jar --backend vineflower --out src/
disrobe jvm dex2-jar classes.dex --out classes/  # in-house .class tree + classes.jar
disrobe jvm decompile classes.dex --backend dex2-jar --out external/  # installed d2j-dex2jar
disrobe jvm decompile app.apk --backend jadx --out src/
disrobe jvm decompile classes.dex --backend jadx --out src/
disrobe jvm decompile classes.dex --format ghidra --out recovered/
disrobe jvm decompile app.jar --mapping mapping.txt --out src/   # write name-restoration.json
disrobe jvm extract app.apk --out classes/    # extract a .jar / .apk + dump classfile inventory
disrobe jvm backends                          # report available JVM/Android backends on PATH
disrobe jvm jni app.apk                       # link native methods against the apk's own .so files
disrobe jvm jni App.class --native libnative.so --json
disrobe jvm jni module.aar                    # nested classes.jar against jni/<abi>/*.so
disrobe jvm jni base.apk --native split.apk    # cross-split link against an APK Set member
disrobe jvm callgraph app.apk --emit dot      # DEX call graph, one edge per caller-to-callee pair
disrobe apk app.apk                           # resources, certificates, the JNI link table and the code-pattern audit
disrobe auto app.apk --out recovered/         # recursively process recognized payloads
disrobe auto classes.dex --format json --out recovered/

Every format writes in-house source by default: Dalvik source for .dex and .apk, per-class source for .jar and .class. An external backend runs only when --backend names it: cfr, vineflower, procyon, jd or krakatau for class and jar files, jadx or dex2-jar for DEX and APK input. A named backend that is not installed, or that does not apply to the input format, fails with an error. disrobe validates the classfile itself (format 1.0.2-25) and recovers records, sealed types, and pattern matching where the selected backend supports them, plus Kotlin and Scala idioms.

disrobe jvm dex2-jar is the in-house DEX-to-class translator. It writes a deterministic class tree and classes.jar without an external executable. disrobe jvm decompile <dex> --backend dex2-jar is different: it invokes an installed d2j-dex2jar backend and keeps that backend's contract and output separate from the in-house translator.

The in-house translator carries InnerClasses, EnclosingMethod, and generic Signature attributes from DEX system annotations. The Dalvik renderer uses ownership metadata to group nested classes; a $ in a name alone does not establish nesting. The Dalvik decompiler also writes the generic types the Signature annotations carry: class type parameters and parameterized supertypes, field types, and method parameter and return types, each validated against the erased descriptor and with desugared core-library names projected back (j$.util to java.util). A method or field whose generic type names a type variable keeps the erased form, because the lifter types locals from descriptors and a raw Iterator flowing into a Predicate<? super T> would not compile; signatures with method-level type parameters are kept erased for the same reason. On the EdgeCases corpus this took the whole-tree javac error count from 10 to 5. dalvik.annotation.Throws becomes the method's throws clause. D8 backport helpers (List.of, Map.of, Objects.checkIndex, String.repeat, boxed hashCode, Math.floorMod and the rest of a 44-entry catalog in crates/disrobe-pass-jvm/data/d8_backports.json) are recognised by a fingerprint of the helper body (instruction mnemonics, literals and referenced members with the helper class normalised) and their call sites are written as the JDK call; a helper class whose every method is recognised is dropped from the output, otherwise only its unrecognised methods remain. The catalog is derived from an authored probe compiled with d8 9.1.31 (tests/fixtures/d8_backports/), and tests/lifting/d8_backport_recovery.rs pins the catalog to the probe, recompiles the recovered probe and requires the authored program's output. A lambda helper that stays declared is written as synthLambda$…, because a declared lambda$main$0 collides with the synthetic javac generates for the recovered lambda and the recompiled class fails to load. Invalid annotation metadata is reported without rejecting an otherwise parseable DEX.

For a standalone DEX, jvm decompile --format ghidra|ida|json writes descriptor-keyed class, method, and field entries beside the normal output. auto accepts the same formats when the original input itself reaches a successful jvm.classify node and writes the sidecar under exports/dalvik/. It does not reuse a DEX extracted from an APK or another container. Each entry keeps the original owner, original member name, descriptor, and replacement as separate values. When the current recovery path has not changed a name, the original and replacement stay identical. The Ghidra and IDAPython scripts resolve the original logical identity in the loaded DEX database before renaming the database object; they do not assign virtual addresses to Dalvik identifiers. IDAPython class renames require IDA 9.4 or later because that release introduced the loader's persistent DEX type-rename store. Method and field renames use the older DEX record APIs.

Coverage and fidelity

Classfile

The in-house classfile decompiler is gated against real javac: on the EdgeCases corpus, the asserted floor is 131 of 131 decompiled methods (100%) recompiling error-free on JDK 25. Its emitted source carries enum types with their constant bodies, declaration and member annotations, enhanced-for loops over arrays and iterables, and multi-catch clauses, each recompile-checked on the same corpus (foreach_multicatch_recovery.rs). CI provisions a JDK so this gate runs there.

Dalvik

The Dalvik lifter's recovered bodies are graded by the real JVM bytecode verifier rather than by the lifter's own output: a committed gate assembles the recovered classes from the committed dex corpus, loads them under -Xverify:all, and asserts that the recovered classes pass the verifier; 100% of verifier-presented classes pass (118 of 118, 0 lifter verify failures; the other 37 of 155 classes are link-skipped because they reference supertypes the harness does not bundle, Kotlin's Function1 among them, which is a test-harness limit and not a lifter defect). A live-range-splitting pass recovers method bodies whose registers carry conflicting JVM types across control-flow joins; 317 re-hosted bodies verify clean under the same gate. The committed-corpus verifier floor and the EdgeCases recompile floor are asserted by committed test gates.

The in-house Dalvik decompiler, the default for disrobe jvm decompile on .dex and .apk, is graded on the same corpus against the real EdgeCases.java source rather than its own output. A value computed in one basic block and consumed in another (an array length, or a wide argument to a call such as Math.abs or charAt) is materialized into a local at its real use site instead of being dropped across the block boundary, so all eight leaf methods reconstruct their call sites with full fidelity while every method's signature, control flow, and operators recover (dalvik_decompile_oracle.rs).

The same source path reverses core-library desugaring emitted by D8 9.1.31 with desugar_jdk_libs_configuration 2.1.5. It restores marker-confirmed public API types in the j$/time, j$/util, and j$/nio namespaces, receiver-first $-EL calls, $-CC interface static calls, and exact supported Desugar* retarget helpers. The committed minimum-API-21 DEX covers time, streams, functions, Optional, concurrent, and NIO APIs. A minimum-API-34 DEX built from the same Java source provides the original call-shape reference. The test recompiles every recovered compilation unit with Java 11, then executes every recovered API probe method through an independent harness. Unknown configuration identifiers, application-owned j$/ classes, wrapper conversions, API flips, unknown helpers, and malformed receiver shapes remain unreversed instead of being renamed by prefix.

D8's backport synthetics hold several m methods whose parameter lists are equal and whose return types differ, which the DEX allows and Java source cannot express. When a class declares two such methods, the decompiler names each after its return type (m$int, m$Comparator, m$List) in both the declaration and every call site, so the emitted tree declares each overload once; the names are deterministic and carry a numeric suffix only when two return types render to the same word. A group that contains a bridge method keeps its name, because Java derives covariant bridges from the declared return type. The whole-tree javac pin in crates/disrobe-cli/tests/jvm_dex_sources.rs records the remaining error count for the D8 fixture and only ratchets down.

An ACC_ENUM class whose static initializer only allocates its own constants is written as an enum declaration: the constants in ordinal order with the constructor arguments that follow the name and ordinal, the $VALUES field and the values, valueOf and $values synthetics dropped, and the constructor without its name and ordinal parameters and its Enum super call. A constant allocated through an anonymous subclass of the enum becomes a constant with a class body: the subclass's methods move under the constant, its constructors and the synthetic accessor constructor of the enum are dropped, static references through the subclass are rewritten to the enum, and the subclass disappears from the emitted tree, so an enum with an abstract method or a per-constant override is a legal enum again. Any other work in the initializer keeps the class form, because an explicit extends Enum is not legal Java. tests/lifting/d8_enum_recovery.rs and tests/lifting/d8_enum_bodies.rs recompile the recovered EnumProbe and EnumBodies fixtures and require the authored programs' output.

When a DEX keeps its debug_info_item local tables (a d8 --debug build of javac -g output), the decompiler names each recovered local after the table entry that covers the register at its definition, as it already does for parameters; a name is used only when every definition of the variable falls under the same entry, the name is a writable Java identifier and no parameter or earlier local carries it. Release builds carry no tables and keep the synthetic varN names. tests/lifting/debug_local_names.rs grades the debug and release builds of the same fixture.

disrobe jvm decompile --include GLOB --exclude GLOB limits the per-class source tree to the units whose binary class name matches: * matches within a package segment, ** across segments, and . or / may separate packages. Nest members live inside their top-level unit, so a glob names top-level classes; the combined listing is always written in full. manifest.json records the globs with the written and skipped unit counts under class_filter; crates/disrobe-cli/tests/jvm_dex_sources.rs grades both directions on the D8 fixture.

Two loop shapes are rewritten after lifting: a counter initialised to zero, tested against a bound at the head and stepped by one at the end of the body becomes for (i = 0; i < n; i++), with constant assignments the compiler hoisted into the loop header moved in front of the loop; when the body starts by reading array[i] into a local, the bound is that array's length and neither the counter nor the bound is used anywhere else, the loop becomes for (T element : array) and the indexed reads in the body use the element. A loop whose body continues or reassigns the counter keeps the while form. An iterator walk, it = coll.iterator() followed by while (it.hasNext()) whose body starts by taking it.next() and never touches the iterator again, becomes for (T x : coll); when the element was cast on the way in, the loop variable is the raw Object and the cast stays as the first statement, because the collection's element type is not known from the DEX. A bare return; that ends a method body is dropped, since Java falls off the end of a void body on its own; a return; inside a branch stays. An if/else whose two branches each hold one assignment to the same local, or one return each, becomes a conditional expression (x = (c ? a : b);, return (c ? a : b);); branches that allocate, nest a conditional or do anything else keep the statement form. The D8 lowering of try-with-resources (a resource assigned before a try, a catch (Throwable) that closes it under its own guard, suppresses the close failure through addSuppressed or its backport, rethrows, and a guarded close() after the try) is folded back into try (T r = init) { ... } when the resource is not touched after the close; the recompile oracles and the EdgeCases javac pin fail on any fold the compiler rejects. The unit tests in dalvik_decompile/loops.rs cover each shape, and the recompile oracles fail on any rewrite that changes the program.

disrobe jvm callgraph walks every method body in a DEX or APK and records one edge per caller-to-callee pair, with the number of call sites behind it, and one node per method that has a body or is called; a callee that no class in the DEX defines is marked external. The output is JSON or Graphviz DOT. tests/dalvik_call_graph.rs requires the edge set on the plants APK to equal androguard's cg output (corpus/recon/android-audit/reference/plants.androguard-cg.gml, 77 edges) and the external node count to match its 57. disrobe behavior walks the same graph for source and sink API pairs reachable from one method (callgraph evidence); crates/disrobe-cli/tests/behavior_android.rs requires the five flow-fixture methods that reach both, including the one with no data flow between them, and none from the control build.

Containers

auto opens four container formats and passes their members to the class and DEX passes. identify, jvm jni and apk also take an APK Set (.apks), an XAPK or an APKM bundle (a zip of a base APK and its split APKs); identify reports the format apk-bundle, the member count and the base member, with the signing schemes read from the base APK itself. The base member is the one named base.apk or base-master.apk, otherwise the first member whose manifest carries no split attribute and holds code; its manifest, resources and signer certificates stand for the application, while the DEX files and lib/ trees of every member are merged for the audit and the JNI link table, so a native library shipped in a configuration split resolves against classes in the base. corpus/recon/android-audit/bundles/ carries an authored pair built from the audit fixtures, graded by the CLI apk and jni tests. An ODEX yields its embedded classes.dex. An OAT file yields its single DEX, found through the oatdata symbol and the OAT header; when the DEX lives in a separate .vdex, which is the usual layout on current ART, the OAT file yields only its manifest and a refusal, because VDEX is not read. A .jmod yields every entry at its archive path, and a jimage (lib/modules) yields every stored resource as module/parent/name; compressed jimage resources are refused rather than emitted, because no jimage decompressor is implemented. Each container also writes jvm-container.json. The jmod reader shares the zip limits (65,536 entries, 256 MiB per entry, 1 GiB in total) and the jimage reader enforces the same totals. tests/jvm_container_chain.rs grades a committed jmod against jmod extract and the grading JDK's own lib/modules against jimage extract, byte for byte.

Obfuscator analysis and recovery

For JVM classfiles, disrobe peels supported protector string patterns before in-house source emission. On Dalvik, it lifts a DexGuard-style flattened method from the rewired control-flow graph when every dispatcher edge resolves to a constant successor and the state register is read only by its dispatcher; otherwise the method keeps a note naming why it was not rewired. BlackObfuscator dispatchers are analyzed. --mapping parses ProGuard/R8 mappings and writes recovered class and member names to name-restoration.json; it does not rewrite emitted Java source. disrobe registers detection and routing for 10 obfuscator and protector families: ProGuard/R8, Zelix KlassMaster, Allatori, Stringer, DashO, DexGuard, BlackObfuscator, yGuard, SkidSuite2, and JBCO (the last three identified by marker strings and, for JBCO, its jsr/ret control flow, with no protector-specific pass).

Flow obfuscation is folded before structuring. A method qualifies when its class tests a static Boolean it only ever initializes to a constant (the opaque predicate JBCO plants), when it contains unreachable code or a typed handler that only rethrows, or when a branch depends on a static int function of constants (the per-block seeds Skidfuscator advances through hash functions). For such a method disrobe runs a bounded constant-flow analysis: it evaluates the pure static int functions of the classes it was given and Integer.parseInt of decimal constants, folds every branch and switch whose operands it decides, removes typed handlers that only rethrow what they caught, turns an exception allocated and thrown only to reach its own handler into a jump, and drops the code left unreachable. Catch-all handlers, which javac emits for finally, are kept, and a method that reads a local it never assigns is left as it is.

On the Android side it also applies eight runtime application self-protection (RASP) fingerprint rules: Promon SHIELD, Guardsquare DexGuard RASP and ThreatCast, Appdome, OneSpan, Arxan/Digital.ai, Zimperium zShield, and Licel DexProtector. For APK signatures, disrobe inventories v1 signing material, verifies v2, v3, and v3.1 content digests, and parses v4 .idsig files to match their APK digest to a computed v2 or v3 content digest.

String-encryption protectors are decrypted by emulating each class's decrypt method on the encrypted constants rather than by guessing a key, so Allatori and DashO style decrypt(String) and decrypt(int, String) routines are recovered statically. The evaluator runs <clinit> for a per-class static key, constructs the receiver and runs <init> for an instance decrypt keyed on a field, synthesizes the calling frame for a caller/stack-trace-keyed key, and covers the long-accumulator arithmetic, getfield/putfield, and switch-dispatched keystreams these routines emit, all under a hard step cap. With disrobe jvm decompile --mapping mapping.txt, ProGuard and R8 names are written to name-restoration.json, with overloaded methods disambiguated by descriptor argument count; emitted Java identifiers are unchanged.

On the Android side, the BlackObfuscator analyzer recognizes the String.hashCode() keyed dispatcher, matches each block's const-string block-name to its switch case, and reports the recovered linear block order in the decompiled output. The method body remains rendered from the flattened graph; the annotation records the recovered order rather than replacing that body. Separately, obfuscator-planted out-of-range exception-table entries are dropped before classfile structuring so they cannot poison the control-flow graph, and jsr/ret subroutines are inlined into a linear stream.

DexGuard hides string constants in an encrypted static String[] decrypted at run time through java.lang.reflect.Method.invoke rather than a direct call. The key and ciphertext are present in the dex; only the dispatch is reflective. disrobe runs a constrained Dalvik register machine over the dex's own routine: it executes the class <clinit> to rebuild the encrypted table, then runs the decrypt(int) body for each index (read the table element, apply the per-char transform against the embedded key, rebuild the string) and emits the plaintext, with the Class.getDeclaredMethod + Method.invoke call sites resolved to their concrete target. disrobe jvm decompile app.dex surfaces the recovered strings and resolved sites in the manifest.

Data flows

disrobe taint lifts a DEX to the normalized IR with the Dalvik registers as named values, so a value is tracked from the call that produced it through moves, constants and intermediate calls to the call that consumes it, and a method that merely calls a source and later a sink without passing the value is not reported. The Android source and sink sets are the defaults for JVM and Dalvik inputs (disrobe taint in the CLI reference lists them). crates/disrobe-cli/tests/taint_android.rs grades the authored audit-flows.apk: four methods with a real flow (IMEI to SMS, IMSI to Log, location through String.valueOf to Log, clipboard to SMS) are reported and the two methods without one are not, and the plant APK, whose methods each hold a single API call, yields no flow.

Anti-analysis checks and their neutralization

The code-pattern audit anchors the anti-analysis checks an APK carries (Debug.isDebuggerConnected, a Build property compared against an emulator literal, a root-indicator path probed through java.io.File, su launched through Runtime.exec), and disrobe apk --neutralize ask|apply|skip turns the first three into patch proposals the defender decides on, one by one or all at once. Each proposal replaces the three-unit invoke and its move-result with const/4 vN, #0 and three nops, so the check's boolean result is false and the instruction stream stays verifiable; a launched su process is refused rather than rewritten, and a result register beyond the const/4 range or a call whose result is not consumed by move-result is refused with the reason. The input APK is never modified: accepted patches go to a classes.neutralized.dex beside android-neutralization.json, which records the plan, the per-patch decision and the output path, and the defender repacks and re-signs. Without the flag nothing is proposed or written. tests/android_neutralize.rs grades the plan on the plant APK (three patches, one refusal, the patched DEX still decodes and no longer carries the debugger call) and its silence on the control build. disrobe auto runs the same audit on every APK it opens and writes the findings to android-audit.json beside the extracted DEX files when there is at least one; a clean application writes no sidecar. tests/auto_apk_dex.rs grades the sidecar on the plant APK (51 findings) and its absence on the control build.

The audit reads each method in execution order rather than file order, so unconditional goto chains and reordered blocks do not separate a constant from the call that consumes it, and a method whose only reachable instruction is return-void still matches the trust-manager shape. A static method that forwards all of its parameters, in order, to one call and returns that call's result is a forwarding wrapper: every call through it is audited as a call to the forwarded API, with the same receiver and parameter values, and the wrapper body itself is audited only when nothing calls it. A class whose static initializer fills a List with Class.getDeclaredMethod lookups is a reflection table: a static call that takes an index, a receiver and an argument array and belongs to that class resolves, when the index is a constant, to the table entry's class and method name, so the hidden API still meets its rule. Encrypted constants are recovered before the string rules run: the DEX string evaluator follows constants through basic blocks with a must-equal merge at joins, so a reordered or opaque-predicate caller still binds the literal to the decrypt call; a (String)String method that only forwards to a recognised decryptor is itself a decryptor, which covers call indirection over the decrypt helper; and the evaluator models SecretKeyFactory with PBKDF2 (HMAC-SHA1 and HMAC-SHA256, at most 100,000 iterations), SecretKeySpec, IvParameterSpec, Cipher for AES and DES in ECB and CBC with PKCS5 or no padding, MessageDigest for MD5, SHA-1 and SHA-256, and boxed Integer parsing, each refused by name outside that set. corpus/recon/android-audit/obfuscapk/ holds the plants APK after each of these transforms as produced by Obfuscapk (obfuscate.sh), and tests/android_audit_obfuscated.rs requires every rule to fire as often on the transformed build as on the plain one, with the obfuscator's own helpers reported under the rules they trigger (the decryptor's AES/ECB and embedded key, the reflection helpers' Method.invoke) and every encrypted call site recovered.

Native libraries inside the APK are audited too. For each lib/<abi>/*.so, the ELF dynamic imports and the file's literals anchor four rules: DR-APK-NATIVE-PTRACE on an imported ptrace, DR-APK-NATIVE-TRACER-PID on the TracerPid literal, DR-APK-NATIVE-FRIDA-CHECK on Frida indicator literals (frida-agent, frida-gadget, frida-server, re.frida.server, gum-js-loop, linjector, 27042) and DR-APK-NATIVE-MEMORY-WATCH on an imported inotify_add_watch beside /proc/self/mem or /proc/self/maps. A native finding names the library path as its site with no method or offset. corpus/recon/android-audit/audit-native.apk carries an authored libantidebug.so and a libclean.so for arm64-v8a and x86_64, built with zig cc from native/*.c; tests/android_audit.rs requires the four rules on both anti-debug builds and nothing on the control builds. Native checks have no neutralization proposal yet: the proposals cover the Dalvik checks only.

DEX to class translation

disrobe jvm dex2-jar translates every DEX method body back to JVM bytecode in-house. A static or super call whose owner is an interface is emitted as an InterfaceMethodref, which the JVM requires for interface static methods: the owner is known to be an interface from the DEX class definition flags, or, for platform classes the DEX cannot describe, from a table of the 1232 public java.* and javax.* interfaces of JDK 25 (data/jdk25-public-interfaces.txt). tests/dex2jar_execution.rs grades the translation by execution: the d8-built EdgeCases.dex (72 classes, 3624-unit main) is translated, written to a jar and run under java -Xverify:all beside the javac-built baseline jar, with one authored android.os.Build$VERSION stub on the classpath for D8's API-level desugaring, and the two programs must print the same 126 lines (map entries sorted on both sides because Map.copyOf salts its iteration order per JVM run). The kotlinc-built EdgeCasesKt.dex is graded the same way against EdgeCasesKt.jar with the Kotlin standard library and coroutines runtime on the classpath (74 lines, one elapsed-time figure stripped); it required two more translation rules, invoke-super emitted as invokespecial rather than a virtual call that recursed into the override, and the DEX static-value array carried into ConstantValue attributes so a static final double keeps its value. dex2jar 2.4 and enjarify, given the same input and classpath, both stop at the first static interface call with an IncompatibleClassChangeError after 28 lines. The translated Code attribute also carries a LineNumberTable from the DEX debug_info_item positions and, when the DEX kept its local tables, a LocalVariableTable with each local's name, descriptor and JVM slot; tests/formats/dex2jar_debug_tables.rs compares both with javap -c -l against the positions and locals parsed from the same DEX.

Identification

disrobe identify recognises APK, DEX, JAR and class inputs before the native identifier runs and reports, with evidence and a confidence, the DEX version and Android range, the DEX writer (d8/r8 when the data section carries code items, debug info, type lists and string data first and the map list last, the layout those writers produce; dexlib 2.x when string data and type lists lead the data section and code items precede class data, the layout the smali and apktool writer produces; any other layout is reported as an unknown writer rather than guessed), ProGuard or R8 renaming (at least half of the top-level classes carry one- or two-letter names), Obfuscapk from its DecryptString, ApiReflection and AdvancedApiReflection helper classes, the protector catalog matches on class files (Allatori, DashO, Zelix KlassMaster, Stringer, yGuard, SkidSuite2, JBCO), RASP vendors and the stub-loader packing scheme on APKs, commercial packer anchors from the mobile pass, the APK signature schemes with their verification result, and the anti-analysis checks the code-pattern audit anchors (debugger, emulator and root checks, dynamic code loading). tests/android_identify.rs grades the d8-built fixtures, the R8-minified and clean pair, the plant and control APKs, the six Obfuscapk builds (the writer on all six, the obfuscator only on the three that carry a helper class) and an Allatori class; a zip without a manifest, DEX or class files is not claimed.

APK resources and manifest

disrobe apk decodes the binary AndroidManifest.xml and resources.arsc in-house: the element tree with typed attribute values (references, strings, integers, booleans, hex, dimensions, fractions and colours), the resource table with ids, names and one value per configuration, and reconstructed R.txt, R.java and values.xml. tests/formats/aapt2_dump_parity.rs grades both decoders against committed aapt2 dump xmltree and aapt2 dump resources output (corpus/apk/aapt2/, build-tools 36.1) for the two aapt2-linked fixtures: every element, attribute and typed value of the manifests and every resource id, name and per-configuration value must be equal, and a control that changes one value in a dump turns the comparison red. Dimensions print as aapt2 does (16dp, 22sp), strings and file resources resolve through the global string pool, and an id resource is the boolean false aapt2 prints as (id).

The same command derives manifest facts from the decoded tree and prints them under facts, permissions and components (JSON key manifest): package, version code and name, compile, minimum and target SDK, used and declared permissions, debuggable, allowBackup, usesCleartextTraffic and networkSecurityConfig, the application label with a @string reference resolved through the resource table, and every activity, activity alias, service, receiver and provider with its qualified name, its label, the explicit exported value, the effective exported state (an unset value is exported when the component declares an intent filter, and a provider without one is exported below SDK 17), its intent-filter count, launcher status, permission and authorities. The badging rows of the same test compare package, versions, SDK levels, permissions, the application label, the debuggable flag and the launchable activities with aapt2 dump badging. The certificates list names each distinct signer certificate once, with its subject, issuer, serial, SHA-256 fingerprint and the signature schemes (v1, v2, v3, v3.1) that carry it, as apksigner verify --print-certs counts them.

JNI linking

disrobe jvm jni and disrobe apk link each declared native method to its C implementation across the DEX or classfile to native library boundary. Static binding computes the short and the long JNI symbol from the class name, method name, and descriptor, escaping _, ;, [, $, and non-ASCII characters as the JNI specification requires, and matches both against the library's exported symbols. A JNINativeMethod array that the compiler placed in the library's read-only data is recovered from the file: the tool walks candidate name, signature, and fnPtr triples, applies the ELF dynamic relocations to their pointer fields, and resolves each function address to its symbol. It reads the table, not the RegisterNatives call, so the recovery does not depend on how the call reaches the JNIEnv function table, and it finds no table that the program assembles in memory at run time. The output is the typed link table plus a JNIEXPORT ... JNICALL C prototype per native method.

An APK whose zip carries both the DEX and the native library links them without the caller naming either side; --native supplies the library for a bare .class, .jar, or .dex input. An AAR unzips its nested classes.jar and scans jni/<abi>/*.so, the AAR layout, rather than an APK's lib/<abi>/. An APK Set (.apks), or a base APK plus split APKs passed with --native, merges every split's DEX and native libraries into one input set, so a native method declared in the base DEX can resolve against a symbol that exists only in a config split. A raw .oat file locates its single embedded DEX through the OAT header's oat_dex_files_offset; a multi-dex .oat is refused, because the per-entry record stride changes between ART releases and is not documented. disrobe auto performs the same link and writes jni-link.json when both sides are in one container.

A native method with no matching symbol in any library is reported unresolved rather than dropped, and dynamic_only counts it. A symbol exported by more than one library is reported ambiguous rather than bound to the first. Each resolving library's ABI directory name is carried on its entry, so a multi-ABI APK states which .so a symbol came from.

The tests below run in cargo xtask prepush when disrobe-pass-jvm or disrobe-cli changes, and in the weekly scheduled CI run on Linux, macOS, and Windows; no JNI test runs on every push. "Real" means the input came from a real compiler or tool; "synthetic" means the test built the bytes itself.

Behaviour Test Reference Input
Short and long symbols, and the C prototypes jni_prototype_oracle.rs The header javac -h writes for a class with 28 native methods (overloads, _, $, a non-ASCII name, primitive, array, and reference types); the prototypes must also pass -fsyntax-only against the JDK's own jni.h real javac output
[ in a long symbol unit test in jni.rs A string written from the JNI specification; the javac -h fixture has no overloaded method with an array parameter synthetic
Symbols for DEX methods jni_native_surface.rs Strings written to match javac -h synthetic DEX and .so
JNINativeMethod recovery from read-only data jni_register_natives.rs readelf symbol values for the four registered functions, one reached through an R_X86_64_RELATIVE relocation real x86-64 and AArch64 .so compiled from a committed C file that never calls RegisterNatives
APK linked by disrobe auto, jni-link.json auto_apk_dex.rs The four entries of the committed .so a test-built APK holding the committed Hello.dex and .so
AAR jvm_jni_link.rs One static and four registered natives, both ABI directories named a committed AAR: a javac-built classes.jar, one synthetic .so, and the committed x86-64 .so
JAR with --native jvm_jni_link.rs The four registered natives the committed JAR and .so
APK Set, base plus split APK jvm_jni_link.rs One native resolved only by the split's symbol synthetic
Single-dex .oat jvm_jni_link.rs; unit tests in oat.rs for the multi-dex refusal One native resolved synthetic OAT headers, no dex2oat output
Unresolved natives jvm_jni_link.rs, jni_native_surface.rs Named in the text and JSON output with a count synthetic DEX
A d8-built DEX against a clang-built .so jvm_jni_link.rs At least one native resolved real javac, d8, and host clang output; the test prints UNGRADED and passes when d8 is not on PATH, which is the case in CI

disrobe native symbols on the two JNI libraries lists every dynamic symbol llvm-nm -D -S reports with the same address, size and kind, lists a symbol present in both .symtab and .dynsym once, and omits ARM mapping symbols ($x, $d) and section and file symbols (crates/disrobe-cli/tests/native_symbols_parity.rs, references beside the fixtures).

Not graded by any test: AAB input, a Windows .dll or macOS .dylib passed with --native, the JNI table that disrobe apk prints, ambiguous symbols, a real dex2oat .oat, and an NDK-built library or the NDK's jni.h.

Only two things are absent from the static artifact and therefore unrecoverable: a fnPtr computed at runtime rather than stored as a relocatable pointer, and a FindClass target name built dynamically rather than passed as a string literal. The native function body at a recovered address is not this surface's job; it goes to the native pseudo-C decompiler.

Code-pattern audit

disrobe apk audits every DEX in the package for insecure API use and reports each site as a DR-APK-* finding anchored to the class, method and instruction offset, with the resolved argument as evidence. The rules are evaluated on the bytecode, not on decompiled text: a Cipher.getInstance call is graded by the transformation string that reaches its argument register, a SecretKeySpec by whether its key bytes came from a string literal, a storage call by the mode constant it receives, and a WebSettings setter by the boolean it is passed. Eight categories are covered.

Category Rules
Weak cryptography DES, 3DES, RC2, RC4 and Blowfish ciphers; ECB mode and bare AES; RSA without OAEP; MD2, MD4, MD5 and SHA-1 digests; SecureRandom seeded with a constant; java.util.Random feeding key material; keys and IVs built from string literals
Transport security empty X509TrustManager checks and a null getAcceptedIssuers; a HostnameVerifier that returns true; the Apache ALLOW_ALL_HOSTNAME_VERIFIER; SslErrorHandler.proceed; SSLContext on SSL, SSLv3, TLSv1 or TLSv1.1
Insecure storage world-readable or world-writable file, preference, database and directory modes; external storage directories and literal /sdcard paths; temporary files; execSQL and rawQuery with a statement that is not a literal
WebView JavaScript enabled, file and content access from file URLs, mixed content always allowed, saved passwords, addJavascriptInterface, remote debugging
Logging every android.util.Log call, raised when a literal in the message mentions a password, token, secret, key or credential
Dangerous APIs Runtime.exec and ProcessBuilder; DexClassLoader, PathClassLoader and in-memory class loaders; System.load by path; Method.invoke
Privacy IMEI, MEID, IMSI, SIM serial, line number, ANDROID_ID and Build.SERIAL; SMS sending; clipboard reads; microphone, camera and location access; installed package enumeration
Anti-analysis su execution and root-indicator paths and packages; emulator fingerprints compared against Build properties; Debug.isDebuggerConnected; Frida indicator literals; getPackageInfo with the signing-certificate flags (a signature self-check); Play Integrity and SafetyNet attestation calls

The grader is the authored corpus/recon/android-audit pair: a plant APK with one insecure use per method and a control APK that uses the same API families correctly, both built by javac, d8, aapt2 and apksigner from committed sources. The test requires every plant to be reported at its own method and nothing else, and no finding on the control. Register values are tracked linearly within a method, so an argument that reaches the call through a branch merge is reported only when the literal is still the last write to that register; a transformation or mode computed at run time is not reported.

Limits

Disrobe reports lowering 83662 / 83943 methods with code items across three SHA-256-pinned FOSS APKs, or 99.7%. This counts emitted bodies. The APKs contain 89,516 declared methods, including 5,573 abstract, native, and interface-instance declarations without bodies.

The JVM check selects non-stub methods by a deterministic hash of each method name and re-hosts their bodies in isolated carriers. Of the bodies presented, 2988 of 2998 pass -Xverify:all without being invoked. Another 5,421 sampled bodies are ungraded: 1,407 constructors, 1,730 invokespecial receivers, 2,141 unresolved dependencies, and 143 other exclusions. The recovery chart's data records the full candidate and sample populations.

APK Emitted bodies / methods with code JVM accepted / presented
Transmissionic 26,360 / 26,416 988 / 989
RustDesk 29,841 / 29,983 1,217 / 1,223
EnRecipes 27,461 / 27,544 783 / 786

The two measureContentWidth bodies excluded by the sampling rules are ungraded and do not contribute to the accepted count. Verdicts are pinned by method name under crates/disrobe-pass-jvm/tests/golden/dalvik_body_attest/. These measurements use local APKs; CI checks the published counts and the separate committed-DEX corpus.

  • Runtime-keyed string schemes (system property, environment, clock, secure random, or a live cross-class table, as Stringer sometimes uses) are flagged as walled instead of faked.
  • A DexGuard routine that derives its key from runtime-only state (a system property, the environment, the clock, or a secure random) is reported as runtime-keyed rather than guessed.
  • DexGuard-style reflection-string recovery is validated against a benign sample implementing that technique, not output produced by the commercial DexGuard tool. The sample is a hand-written .java compiled by real javac and dexed by real d8; the plaintext reference is the stdout of the same program run under a real JVM.
  • SkidSuite2 is detect-only. The strict re-execution grader recompiles recovered Java with javac, runs it, and compares stdout and exit code with the clean program. It pins 28 obfuscated programs as fully recovered. Of these, 27 are real tool outputs: the five Soot 4.7.1 JBCO runner outputs, seven yGuard 5.0.0 outputs with their rename logs reversed into the original identifiers, four Skidfuscator outputs, nine ProGuard 7.10.0 runner outputs, and the ProGuard 7.9.1 and R8 9.1.31 Hello jars with their mappings applied. The 28th is an authored class in the classic Stringer string-encryption shape. Three fixtures stay named residuals. In the R8 EdgeCases jar, six try-with-resources and finally handlers that R8 reshaped into shared handler code form no foldable chain, and four pattern switches keep their typeSwitch call sites. The ProGuard EdgeCases jar also keeps its records' toString, hashCode and equals as ObjectMethods call sites, and that build throws at run time because the optimizer pruned an enum constant array. The Allatori caller-keyed pair decrypts through swap and dup_x2 stack shuffles the lifter does not render. Four JBCO runner invocations failed in the tool and produced no fixtures. The Kotlin EdgeCasesKt jars from ProGuard and R8 are not graded, because their reference program needs kotlinx-coroutines, which no grader environment provides. The grader recovers each jar as one class set with its mapping or rename log applied. disrobe jvm decompile decompiles each class on its own, folding only the hash functions a class declares itself, so Skidfuscator dispatch that calls a sibling class stays in place there. Given --mapping <mapping.txt> or --yguard-log <renamelog.xml>, it also recovers the jar as one class set with the names restored, writes the Java under recovered/, and summarises the renames and any fallback methods in source-recovery.json.
⚠️ **GitHub.com Fallback** ⚠️