feat: apk-reverse Agent Skill

Progressive-disclosure skill for Android APK reverse engineering:
recon, ad removal, membership limits, dex surgical patching, repack/sign,
runtime data, Frida, environment setup, and a hard-won failure catalogue.
This commit is contained in:
inliver233
2026-09-19 21:25:35 +08:00
commit c66658b6b3
33 changed files with 3713 additions and 0 deletions
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Repack + resign pipeline for the ciyuan Android app.
Pipeline: replace dex files -> strip META-INF/ -> zip (AndroidManifest.xml and
resources.arsc kept STORED) -> zipalign + sign via uber-apk-signer -> verify.
Keystore defaults (generated on first use with keytool):
file : <work>/out/cyc.keystore
alias : cyc
storepass / keypass : cyc123456
Usage examples:
# roundtrip: reuse original dexes unchanged, proves the pipeline itself works
python tools/repack.py --apk work/orig.apk --dexdir work/x_orig --out work/out/roundtrip.apk
# real patch: swap a single dex (name=path), other dexes kept from the apk
python tools/repack.py --apk work/orig.apk --dex classes8.dex=work/x_orig/patch/classes8.dex \
--out work/out/patched.apk
Notes:
* All java tooling is invoked through python subprocess (never through the
PowerShell argument passing path, which drops arguments).
* uber-apk-signer silently skips already signed apks (reports 0 processed),
which is why META-INF/ is always stripped before signing.
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import zipfile
JAVA = r'C:\Program Files\Java\jdk-17.0.4.1\bin\java.exe'
KEYTOOL = r'C:\Program Files\Java\jdk-17.0.4.1\bin\keytool.exe'
JARSIGNER = r'C:\Program Files\Java\jdk-17.0.4.1\bin\jarsigner.exe'
SIGNER_JAR = os.environ.get('APK_SIGNER_JAR', 'uber-apk-signer.jar')
KS_PATH = None # pass --ks on the command line
KS_ALIAS = os.environ.get('APK_KS_ALIAS', 'apkreverse')
KS_PASS = os.environ.get('APK_KS_PASS', '')
STORE_ONLY = ('AndroidManifest.xml', 'resources.arsc')
def log(msg):
print(msg, flush=True)
def run(cmd, **kw):
r = subprocess.run(cmd, capture_output=True, text=True, errors='replace', **kw)
return r
def ensure_keystore(path=KS_PATH, alias=KS_ALIAS, password=KS_PASS):
if os.path.exists(path):
log('[ks] reuse %s' % path)
return path
os.makedirs(os.path.dirname(path), exist_ok=True)
cmd = [KEYTOOL, '-genkeypair', '-keystore', path, '-alias', alias,
'-keyalg', 'RSA', '-keysize', '2048', '-validity', '10000',
'-storepass', password, '-keypass', password,
'-dname', 'CN=apkreverse, OU=dev, O=dev, L=NA, ST=NA, C=NA']
r = run(cmd)
if r.returncode != 0:
log('[ks] keytool failed rc=%d\n%s\n%s' % (r.returncode, r.stdout, r.stderr))
raise SystemExit(1)
log('[ks] generated %s (alias=%s pass=%s)' % (path, alias, password))
return path
def collect_replacement_dex(dexdir=None, dex_pairs=None):
repl = {}
if dexdir:
for name in sorted(os.listdir(dexdir)):
if re.match(r'^classes\d*\.dex$', name):
repl[name] = os.path.join(dexdir, name)
for pair in (dex_pairs or []):
if '=' not in pair:
raise SystemExit('--dex expects name=path, got %r' % pair)
name, p = pair.split('=', 1)
repl[name] = p
return repl
def is_signature_entry(name):
"""True only for JAR/APK signature artifacts directly under META-INF/.
META-INF/services/**, META-INF/androidx/**, META-INF/native-image/** and all
other subdirectories are RUNTIME RESOURCES and must survive repacking.
"""
if not name.upper().startswith('META-INF/'):
return False
rest = name[len('META-INF/'):]
if '/' in rest:
return False
up = rest.upper()
if up == 'MANIFEST.MF':
return True
return up.endswith(('.SF', '.RSA', '.DSA', '.EC'))
def build_unsigned(apk, repl, out_apk, drop_meta=True):
"""Write a new apk with dex replaced and only signature entries dropped.
IMPORTANT (root cause of a crash, do not regress):
Stripping the WHOLE META-INF/ breaks ServiceLoader-based runtime wiring.
This APK ships ServiceLoader registrations that Android reads at runtime:
META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory -> kc
META-INF/services/io.ktor.client.HttpClientEngineContainer -> OkHttpEngineContainer
META-INF/services/io.ktor.serialization.kotlinx.KotlinxSerializationExtensionProvider
META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler -> wc
META-INF/services/com.arialyy.aria.* , META-INF/services/wt2 , META-INF/services/xx1
Deleting them makes the app die at startup with:
IllegalStateException: Module with the Main dispatcher is missing ...
So only signature artifacts are removed here; META-INF subdirectories are kept.
"""
seen = set()
replaced_names = set(repl)
with zipfile.ZipFile(apk, 'r') as zin, zipfile.ZipFile(out_apk, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
name = item.filename
base = os.path.basename(name)
if item.is_dir():
continue
if drop_meta and is_signature_entry(name):
continue
if name in replaced_names:
continue
data = zin.read(name)
keep = name in STORE_ONLY or base in STORE_ONLY
zi = zipfile.ZipInfo(name, date_time=item.date_time)
zi.compress_type = zipfile.ZIP_STORED if keep else zipfile.ZIP_DEFLATED
zi.external_attr = item.external_attr
zi.internal_attr = item.internal_attr
zi.create_system = item.create_system
zout.writestr(zi, data)
seen.add(name)
for name, path in sorted(repl.items()):
with open(path, 'rb') as f:
data = f.read()
zi = zipfile.ZipInfo(name, date_time=(2024, 1, 1, 0, 0, 0))
zi.compress_type = zipfile.ZIP_DEFLATED
zout.writestr(zi, data)
log('[zip] + %s (%d bytes <- %s)' % (name, len(data), path))
seen.add(name)
return seen
def zip_report(apk):
rows = []
with zipfile.ZipFile(apk, 'r') as z:
for i in z.infolist():
if i.filename in STORE_ONLY or os.path.basename(i.filename) in STORE_ONLY:
rows.append('%s method=%d (0=STORED)' % (i.filename, i.compress_type))
if re.match(r'^classes\d*\.dex$', i.filename):
rows.append('%s %d bytes method=%d crc=%08x' % (
i.filename, i.file_size, i.compress_type, i.CRC))
return rows
def sign_apk(unsigned_apk, workdir, ks, alias=KS_ALIAS, password=KS_PASS):
outdir = os.path.join(workdir, 'signed')
if os.path.isdir(outdir):
shutil.rmtree(outdir, ignore_errors=True)
os.makedirs(outdir, exist_ok=True)
cmd = [JAVA, '-jar', SIGNER_JAR,
'--apks', unsigned_apk,
'--ks', ks, '--ksAlias', alias,
'--ksPass', password, '--ksKeyPass', password,
'-o', outdir, '--verbose']
r = run(cmd)
log('[sign] rc=%d' % r.returncode)
log(r.stdout.strip())
if r.stderr.strip():
log('[sign][stderr] ' + r.stderr.strip())
cand = os.path.join(outdir, os.path.basename(unsigned_apk))
if not os.path.exists(cand):
outs = [os.path.join(outdir, f) for f in os.listdir(outdir)] if os.path.isdir(outdir) else []
if not outs:
raise SystemExit('signing produced no output')
cand = outs[0]
return cand, r.stdout
def verify_apk(apk):
log('== verify: %s' % apk)
ok = True
# 1) uber-apk-signer verify (also re-checks zipalign)
r = run([JAVA, '-jar', SIGNER_JAR, '-a', apk, '-y'])
log('[verify:signer] rc=%d\n%s' % (r.returncode, r.stdout.strip()))
if r.returncode != 0 or 'Verified' not in r.stdout:
ok = ok and ('DOES NOT VERIFY' not in r.stdout)
# 2) jarsigner (v1 / jAR signature)
r2 = run([JARSIGNER, '-verify', '-certs', apk])
txt = (r2.stdout or '') + (r2.stderr or '')
log('[verify:jarsigner] rc=%d\n%s' % (r2.returncode, txt.strip()[:2000]))
# 3) keytool cert dump
r3 = run([KEYTOOL, '-printcert', '-jarfile', apk])
log('[verify:keytool] rc=%d\n%s' % (r3.returncode, r3.stdout.strip()[:1500]))
# 4) v2/v3 APK Signing Block presence
with open(apk, 'rb') as f:
blob = f.read()
has_v2 = b'APK Sig Block 42' in blob
log('[verify:v2block] %s' % ('present' if has_v2 else 'MISSING'))
return ok and (r.returncode == 0)
def fingerprint(apk):
r = run([KEYTOOL, '-printcert', '-jarfile', apk])
sha = re.findall(r'(SHA1|SHA256):\s*([0-9A-F:]+)', r.stdout)
return sha
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--apk', required=True)
ap.add_argument('--out', required=True)
ap.add_argument('--dexdir')
ap.add_argument('--dex', action='append', default=[], help='name=path, repeatable')
ap.add_argument('--ks', default=KS_PATH)
ap.add_argument('--no-sign', action='store_true')
ap.add_argument('--workdir', default=None)
args = ap.parse_args()
apk = os.path.abspath(args.apk)
out = os.path.abspath(args.out)
if not os.path.exists(apk):
raise SystemExit('missing apk: %s' % apk)
os.makedirs(os.path.dirname(out), exist_ok=True)
os.makedirs(args.workdir, exist_ok=True)
repl = collect_replacement_dex(args.dexdir, args.dex)
log('[in ] %s (%d bytes)' % (apk, os.path.getsize(apk)))
log('[repl] %s' % ', '.join('%s<-%s' % (k, v) for k, v in sorted(repl.items())))
unsigned = os.path.join(args.workdir, 'unsigned.apk')
if os.path.exists(unsigned):
os.remove(unsigned)
build_unsigned(apk, repl, unsigned)
log('[zip] unsigned written: %d bytes' % os.path.getsize(unsigned))
for row in zip_report(unsigned):
log('[zip] ' + row)
if args.no_sign:
shutil.copy2(unsigned, out)
else:
ks = ensure_keystore(args.ks)
signed, _ = sign_apk(unsigned, args.workdir, ks)
shutil.copy2(signed, out)
log('[out] %s (%d bytes)' % (out, os.path.getsize(out)))
log('== zip layout of final apk')
for row in zip_report(out):
log(' ' + row)
ok = verify_apk(out)
sha = fingerprint(out)
for algo, val in sha:
log('[cert] %s %s' % (algo, val))
log('[result] %s' % ('OK' if ok else 'CHECK-FAILED'))
return 0 if ok else 2
if __name__ == '__main__':
sys.exit(main())