Files
apk-reverse/references/recon.md
T
inliver233 a552157f1b Close the byte-patching gap, add the server-config path, fix two script crashes
Worked end-to-end on a real unobfuscated commercial app (single-dex, no packer,
server-driven launch screen) and folded back everything that cost time. Net +17
files, no new dependencies, no sample-specific identifiers.

New references
  byte-level-patching.md
    The technique that was missing entirely. Equal-length in-place edits beat
    method rebuilding (measured on a real sample: method rewrite grew debug_info
    924 B -> 22.8 KB and the dex 4.32 MB -> 7.73 MB), but four failure modes are
    silent: same-length is not enough, branch polarity, verifier legality, and
    stale dex header fields. Covers locating an instruction's exact offset (why
    listings and byte-pattern guesses both fail), the instruction width table and
    the two traps that desynchronise a decode, neutralise-a-branch vs redirect-it,
    and the header's signature-then-checksum order.
  server-config-and-updates.md
    The most common shape of "ad" in a modern app, and the one usually
    mis-diagnosed: the server supplies UI the client renders. The two-layer fetch
    (local fallback then remote override) that proves it, finding the config DTOs
    through the field names data classes keep, why you patch the decision and not
    the data, deciding the scope of "remove", and remote re-enable / cached config
    durability.

New scripts
  dexutil.py             dependency-free dex reader: structural walk, exact
                         instruction decode, dex header recompute/verify in the
                         correct order, branch-target and operand helpers
  dex_find_insn.py       locate an instruction by decoded semantics, print its exact
                         byte offset with context and BOTH sides of any branch
  dex_patch_bytes.py     equal-length byte patches from a JSON spec: semantic match,
                         polarity pin via expect_next, equal-length enforcement,
                         verifier check, header recompute, re-decode to prove it landed
  dex_check_verifier.py  does any conditional branch target a move-result (bypassing
                         its producer)? Compares builds and separates pre-existing
                         findings from regressions the patch introduced
  coldstart.py           cold-launch capture: timed screenshot burst + logcat signals
                         + installed-build facts + launch timing, and a warning when
                         the foreground activity is not the app
  check_repo.py          maintenance check: every script parses and answers --help,
                         every referenced path resolves, every file is documented

Fixes found while running the above
  repack.py       wrote archives with a plain zipfile writer, which cannot satisfy
                  the Android R+ install gate: resources.arsc must be STORED *and*
                  4-byte aligned, and a zip extra record cannot express a 1-3 byte
                  pad (a stored filler entry is inserted instead). Also fixes the
                  central-directory pointer and adds an alignment gate to the output.
  usb_net_proxy.py, dex_strpatch.py
                  crashed on --help with a traceback instead of printing usage.

Updated
  toolchain.md            ddc added (single-binary dex->Java with query subcommands,
                          with measured timings and its real limits); "not on PATH is
                          not not-installed" with the search commands; which signer
                          to use and why jarsigner breaks alignment
  repack-and-sign.md      alignment requirement and its arithmetic; dex header
                          recompute; jarsigner hazard; vendor install interception
                          ([N] with no symbolic constant -> root pm install); the
                          installer can keep owning the screen after an install
  recon.md                resolve package identity from a tool, not from hand-read
                          AXML strings (a wrong package sends all later device queries
                          nowhere); snapshot the config surface during recon
  long-task-discipline.md captures you never looked at are not evidence (incl. the
                          consistent-but-wrong-window variant); long-context decay and
                          the record shape that survives it
  dynamic-frida.md        when live attach cannot work at all: static instrumentation,
                          static read, and self-recorded evidence, with the control
                          build caveat each one carries
  ad-removal.md           server-driven config is usually the EASIEST case, not the
                          hardest; a three-way zero result is a positive finding;
                          scope note on local brand splash vs promotional content
  pitfalls.md             P37 stale dex header checksum, P38 [-124] alignment,
                          P39 bare-numeric install failure, P40 wrong-window evidence,
                          P41 inverted branch polarity, P42 decode desynchronisation
  SKILL.md, README.md     symptom index, reference/script indexes and requirements
                          updated for all of the above
2026-09-20 02:05:41 +08:00

12 KiB

Recon — build the picture before touching anything

Ten minutes here prevents hours of wrong work. Answer the five questions from SKILL.md with concrete evidence.

1. Identity

# Without a full decompiler (fast, works everywhere):
aapt dump badging app.apk | head -20
# or, from the extracted manifest:
#   package name, versionName, versionCode, sdkVersion, targetSdkVersion
# or, if you have ddc (no JVM, single binary, also gives the launcher):
ddc info app.apk

Record: package name, version name/code, min/target SDK, all requested permissions, all declared components.

Do not hand-parse the package name out of the binary manifest as your primary source. Real AXML string tables are full of class-name fragments that look exactly like package names — in one sample, the strings visible without a real parser suggested com.<app>.offline (a service class prefix) while the actual package was com.<vendor>.app. Every later pm list packages, dumpsys package, /data/data/<pkg> and pm install command depends on this value, so getting it wrong sends the whole recon down a path where nothing is found and the natural conclusion is "the tool is broken".

Cross-check two independent sources and require agreement:

  • ddc info app.apk → package, label, launcher
  • aapt2 dump badging app.apk | head -1 (or aapt dump badging)

If they disagree, resolve it before proceeding. A single label value is also worth having: it is what appears on the launcher, which makes device-side identification unambiguous later.

Note: some aapt builds choke on non-ASCII paths. Normalize sample paths to ASCII before running tooling.

1b. Snapshot the config surface early

If the app has a remote-config endpoint, its DTOs are the cheapest thing in the package to find, because data classes serialise their own field names into strings that survive R8:

ddc findrefs app.apk string "SplashConfig"       # or Config, Popup, Banner, Tabbar...
ddc strings app.apk -f "enabled" --with-locations

Do this during recon rather than later: it tells you immediately whether the launch screen, popups and tab set are client decisions or server data, which is the difference between a five-minute patch and a dead end. See server-config-and-updates.md.

2. Is it packed?

Read AndroidManifest.xml → application android:name.

What you see Meaning
The app's own class (e.g. com.example.app.App) No packer. Dex is directly editable.
A vendor class (com.stub.StubApp, com.secneo..., com.tencent.StubShell, s.h.e.l.l.*, com.nagain.*, …) Packed. Unpack first.
A short meaningless name that does not match the app's package (e.g. com.bc41), plus android:appComponentFactory pointing at another short class Dex-level whole-APK packer. The shell registers its own Application/proxy under names unrelated to the app.
classes.dex is only tens of KB and holds nothing but shell classes The real code is not on disk.
A shell library in lib/<abi>/ (libDexHelper.so, libjiagu*.so, libshell*.so, …) Native half of the shell: decrypts and loads the payload.
One or more large high-entropy assets/ blobs, possibly disguised as JPEG (valid magic bytes, no decodable image) Encrypted payload.
assets/ contains a second .apk or .jar Wrapped/loader build.

A clean shape is also a result: record it and move on. An app whose application android:name is its own class, with a single plain classes.dex and no native libraries, is directly editable — and that finding is what lets you skip the entire packer/unpack branch of this skill. It is worth one explicit line in the record ("no packer: application is the app's own class; dex readable; no lib/") so that later steps cannot re-open a question that was already answered. Most real targets are this shape; the packer material in this repository exists for the rest.

Decide from sizes and structure, not from names:

unzip -l app.apk | sort -k1 -n | tail -20   # biggest entries: assets? libs? dex?

Loaded-shell check (highest fidelity): if the app runs and its own classes are reachable, it is unpacked at runtime regardless of what the manifest says. If the app's own code is not among the dex classes, it is packed on disk.

Unpacking a dex-level packer

1. Map payload → dumped dex by length. If the shell uses length-preserving encryption, each encrypted assets/ payload has exactly the same byte count as the plaintext dex it decrypts to. That equality is the strongest single discriminator for "which dump is a real original dex" — far more reliable than sorting dumps by size. Measure it per file before trusting any dump.

2. Check how the real dex is loaded. A log line like InMemoryDexFile[cookie=[0, ...]] means the dex is built in memory from the payload and never written to disk, so there is no dropped file to find. Use a memory-scanning dumper (frida-dexdump, or your own script walking the class loaders) instead of hunting for artifacts under the app's data directory. See references/dynamic-frida.md.

3. Two dumper caveats that cost real time.

  • -D <device-id> does not accept a remote device (Device '<id>' not found). Register a remote device with -H <host>:<port>; keep -D for local/USB ids.
  • The -o <dir> output directory must already exist — the tool does not create it. Otherwise every dex write fails and the run still ends with All done, which reads exactly like a successful dump of nothing. mkdir -p out first, and judge the result by the file count, never by the final message.

4. Filter the dumps before using any of them. A memory dump contains three kinds of garbage:

  • Duplicate copies of one dex. Identical byte count does not imply identical content — files of the same size have been measured to differ in ~73% of their bytes, carry a different dex version, and fail to parse. Dedupe by content hash, never by size.
  • SDK plugin dexes downloaded at runtime, which were never part of the APK.
  • Structurally broken dexes whose header and body disagree.

Cheap header check that catches the third class, plus the hash used for the first:

import struct, hashlib
d = open(p, 'rb').read()
assert d[:4] == b'dex\n'                                # magic
assert struct.unpack_from('<I', d, 32)[0] == len(d)     # file_size == actual size
assert struct.unpack_from('<I', d, 36)[0] == 112        # header_size
sha = hashlib.sha256(d).hexdigest()

Then confirm survivors actually parse (baksmali/dexlib2) or load on device. Header consistency alone is not proof of a usable dex.

5. Which dump is the app's own business dex. Count string-feature hits per dump instead of trusting file order:

python scripts/dex_strings.py <dump_dir> --find 'Lcom/example/app/' --per-file
python scripts/dex_strings.py <dump_dir> --find 'Lcom/example/adsdk/' --per-file

Heuristic: the business dex mixes the app's package prefix (hundreds of hits) with an ad-SDK prefix (thousands of hits). A pure SDK plugin dex has one vendor prefix at its maximum count and zero app-package hits. Zero app-package hits means it is not the app.

3. Where does the app's own code live?

Indicator Code location
Plain classes*.dex, app classes visible in them Dex — patchable with dexlib2/smali
libflutter.so + libapp.so Flutter (Dart AOT) — dex contains only a thin shell
libil2cpp.so + assets/bin/Data/ Unity (IL2CPP) — native
libmono*.so + assets/bin/Data/Managed/ Unity (Mono) — managed DLLs under assets/
libreactnativejni.so + assets/index.android.bundle React Native — JS bundle under assets/
libhermes.so Hermes bytecode
Almost everything in .so Native — different toolchain entirely

For a Kotlin/Java app, note the module layout: app code often lives in a small subset of dex files while large SDKs occupy the rest. Finding which dex holds the app's package is a huge time-saver:

python scripts/dex_strings.py <dex_dir> --find 'Lcom/example/app/' --per-file

Consequence for editing: you usually only need to replace one or two dex files, which keeps the repack minimal. When the app is packed, run this check against the dumped dexes, not the ones on disk (§2).

4. SDK and library inventory

Extract strings across all dex and group by vendor markers. This reveals ads, analytics, crash reporting, attribution, and any anti-tamper SDK, in one pass.

python scripts/dex_strings.py <dex_dir> --urls
python scripts/dex_strings.py <dex_dir> --find 'anythink|openadsdk|com.qq.e|umeng|bugly|crashsdk'

What to look for:

  • Ad networks / aggregators: openadsdk TTAdSdk Pangle com.qq.e GDTAd anythink ATSDK ksad mobads sigmob bdxadsdk
  • Analytics / crash / attribution: umeng bugly crashsdk appsflyer adjust UMCrash
  • Identity / device id: oaid msa SupplementaryDID freemme
  • Anti-tamper / root / hook detection: libsgcore libInno libqmcheat, strings like frida xposed magisk substrate, /system/xbin/su
  • Media stack: libmpv libavcodec libplayer libgdx

Important distinction: a vendor marker in the string table does not mean the app uses that feature. Presence of xposed/frida strings is often just an SDK's own detection list. Decide based on behavior, not on strings (references/verification.md).

5. Signature and tamper checks

Search the app's own packages only (ignore third-party SDKs) for:

  • getPackageInfo, PackageInfo, signatures, GET_SIGNATURES, Signature → app-side signature verification
  • hardcoded SHA-1/SHA-256 hex constants, base64 license blobs → certificate pinning to the original signer
  • checkSignature, verifySignature, signCheck → a named check

Beware false positives. SignatureCheck and verifySignature exist inside okhttp3 (SuppressSignatureCheck, BasicCertificateChainCleaner.verifySignature) — TLS plumbing, not app tamper checks. Likewise a native lib*.so named like a security library may be an ad SDK's payload decryptor, not a shell.

Confirm by checking whether app code references it. If the only callers are library-internal, it is not your problem.

6. API surface

python scripts/dex_strings.py <dex_dir> --urls

Collect: base URLs, DoH/DoT lookups, path constants, CDN hosts, custom headers.

Treat unique strings as navigation aids — they can often be searched byte-wise in the dex to find the owning class (see references/dex-patching.md §finding-the-call-site).

Two patterns worth recognizing early:

  • Dynamic gateway: the real API host is fetched at runtime (e.g. via a DNS TXT record over DoH). The hardcoded host in dex may be only a fallback. This affects server-side analysis, not patching.
  • Minimal headers / no request signing: if auth is just Authorization: Bearer plus a static app-name header, then a repackaged client is not distinguishable to the server by request signature — which matters for references/server-api.md.

Recon output

Write a short profile before patching. Minimum:

package / version / ABI
packed? (evidence)
if packed: packer family, unpack method, kept dumps + sha256
code location (dex / flutter / native)
app code in which dex files
ad SDK(s) and the app's wrapper class
analytics/crash SDKs
anti-tamper: present? where? app-side or library-internal?
API base + notable endpoints
device plan (rooted real device / emulator / static only)