Add signature-derived-key coverage, PID attach, MMKV; fix stale question count

New: references/signature-derived-keys.md (apps that use their own signing certificate as crypto key material: detection greps, why offline extraction is unreliable -- the runtime value is a certificate DER from inside the PKCS#7 chain, not the .RSA container -- plus the hardcode-then-verify procedure); scripts/sig_probe.py (offline candidates via --apk, authoritative read via --live, both exercised); pitfalls.md P27 (rebuilt APK installs and renders but every signed request returns empty/-1 signing params).

Updated: dynamic-frida.md (attach by PID when enumerate_processes omits the target; verify runtime/Java-bridge availability before blaming the script); runtime-data.md (MMKV format, parse/write snippet, crc32 and in-memory-overwrite traps); server-api.md (route the signature-derived-key case to the new reference); SKILL.md and README.md (new classification question, corrected question count, index entries).
This commit is contained in:
inliver233
2026-09-19 22:31:32 +08:00
parent cf636c4263
commit 21686e84f1
9 changed files with 594 additions and 4 deletions
+8
View File
@@ -18,6 +18,9 @@ directly.
machine", and confusing the two is the most expensive drift in this domain. 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 - Choosing the **safest patch layer** for a given change, and avoiding the layers that
break the app. 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 - 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 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 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 long-task-discipline.md live record, conclusion grading, drift control, timeout and
wait calibration, deliverable-form drift, handover wait calibration, deliverable-form drift, handover
ad-removal.md ad taxonomy, wrapper mapping, callback trap, global gates, verification 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 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 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 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 (base64/hex x rotation x deflate); re-encode the edited payload
snap.py bounded burst screenshots + control-tree capture with a stall snap.py bounded burst screenshots + control-tree capture with a stall
detector, and a verdict on whether the tree is usable at all 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 ## Install
+9 -1
View File
@@ -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. 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. 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. 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 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 before starting any new experiment. Losing earlier findings is the most expensive failure in this
skill, and it is entirely preventable. 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 ## 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/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/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/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/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/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** | | `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/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/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/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`. |
+44
View File
@@ -38,6 +38,50 @@ device = frida.get_device_manager().add_remote_device('127.0.0.1:27042')
Keep `-s <serial>` on every other adb call too, or you will drift between targets mid-session. Keep `-s <serial>` 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 <pkg>` 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(<package name>)` 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 ### 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. 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.
+49
View File
@@ -641,3 +641,52 @@ and arrives fast, which reads as competence.
derived only from the tool's own output means nothing. 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 - **Name helpers so they cannot shadow a module** (`dart_disasm.py`, not `dis.py`), and keep a
timeout on every scan (SKILL.md). 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 <pkg>`. Cross-check the candidate list from
`scripts/sig_probe.py --apk <original.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`.
+63
View File
@@ -18,6 +18,7 @@ Sometimes the cheapest fix is not code at all, but a value in app data. And some
/data/data/<pkg>/ (== /data/user/0/<pkg>) /data/data/<pkg>/ (== /data/user/0/<pkg>)
├── files/ ├── files/
│ ├── datastore/*.preferences_pb AndroidX DataStore (protobuf) │ ├── datastore/*.preferences_pb AndroidX DataStore (protobuf)
│ ├── mmkv/<name> MMKV (Tencent) — one file per named store
│ └── ... │ └── ...
├── shared_prefs/*.xml SharedPreferences ├── shared_prefs/*.xml SharedPreferences
├── databases/*.db SQLite (Room, etc.) ├── 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 (`<boolean>`, `<int>`, `<long>`, `<string>`). Same ownership rules. Simple XML. Edit with the app stopped. Types are explicit (`<boolean>`, `<int>`, `<long>`, `<string>`). 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('<I', blob, 0)[0]
pos, end, out = 8, 8 + size, []
def varint(p):
v = s = 0
while True:
b = blob[p]; p += 1
v |= (b & 0x7F) << s
if not b & 0x80:
return v, p
s += 7
while pos < end:
klen, pos = varint(pos); key = blob[pos:pos + klen]; pos += klen
vlen, pos = varint(pos); val = blob[pos:pos + vlen]; pos += vlen
out.append((key, val))
return out
```
Writing a change back means rebuilding the region and **recomputing both header fields**:
```python
import struct, zlib
body = new_region_bytes
blob = bytearray(capacity) # keep the original file length
struct.pack_into('<I', blob, 0, len(body))
struct.pack_into('<I', blob, 4, zlib.crc32(body) & 0xFFFFFFFF)
blob[8:8 + len(body)] = body
```
Cautions:
- **A wrong crc32 makes the library throw the whole store away.** The visible symptom is "my
edit did nothing" or "the app reset every setting", never an error.
- **Values are raw bytes**, not always text. JSON is common; a serialized protobuf or a small
encrypted payload is also common (`scripts/blob_decode.py` searches the framing).
- **The in-memory copy wins until the process restarts.** `am force-stop` before editing and
re-launch after, or the running app overwrites your change on exit.
- **Do not delete the `.crc` companion.** Leave it in place when hand-editing the store; it is
validated and regenerated by the library.
## SQLite ## SQLite
```bash ```bash
+4 -2
View File
@@ -74,10 +74,12 @@ When you plan to change a request parameter (a path, a `position`, an id), probe
**Repack-hostile (will break a re-signed client):** **Repack-hostile (will break a re-signed client):**
- Signature/certificate checked server-side, or pinned in native code. - Signature/certificate checked server-side, or pinned in native code.
- Request signing derived from the APK signature. - Request signing derived from the APK signature — the client computes with its own
certificate, so re-signing changes the key. Silent and easy to misdiagnose; detection,
extraction and the differential test are in `references/signature-derived-keys.md`.
- Device attestation (Play Integrity / SafetyNet, OEM attestation). - Device attestation (Play Integrity / SafetyNet, OEM attestation).
Determine which you face **before** shipping a repack. A client that authenticates fine on first launch but silently fails features later is the classic signature-validation symptom. Determine which you face **before** shipping a repack. A client that authenticates fine on first launch but silently fails features later is the classic signature-validation symptom — and when computed signing parameters (`sign`, `_p`, `uth`) evaluate to `-1` or empty, it is the signature-derived-key case rather than a server decision.
## What to record ## What to record
+142
View File
@@ -0,0 +1,142 @@
# Signature-derived keys — when re-signing silently breaks the app
A class of app that repack-friendly guidance does not cover: the client feeds **its own APK
signing certificate** into a native routine and uses the result as the key for request
signing or for SDK payload encryption. Re-sign the APK and the key changes, so every signed
request fails — while the app itself launches perfectly.
This is distinct from "the server checks the signature". Here the *client* uses the signature
as key material, and the server was built against the original key. The failure looks like a
server problem and is actually a signing problem.
## How to spot it (four greps, minutes)
```bash
# the usual shape: signature bytes handed to a crypto helper
grep -rn "toCharsString()" <decompiled_sources>
grep -rn "getPackageInfo(.*, *64)" <decompiled_sources> # 64 = GET_SIGNATURES
grep -rn "signatures\[0\]" <decompiled_sources>
# and the same pattern in SDK-internal helpers
grep -rn "signatures\[0\].toByteArray" <decompiled_sources>
```
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 <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 <original.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, "<original-value-hex>"
```
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.
+1 -1
View File
@@ -15,7 +15,7 @@ import java.util.List;
/** /**
* dexlib2 定点 patch:把指定方法的实现整体替换为 return-void(或 return 常量)。 * dexlib2 定点 patch:把指定方法的实现整体替换为 return-void(或 return 常量)。
* *
* 为什么不用 baksmali/smali 整树往返:实测本项目的 classes7.dex 往返重建后运行时报 * 为什么不用 baksmali/smali 整树往返:实测在一个多 dex 应用上,整树往返重建后运行时报
* IncompatibleClassChangeError: Found interface io.ktor.client.engine.HttpClientEngine, * IncompatibleClassChangeError: Found interface io.ktor.client.engine.HttpClientEngine,
* but class was expected * but class was expected
* (R8 生成的 synthetic access bridge 被破坏)。本工具只改目标方法的 code_item, * (R8 生成的 synthetic access bridge 被破坏)。本工具只改目标方法的 code_item,
+274
View File
@@ -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 <package>
python sig_probe.py --live <package> --serial <adb-serial> --host 127.0.0.1:27042
# offline: enumerate candidate blobs inside META-INF/*.RSA
python sig_probe.py --apk <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())