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:
@@ -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