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:
@@ -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
|
||||||
|
|||||||
@@ -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`. |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user