diff --git a/README.md b/README.md index 898673f..89cb827 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ directly. machine", and confusing the two is the most expensive drift in this domain. - Choosing the **safest patch layer** for a given change, and avoiding the layers that break the app. +- Catching the repack failure that looks like success: an app that installs, launches and + renders perfectly while **every signed request is rejected**, because the client derives its + request-signing key from its own signing certificate. - Separating **your own mistakes from the app's or the server's problems** — a feature-scoped failure (login, registration, payment) is often a TLS/certificate issue on one code path, not a consequence of the patch you just built. Device state, a dead device @@ -50,6 +53,9 @@ references/ loaded on demand, one topic each long-task-discipline.md live record, conclusion grading, drift control, timeout and wait calibration, deliverable-form drift, handover ad-removal.md ad taxonomy, wrapper mapping, callback trap, global gates, verification + signature-derived-keys.md when the app's own signing certificate is used as key material: + detection greps, why offline extraction is unreliable, the + hardcode-then-verify procedure membership-and-limits.md server vs client authority; what is and is not patchable server-api.md probe an app's API; prove who owns the gate tls-and-cert.md feature-scoped network failures: expired certs, dual trust chains @@ -94,6 +100,8 @@ scripts/ parameterized, path-agnostic (base64/hex x rotation x deflate); re-encode the edited payload snap.py bounded burst screenshots + control-tree capture with a stall detector, and a verdict on whether the tree is usable at all + sig_probe.py find the exact signatures[0].toCharsString() value — offline + candidates from an APK, or the authoritative read from a device ``` ## Install diff --git a/SKILL.md b/SKILL.md index 4dfef95..7db026b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -13,7 +13,7 @@ Three rules override everything else in this skill: 2. **Never ship an unverified APK.** "It assembles" is not "it works". Install it, launch it, exercise the feature you changed, on a real device or a close emulator. 3. **Know which layer owns the behavior before you patch.** Ads, paywalls, and feature gates live in different places. Patching the wrong layer either does nothing or breaks the app. Classify first (see step 2 below), patch second. -## Start here: classify the target in seven questions +## Start here: classify the target in ten questions Answer these before touching a tool. Every one of them changes the whole plan. @@ -51,6 +51,12 @@ Answer these before touching a tool. Every one of them changes the whole plan. and keep its record updated as you go. Re-read the refuted-conclusions and dead-routes sections before starting any new experiment. Losing earlier findings is the most expensive failure in this skill, and it is entirely preventable. +10. **Does the client sign its requests with its own signing certificate?** + → `references/signature-derived-keys.md`. Grep for `toCharsString()` / `signatures[0]` / + `getPackageInfo(..., 64)` **before the first repack**. If that value feeds a native HMAC/DES + routine, the rebuilt APK must hardcode the *original* certificate value at every read site, or + every signed request fails while the app still launches and looks healthy. This is the single + most expensive silent failure in a repack, and 15 minutes of grep prevents it. ## The workflow, end to end @@ -94,6 +100,7 @@ Load only what the current step needs. | `references/server-api.md` | The behavior is decided by a response, or you need to know if a patch can even matter | | `references/dex-patching.md` | Any actual editing of dex/smali, choosing a patch layer, choosing a tool | | `references/repack-and-sign.md` | Rebuilding, signing, installing, or a repacked app misbehaves | +| `references/signature-derived-keys.md` | The app reads `signatures[0]`/`toCharsString()`, or a rebuilt APK installs and runs but every signed request fails (`sign`/`_p`/`uth` empty or `-1`) | | `references/runtime-data.md` | Local state matters: DataStore, SharedPreferences, SQLite, protobuf caches, tokens — **or your data edit keeps being reverted, or a stored value looks encrypted** | | `references/dynamic-frida.md` | Frida setup, hooking strategy, tracing caller chains, finding the real call site | | `references/environment.md` | Device/emulator setup, root, ADB, networking, offline devices, emulator console control and recovery, **the preflight check to run before every experiment block** | @@ -132,3 +139,4 @@ All scripts are parameterized and path-agnostic; pass paths explicitly. Run `--h | `scripts/lib_map.py` | What is **actually mapped** into a live process: per-library path, base, architecture (`ELF e_machine`), and classification (system / from-APK / runtime-materialized). Answers "which library and which ABI is really executing". | | `scripts/blob_decode.py` | Decode an opaque stored value by searching the parameter space (base64/base64url/hex × rotation × deflate/zlib/gzip) instead of guessing, then re-encode an edited payload with the same parameters. | | `scripts/snap.py` | Bounded burst screenshot + control-tree capture, with a stall detector and an explicit verdict on whether the accessibility tree is usable at all. Use it so you *look* at the screen instead of driving blind. | +| `scripts/sig_probe.py` | Find the exact `signatures[0].toCharsString()` value: offline candidate enumeration from an APK (`--apk`), or the authoritative value read from a live package (`--live`). Feed the result into the hardcoded constant described in `references/signature-derived-keys.md`. | diff --git a/references/dynamic-frida.md b/references/dynamic-frida.md index 3327d8e..f5b1cc0 100644 --- a/references/dynamic-frida.md +++ b/references/dynamic-frida.md @@ -38,6 +38,50 @@ device = frida.get_device_manager().add_remote_device('127.0.0.1:27042') Keep `-s ` on every other adb call too, or you will drift between targets mid-session. +### Attach by PID when the process list is incomplete + +`device.enumerate_processes()` can return a list that simply does not contain the app you are +targeting, while `adb shell ps -A | grep ` shows it running. This is an enumeration gap, +not a permission problem, and no amount of retrying fixes it. Resolve the pid through adb and +attach to it directly: + +```python +import subprocess + +def find_pid(serial, pkg): + out = subprocess.run(['adb', '-s', serial, 'shell', 'ps -A -o PID,NAME'], + capture_output=True, text=True).stdout + for line in out.splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[1] == pkg: + return int(parts[0]) # last match wins; use the main process + return None + +pid = find_pid(serial, pkg) +session = device.attach(pid) # works where attach(pkg) raises ProcessNotFound +``` + +Two related behaviours worth knowing: + +- `attach()` raises `ProcessNotFoundError` in exactly this situation, which is + easy to misread as "the app is not running". +- A pid captured earlier goes stale the moment the process is recycled. Re-resolve it + immediately before each injection rather than reusing a value from an earlier step. + +### Verify the runtime before blaming the script + +A script that reports nothing at all is usually not seeing the Java layer. Two failure modes +look identical from the host — test for both in one go: + +```javascript +// send({ runtime: Script.runtime, java: typeof Java, objc: typeof ObjC }); +``` + +- `Java` is `undefined` ⇒ the script runtime has no Java bridge. Pass `runtime='v8'` + explicitly when creating the script; some builds default to a runtime without it. +- The bridge exists but every hook misses ⇒ you attached to the wrong process, or the layer + below is native (go to the four-layer probe / `references/native-and-so.md`). + ### Start the device server so it survives Run it as root, give it a non-obvious name out of obvious paths, and **detach it from the shell's session**. A process started with `nohup ... &` from an `adb shell` that then exits gets reaped mid-run — the hooks work for a minute and then stop. diff --git a/references/pitfalls.md b/references/pitfalls.md index 37b05f1..a35149f 100644 --- a/references/pitfalls.md +++ b/references/pitfalls.md @@ -641,3 +641,52 @@ and arrives fast, which reads as competence. derived only from the tool's own output means nothing. - **Name helpers so they cannot shadow a module** (`dart_disasm.py`, not `dis.py`), and keep a timeout on every scan (SKILL.md). + +--- + +## P27. Every request fails after a repack because the signing certificate *is* the key + +**Symptom** + +The rebuilt app installs, launches, and draws its shell — but every API-backed screen shows a +generic network error. Computed request parameters (`sign`, `_p`, `uth`) come out as `-1`, +empty, or null. No Java exception anywhere, and the app's own UI still looks healthy. + +**Root cause** + +The client uses **its own APK signing certificate as key material**: it reads +`PackageInfo.signatures[0]` and hands that value to a native HMAC/DES routine which produces the +request signature. Re-signing changed the certificate, so the derived key changed, so the server +rejects every signed request. This is not "the server checks the signature" — the *client* is +computing with it, and the server was built against the original key. + +**Why it is hard to see** + +It reads as a server problem or a bad patch. The artifact is valid, the patch is correct, the +build verifies, and the app runs — so the natural conclusion is "a client patch is not possible +here", which sends you back into static analysis for hours. A naive fix also survives inspection: +hardcoding *a* signature value produces correct-looking smali and a clean build, and nothing +complains until a request is actually sent. + +A second trap sits inside the first: `signatures[0].toByteArray()` is **not** the +`META-INF/*.RSA` file. On modern Android it is a certificate DER taken from inside the PKCS#7 +chain. Hardcoding the whole `.RSA` content (1199 bytes in one measured sample) instead of the +runtime value (777 bytes) yields a build in which every signing parameter is `-1`. + +**Do instead** + +1. **Detect it before repacking.** Grep the decompiled sources for `toCharsString()`, + `getPackageInfo(..., 64)` and `signatures[0]`. If the value feeds a native method that also + does HMAC/AES/DES, this pitfall applies. +2. **Read the real value from the device**, not from the file — + `python scripts/sig_probe.py --live `. Cross-check the candidate list from + `scripts/sig_probe.py --apk `; the runtime length decides which candidate is + correct. +3. **Hardcode it at every read site** (there is usually more than one), then assert the remaining + site count is zero. +4. **Prove it differentially.** Print the computed signing parameter for the same startup request + on the original build and the rebuilt one. Same shape ⇒ consistent. `-1`/empty ⇒ the key is + still wrong. +5. **Check the OAID/device-id path too** — it frequently hashes the same certificate separately. + +Full treatment: `references/signature-derived-keys.md`. diff --git a/references/runtime-data.md b/references/runtime-data.md index 103e0a5..18dd94f 100644 --- a/references/runtime-data.md +++ b/references/runtime-data.md @@ -18,6 +18,7 @@ Sometimes the cheapest fix is not code at all, but a value in app data. And some /data/data// (== /data/user/0/) ├── files/ │ ├── datastore/*.preferences_pb AndroidX DataStore (protobuf) +│ ├── mmkv/ MMKV (Tencent) — one file per named store │ └── ... ├── shared_prefs/*.xml SharedPreferences ├── databases/*.db SQLite (Room, etc.) @@ -67,6 +68,68 @@ Verify by reading the file back with `xxd` before launching. Simple XML. Edit with the app stopped. Types are explicit (``, ``, ``, ``). Same ownership rules. +## MMKV (Tencent) + +Common in apps whose SDK stack is Chinese-market. One file per named store under `files/mmkv/`, +with a `.crc` companion. It is **not** a database and **not** XML — it is a small custom binary +format: + +``` +offset 0 uint32 actual_size size of the data region +offset 4 uint32 crc32 over the data region +offset 8 data region repeated: varint key_len, key, varint val_len, value +``` + +Files are preallocated, so trailing zeroes are normal; `actual_size` is what matters. + +Parsing is mechanical, and no library is needed: + +```python +import struct + +def parse(blob): + size = struct.unpack_from(' +grep -rn "getPackageInfo(.*, *64)" # 64 = GET_SIGNATURES +grep -rn "signatures\[0\]" + +# and the same pattern in SDK-internal helpers +grep -rn "signatures\[0\].toByteArray" +``` + +Signals that confirm it matters: + +- The value is passed into a `native` method (JNI) or a helper that also does HMAC/AES/DES. +- The string table of the native library contains algorithm primitives (`hmac_sha256`, + `hmac_md5`, `des_crypt`, `sha256_update`, `initKey`, `make_SubKey`, …) but **no hardcoded + certificate or key blob** — i.e. the key is supplied from Java. +- Request URLs carry computed parameters (`sign`, `_p`, `uth`, `sig`, `nonce`) that are not + derivable from the visible app data. +- The same certificate value is also fed to a third-party ad/OAID SDK helper. + +If those hold, **any rebuilt APK needs the original certificate value hardcoded**, not the +new one. Patch that before you patch anything else, or you cannot distinguish "my patch broke +it" from "signing broke it". + +## The trap: what that value actually is + +Do **not** assume `signatures[0].toByteArray()` is the whole `META-INF/*.RSA` file. It is +platform-dependent, and the difference is invisible offline: + +| Platform behaviour | What you get | +|---|---| +| Old JAR-signature path | the full PKCS#7 blob (`META-INF/CERT.RSA` content) | +| Modern (v2/v3 present) | a **single certificate from the chain**, DER-encoded — not the container, and not necessarily the first one | + +Measured consequence on one sample: the `.RSA` file was 1199 bytes / 2398 hex chars, but the +runtime value was **777 bytes / 1554 hex chars** — the *last* certificate inside the PKCS#7. +Hardcoding the file bytes produced correct-looking smali, a clean build, a successful install, +and every request signing parameter evaluating to `-1`. + +**Therefore: never derive this value offline and trust it.** Read it from the running app +(`scripts/sig_probe.py --live`), and only fall back to offline extraction to cross-check. + +## Extract the real value + +**Route A — from the device (authoritative):** + +```bash +python scripts/sig_probe.py --live --pkg # needs Frida + a rooted device +``` + +It prints `signatures[0].toCharsString()` and its length, read from the package manager on the +device. This is the only value that is guaranteed to match what the app computes. + +**Route B — offline, to cross-check:** + +```bash +python scripts/sig_probe.py --apk +``` + +This enumerates the candidate certificate blobs inside `META-INF/*.RSA` (each top-level +SEQUENCE inside the PKCS#7 `certificates` set) and prints each as hex with its length. Pick the +length that Route A reported; if Route A is unavailable, generate one build per candidate — +there are rarely more than two. + +## Patch it + +Replace every signature read with a string constant holding the original value, rather than +trying to make the new signature look like the old one. + +In smali, the pattern to replace is an invoke/move-result pair: + +```smali +invoke-virtual {vX}, Landroid/content/pm/Signature;->toCharsString()Ljava/lang/String; + +move-result-object vX +``` + +Rewrite both instructions into a single load (a regex over the smali tree handles all sites at +once): + +```smali +const-string vX, "" +``` + +Notes that save a build cycle: + +- **Do not delete the surrounding `getPackageInfo` calls.** They are harmless and removing + them perturbs register allocation. Only the two instructions above need to change. +- **Miss one site and it still fails.** Count the sites before and after (`grep -c`), and assert + the post-patch count is zero. There is usually more than one — one per crypto helper. +- Keep the smali file's `.orig` backup so the patch is re-runnable; some build pipelines pick + `.orig` up as an unknown file and warn, which is harmless. +- The same treatment applies to copies of the pattern inside bundled SDK packages + (`…/secure/…Utils`, `…/oaid/…`), not just the app's own util class. + +## Verify with a control, not with a feeling + +A launch that "looks fine" proves nothing here: the app starts, then quietly fails every +network call. Use a differential test. + +1. **Measure the signing parameter on both builds.** Hook the request builder and print the + computed parameter for a request the app always makes on startup. + - original APK → a value with the normal shape (encoded / base64-ish) + - rebuilt APK → the same shape ⇒ signing is consistent + - rebuilt APK → `-1`, empty, or null ⇒ the key is still wrong; go back to Route A +2. **Compare a full request URL**, parameter by parameter, between the two builds. Anything + that differs beyond a timestamp/device id is a candidate root cause. +3. **Exercise the feature**, not the launch: an API-backed screen must actually render data. + "Network error" on a screen that is otherwise intact is the signature of exactly this bug. + +Before blaming your patch: run the **unmodified original** on the same device and network. If +it also fails, the problem is environmental (`references/tls-and-cert.md`, +`references/pitfalls.md` P9) and not the repack. + +## Related variants of the same root cause + +- **OAID / device-id SDKs** hash the signature (`MessageDigest("SHA1").digest(signatures[0].toByteArray())`) + and the result goes into a request header. Same fix, different call site. +- **Ad SDKs** use the signature to encrypt their own config payloads; leaving them unpatched + usually degrades ads rather than breaking the app, so patch them after the core path works. +- **Certificate pinning to the app's own signature** is a different problem: it is verified, + not used as a key. See `references/tls-and-cert.md`. + +## Cost of getting this wrong + +Without this step the task stalls in the worst possible way: the artifact is correct, the +patch is correct, the build is correct, and the app is unusable — which reads as "the client +patch is impossible" and sends you back to static analysis for hours. Budget 15 minutes for +this before the first repack of any signed-request app. diff --git a/scripts/dexpatch/PatchMethod.java b/scripts/dexpatch/PatchMethod.java index 2efd40a..d383a78 100644 --- a/scripts/dexpatch/PatchMethod.java +++ b/scripts/dexpatch/PatchMethod.java @@ -15,7 +15,7 @@ import java.util.List; /** * dexlib2 定点 patch:把指定方法的实现整体替换为 return-void(或 return 常量)。 * - * 为什么不用 baksmali/smali 整树往返:实测本项目的 classes7.dex 往返重建后运行时报 + * 为什么不用 baksmali/smali 整树往返:实测在一个多 dex 应用上,整树往返重建后运行时报 * IncompatibleClassChangeError: Found interface io.ktor.client.engine.HttpClientEngine, * but class was expected * (R8 生成的 synthetic access bridge 被破坏)。本工具只改目标方法的 code_item, diff --git a/scripts/sig_probe.py b/scripts/sig_probe.py new file mode 100644 index 0000000..b37d915 --- /dev/null +++ b/scripts/sig_probe.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +""" +sig_probe.py -- find the exact value Android returns for +`PackageInfo.signatures[0].toCharsString()`. + +Why this exists: apps that use their own signing certificate as a *crypto key* need that +value hardcoded after a repack. Deriving it offline is unreliable -- it is platform +dependent, and on modern Android it is a single certificate from the chain, not the whole +`META-INF/*.RSA` blob. Get it from the device, or cross-check offline candidates. + +Usage +----- + # authoritative: read from a running (or launchable) package on a rooted device + python sig_probe.py --live + python sig_probe.py --live --serial --host 127.0.0.1:27042 + + # offline: enumerate candidate blobs inside META-INF/*.RSA + python sig_probe.py --apk + +Output is plain hex, ready to paste into a `const-string` smali patch. + +Notes +----- +* --live needs `frida` (pip install frida) plus a matching frida-server on the device. + Host and device versions must match; see references/dynamic-frida.md. +* `--serial` is honoured for adb calls. If several devices are attached, pass it -- Frida's + USB auto-selection can silently pick the wrong one. +""" + +import argparse +import io +import struct +import subprocess +import sys +import time +import zipfile + +SIG_EXTS = ('.RSA', '.DSA', '.EC') + + +# --------------------------------------------------------------------------- DER helpers +def _tlv(data, off): + """Return (tag, header_len, content_len). Raises on truncation.""" + if off + 2 > len(data): + raise ValueError('truncated TLV at %d' % off) + tag = data[off] + first = data[off + 1] + if first & 0x80: + n = first & 0x7F + if n == 0 or off + 2 + n > len(data): + raise ValueError('bad length at %d' % off) + length = int.from_bytes(data[off + 2:off + 2 + n], 'big') + return tag, 2 + n, length + return tag, 2, first + + +def _children(data, start, end): + """Yield (tag, header_len, content_len, content_off) for each child in [start, end).""" + off = start + while off < end: + tag, hlen, clen = _tlv(data, off) + yield tag, hlen, clen, off + hlen + off += hlen + clen + + +def pkcs7_certificates(blob): + """Extract the DER certificate blobs from a PKCS#7 SignedData structure. + + ContentInfo ::= SEQUENCE { contentType OID, content [0] EXPLICIT SignedData } + SignedData ::= SEQUENCE { version, digestAlgorithms, contentInfo, + certificates [0] IMPLICIT SET OF Certificate, ... } + """ + out = [] + tag, hlen, clen = _tlv(blob, 0) + if tag != 0x30: + return out + content_off = hlen + content_end = hlen + clen + + # ContentInfo -> [0] EXPLICIT -> SignedData SEQUENCE + signed_data = None + for t, h, c, coff in _children(blob, content_off, content_end): + if t == 0xA0: # content [0] EXPLICIT + for t2, h2, c2, coff2 in _children(blob, coff, coff + c): + if t2 == 0x30: # SignedData + signed_data = (coff2, coff2 + c2) + break + if signed_data is None: + return out + + sd_start, sd_end = signed_data + # children of SignedData: version, digestAlgorithms, contentInfo, certificates [0], ... + seen_a0 = 0 + for t, h, c, coff in _children(blob, sd_start, sd_end): + if t == 0xA0: # certificates [0] IMPLICIT + seen_a0 += 1 + if seen_a0 > 1: + break + for t3, h3, c3, coff3 in _children(blob, coff, coff + c): + if t3 == 0x30: # a Certificate + out.append(blob[coff3 - h3:coff3 + c3]) + return out + + +def candidates_from_apk(path): + """Every plausible hardcode candidate, longest-first, de-duplicated.""" + results = [] + with zipfile.ZipFile(path) as z: + names = [n for n in z.namelist() + if n.upper().startswith('META-INF/') and n.upper().endswith(SIG_EXTS)] + for name in names: + blob = z.read(name) + whole = ('whole ' + name, blob) + results.append(whole) + for i, cert in enumerate(pkcs7_certificates(blob)): + results.append(('%s cert[%d]' % (name, i), cert)) + + seen, uniq = set(), [] + for label, blob in results: + if blob not in seen: + seen.add(blob) + uniq.append((label, blob)) + return uniq + + +# --------------------------------------------------------------------------- live route +LIVE_JS = r""" +// Single-shot read. No timers: frida's JS runtime has no setTimeout, and using one +// turns a "not ready yet" into a silent no-response. The Python side retries instead. +Java.perform(function () { + var pkg = PKG_NAME; + try { + var app = Java.use('android.app.ActivityThread').currentApplication(); + if (app === null) { + send({ ok: false, retry: true, error: 'Application not initialized yet' }); + return; + } + var ctx = app.getApplicationContext(); + var pm = ctx.getPackageManager(); + var pi = pm.getPackageInfo(pkg, 64); + var arr = pi.signatures.value; + var out = []; + for (var i = 0; i < arr.length; i++) { + var s = arr[i].toCharsString(); + var blen = -1; + try { blen = arr[i].toByteArray().length; } catch (e) {} + out.push({ i: i, hex: s, len: s.length, bytes: blen }); + } + send({ ok: true, signatures: out }); + } catch (e) { + send({ ok: false, error: String(e) }); + } +}); +""" + + +def run_live(pkg, serial, host, timeout): + try: + import frida + except ImportError: + print('[!] frida not installed: pip install frida', file=sys.stderr) + return 2 + + try: + dev = frida.get_device_manager().add_remote_device(host) + except Exception as exc: # noqa: BLE001 + print('[!] cannot reach frida-server at %s: %s' % (host, exc), file=sys.stderr) + print(' is it running, and is the port forwarded? ' + 'adb %sforward tcp:%s tcp:27042' + % (('-s %s ' % serial) if serial else '', + host.rsplit(':', 1)[-1]), file=sys.stderr) + return 2 + + # A running process is preferred; fall back to spawning so package data is readable. + pid, spawned = None, False + for p in dev.enumerate_processes(): + if p.name == pkg: + pid = p.pid + break + if pid is None: + try: + pid = dev.spawn([pkg]) + spawned = True + except Exception as exc: # noqa: BLE001 + print('[!] package not running and could not be spawned: %s' % exc, file=sys.stderr) + print(' launch it once, then re-run', file=sys.stderr) + return 2 + + result = {} + + def on_msg(msg, _data): + if msg.get('type') == 'error': + result['_js_error'] = msg.get('description') or str(msg) + else: + result.update(msg.get('payload') or {}) + + try: + session = dev.attach(pid) + if spawned: + dev.resume(pid) + time.sleep(1.0) + + # Retry at the Python level: right after a spawn the Application object may not + # exist yet, and the script reports that as retryable rather than failing. + deadline = time.time() + timeout + while time.time() < deadline: + result.clear() + # V8 is required: on some frida builds the default runtime has no Java bridge, + # and the failure looks like "no response" rather than a clear error. + script = session.create_script( + LIVE_JS.replace('PKG_NAME', repr(pkg).replace("'", '"')), runtime='v8') + script.on('message', on_msg) + script.load() + t0 = time.time() + while not result and time.time() - t0 < 1.5: + time.sleep(0.1) + if result.get('ok') or not result.get('retry'): + break + time.sleep(0.4) + except Exception as exc: # noqa: BLE001 + print('[!] injection failed: %s' % exc, file=sys.stderr) + return 2 + + if not result.get('ok'): + detail = result.get('error') or result.get('_js_error') or 'no response' + print('[!] probe failed: %s' % detail, file=sys.stderr) + return 2 + + for sig in result['signatures']: + print('signature[%d] toCharsString length = %d toByteArray bytes = %d' + % (sig['i'], sig['len'], sig['bytes'])) + print(sig['hex']) + print() + return 0 + + +# --------------------------------------------------------------------------- main +def main(): + ap = argparse.ArgumentParser( + description='Find the exact signatures[0].toCharsString() value (for hardcoding ' + 'into a repacked APK).', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__) + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument('--apk', help='APK to inspect offline (enumerates candidates)') + g.add_argument('--live', metavar='PKG', help='package name; read the value from the device') + ap.add_argument('--serial', help='adb serial (when several devices are attached)') + ap.add_argument('--host', default='127.0.0.1:27042', + help='frida-server endpoint for --live (default %(default)s)') + ap.add_argument('--timeout', type=float, default=20.0, + help='seconds to wait for the live probe (default %(default)s)') + args = ap.parse_args() + + if args.live: + return run_live(args.live, args.serial, args.host, args.timeout) + + cands = candidates_from_apk(args.apk) + if not cands: + print('[!] no META-INF/*.RSA|*.DSA|*.EC in %s (is it signed?)' % args.apk, + file=sys.stderr) + return 1 + + print('Candidates in %s' % args.apk) + print('(modern Android returns ONE certificate from the chain, not the whole .RSA --') + print(' use --live to know which; otherwise build one variant per candidate)\n') + for label, blob in cands: + print('%-28s %6d bytes %5d hex chars' % (label, len(blob), len(blob) * 2)) + print(blob.hex()) + print() + return 0 + + +if __name__ == '__main__': + sys.exit(main())