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
+1 -1
View File
@@ -15,7 +15,7 @@ import java.util.List;
/**
* dexlib2 定点 patch:把指定方法的实现整体替换为 return-void(或 return 常量)。
*
* 为什么不用 baksmali/smali 整树往返:实测本项目的 classes7.dex 往返重建后运行时报
* 为什么不用 baksmali/smali 整树往返:实测在一个多 dex 应用上,整树往返重建后运行时报
* IncompatibleClassChangeError: Found interface io.ktor.client.engine.HttpClientEngine,
* but class was expected
* (R8 生成的 synthetic access bridge 被破坏)。本工具只改目标方法的 code_item,
+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())