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:
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
- **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 <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>)
|
||||
├── files/
|
||||
│ ├── datastore/*.preferences_pb AndroidX DataStore (protobuf)
|
||||
│ ├── mmkv/<name> 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 (`<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
|
||||
|
||||
```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):**
|
||||
- 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).
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user