Broaden skill with unpacking, TLS triage, third-party build auditing, runtime probe
references: recon gains packer identification + unpacking workflow (payload/plaintext size mapping, dump validation, hash de-duplication, business-dex heuristic); repack-and-sign gains the de-shelled repack pipeline, why apktool over binary AXML, and the apksigner scheme-selection caveat; new tls-and-cert documents feature-scoped network failures, dual trust chains and the fix template; new third-party-builds covers auditing a cracked/modded APK before trusting it; dynamic-frida gains version pinning, remote-device path, the four-layer probe and hook-never-fires triage; environment gains UI automation pitfalls; ad-removal gains the single convergence point, dynamic-plugin SDKs and the global timestamp gate; pitfalls gains P13-P17; verification gains rung 7 and the three-signal ad check. scripts: new frida_probe.js (four-layer runtime probe), run_probe.py and tls_check.py; repack.py fixes two crash-on-default bugs and a misleading META-INF note; datastore_inject.py, PatchMethodExample.java and build_scripts.py lose hardcoded values - the example class name did not match its file and could not compile.
This commit is contained in:
+298
-99
@@ -1,46 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Repack + resign pipeline for the ciyuan Android app.
|
||||
"""Repack an APK with replaced dex files, drop ONLY the signature entries, and re-sign.
|
||||
|
||||
Pipeline: replace dex files -> strip META-INF/ -> zip (AndroidManifest.xml and
|
||||
resources.arsc kept STORED) -> zipalign + sign via uber-apk-signer -> verify.
|
||||
Pipeline: replace dex files -> drop signature artifacts -> zip, keeping
|
||||
AndroidManifest.xml and resources.arsc STORED -> zipalign + sign -> verify.
|
||||
|
||||
Keystore defaults (generated on first use with keytool):
|
||||
file : <work>/out/cyc.keystore
|
||||
alias : cyc
|
||||
storepass / keypass : cyc123456
|
||||
Everything machine-specific is a parameter or resolved from PATH. No JDK path is
|
||||
baked in, no keystore password is baked in, no target app is assumed.
|
||||
|
||||
Keystore handling:
|
||||
--ks <path> is where the keystore lives. If it does not exist, one is generated
|
||||
with keytool and its password is stored next to it as <ks>.pass.txt, so the next
|
||||
run reuses it. Passwords are never hardcoded in this script. That file is a local
|
||||
artifact -- do not commit it.
|
||||
|
||||
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
|
||||
# roundtrip: reuse the original dexes unchanged, proves the pipeline itself works
|
||||
python 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 \
|
||||
python repack.py --apk work/orig.apk --dex classes8.dex=work/patch/classes8.dex \
|
||||
--out work/out/patched.apk
|
||||
|
||||
# no signing, no java tooling required at all
|
||||
python repack.py --apk work/orig.apk --dexdir work/x_orig --out work/unsigned.apk --no-sign
|
||||
|
||||
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.
|
||||
* All java tooling is invoked through python subprocess, never through a host
|
||||
shell (PowerShell drops arguments in ways that look like tool failures).
|
||||
* uber-apk-signer silently skips already-signed apks (reports 0 processed), which
|
||||
is why signature entries are always dropped before signing.
|
||||
* uber-apk-signer is used for SIGNING to keep the verified path unchanged;
|
||||
apksigner is used for VERIFYING when present, with an explicit sdk range.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
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')
|
||||
|
||||
|
||||
@@ -49,27 +50,118 @@ def log(msg):
|
||||
|
||||
|
||||
def run(cmd, **kw):
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, errors='replace', **kw)
|
||||
return r
|
||||
return subprocess.run(cmd, capture_output=True, text=True, errors='replace', **kw)
|
||||
|
||||
|
||||
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,
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool resolution: PATH first, explicit flag second, never a baked-in path.
|
||||
# ---------------------------------------------------------------------------
|
||||
def sibling_tool(exe, name):
|
||||
"""Look for `name` next to `exe`, for JDKs whose bin dir is not on PATH."""
|
||||
if not exe:
|
||||
return None
|
||||
folder = os.path.dirname(exe)
|
||||
for cand in (name, name + '.exe', name + '.bat'):
|
||||
path = os.path.join(folder, cand)
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def resolve_tool(name, explicit=None, hint_exe=None):
|
||||
if explicit:
|
||||
return explicit
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
return found
|
||||
return sibling_tool(hint_exe, name)
|
||||
|
||||
|
||||
def resolve_tools(args):
|
||||
"""Locate java/keytool/jarsigner/zipalign/apksigner. Missing ones stay None."""
|
||||
java = resolve_tool('java', args.java)
|
||||
tools = {
|
||||
'java': java,
|
||||
'keytool': resolve_tool('keytool', args.keytool, java),
|
||||
'jarsigner': resolve_tool('jarsigner', args.jarsigner, java),
|
||||
'zipalign': resolve_tool('zipalign', args.zipalign),
|
||||
'apksigner': resolve_tool('apksigner', args.apksigner),
|
||||
}
|
||||
return tools
|
||||
|
||||
|
||||
def require(tools, key, why):
|
||||
if tools.get(key):
|
||||
return tools[key]
|
||||
raise SystemExit(
|
||||
"error: '%s' was not found on PATH, and it is needed to %s.\n"
|
||||
" install a JDK (17+ recommended) / Android build-tools and put them on PATH,\n"
|
||||
" or pass the path explicitly (--%s <path>).\n"
|
||||
" Path lookup is deliberate: a machine-specific absolute path must never be\n"
|
||||
" baked into this script." % (key, why, key))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Keystore
|
||||
# ---------------------------------------------------------------------------
|
||||
def read_pwd_file(path):
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding='utf-8') as fh:
|
||||
txt = fh.read().strip()
|
||||
return txt or None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def ensure_keystore(ks_path, alias, password, keytool):
|
||||
"""Return the keystore password, generating the keystore on first use."""
|
||||
pwd_file = ks_path + '.pass.txt'
|
||||
|
||||
if os.path.exists(ks_path):
|
||||
pwd = password or read_pwd_file(pwd_file)
|
||||
if not pwd:
|
||||
raise SystemExit(
|
||||
'error: keystore %s exists but its password is unknown.\n'
|
||||
' pass --ks-pass <pw>, or write the password into %s' % (ks_path, pwd_file))
|
||||
log('[ks] reuse %s (alias=%s)' % (ks_path, alias))
|
||||
return pwd
|
||||
|
||||
pwd = password or read_pwd_file(pwd_file)
|
||||
generated = pwd is None
|
||||
if generated:
|
||||
# keytool rejects passwords shorter than 6 characters.
|
||||
pwd = secrets.token_urlsafe(12)
|
||||
|
||||
folder = os.path.dirname(ks_path)
|
||||
if folder:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
cmd = [keytool, '-genkeypair', '-keystore', ks_path, '-alias', alias,
|
||||
'-keyalg', 'RSA', '-keysize', '2048', '-validity', '10000',
|
||||
'-storepass', password, '-keypass', password,
|
||||
'-storepass', pwd, '-keypass', pwd,
|
||||
'-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
|
||||
|
||||
try:
|
||||
with open(pwd_file, 'w', encoding='utf-8') as fh:
|
||||
fh.write(pwd + '\n')
|
||||
os.chmod(pwd_file, 0o600)
|
||||
except OSError as exc:
|
||||
log('[ks] could not persist the password: %s' % exc)
|
||||
|
||||
log('[ks] generated %s (alias=%s)' % (ks_path, alias))
|
||||
if generated:
|
||||
log('[ks] password stored in %s -- local artifact, do not commit it' % pwd_file)
|
||||
return pwd
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Packing
|
||||
# ---------------------------------------------------------------------------
|
||||
def collect_replacement_dex(dexdir=None, dex_pairs=None):
|
||||
repl = {}
|
||||
if dexdir:
|
||||
@@ -79,16 +171,17 @@ def collect_replacement_dex(dexdir=None, dex_pairs=None):
|
||||
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
|
||||
name, path = pair.split('=', 1)
|
||||
repl[name] = path
|
||||
return repl
|
||||
|
||||
|
||||
def is_signature_entry(name):
|
||||
"""True only for JAR/APK signature artifacts directly under META-INF/.
|
||||
"""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.
|
||||
META-INF/services/**, META-INF/androidx/**, META-INF/native-image/** and every
|
||||
other subdirectory are RUNTIME RESOURCES and must survive repacking. Anything
|
||||
that is not directly under META-INF/ is kept by construction.
|
||||
"""
|
||||
if not name.upper().startswith('META-INF/'):
|
||||
return False
|
||||
@@ -101,20 +194,20 @@ def is_signature_entry(name):
|
||||
return up.endswith(('.SF', '.RSA', '.DSA', '.EC'))
|
||||
|
||||
|
||||
def build_unsigned(apk, repl, out_apk, drop_meta=True):
|
||||
def build_unsigned(apk, repl, out_apk, drop_signatures=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:
|
||||
IMPORTANT (root cause of a startup crash, do not regress):
|
||||
Stripping the WHOLE META-INF/ breaks ServiceLoader-based runtime wiring. Android
|
||||
reads these registries at runtime, and they live in the APK:
|
||||
META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory
|
||||
META-INF/services/io.ktor.client.HttpClientEngineContainer
|
||||
META-INF/services/<lib>.core.* (third-party SDK registries)
|
||||
META-INF/services/<obfuscated-class-name> (R8-renamed providers)
|
||||
Deleting them makes the app die at startup with something like:
|
||||
IllegalStateException: Module with the Main dispatcher is missing ...
|
||||
So only signature artifacts are removed here; META-INF subdirectories are kept.
|
||||
and the message never points at META-INF, so the cause is very hard to find.
|
||||
Therefore: drop signature artifacts only, keep every META-INF subdirectory.
|
||||
"""
|
||||
seen = set()
|
||||
replaced_names = set(repl)
|
||||
@@ -124,7 +217,8 @@ def build_unsigned(apk, repl, out_apk, drop_meta=True):
|
||||
base = os.path.basename(name)
|
||||
if item.is_dir():
|
||||
continue
|
||||
if drop_meta and is_signature_entry(name):
|
||||
if drop_signatures and is_signature_entry(name):
|
||||
log('[zip] - %s (signature artifact)' % name)
|
||||
continue
|
||||
if name in replaced_names:
|
||||
continue
|
||||
@@ -138,8 +232,8 @@ def build_unsigned(apk, repl, out_apk, drop_meta=True):
|
||||
zout.writestr(zi, data)
|
||||
seen.add(name)
|
||||
for name, path in sorted(repl.items()):
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read()
|
||||
with open(path, 'rb') as fh:
|
||||
data = fh.read()
|
||||
zi = zipfile.ZipInfo(name, date_time=(2024, 1, 1, 0, 0, 0))
|
||||
zi.compress_type = zipfile.ZIP_DEFLATED
|
||||
zout.writestr(zi, data)
|
||||
@@ -160,12 +254,23 @@ def zip_report(apk):
|
||||
return rows
|
||||
|
||||
|
||||
def sign_apk(unsigned_apk, workdir, ks, alias=KS_ALIAS, password=KS_PASS):
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sign / verify
|
||||
# ---------------------------------------------------------------------------
|
||||
def sign_apk(unsigned_apk, workdir, ks, alias, password, tools, signer_jar):
|
||||
java = require(tools, 'java', 'run the signer jar')
|
||||
if not os.path.exists(signer_jar):
|
||||
raise SystemExit(
|
||||
'error: signer jar not found: %s\n'
|
||||
' pass --signer-jar <path>, or set APK_SIGNER_JAR.\n'
|
||||
' Any zipalign+apksigner based signer works here.' % signer_jar)
|
||||
|
||||
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,
|
||||
|
||||
cmd = [java, '-jar', signer_jar,
|
||||
'--apks', unsigned_apk,
|
||||
'--ks', ks, '--ksAlias', alias,
|
||||
'--ksPass', password, '--ksKeyPass', password,
|
||||
@@ -175,67 +280,149 @@ def sign_apk(unsigned_apk, workdir, ks, alias=KS_ALIAS, password=KS_PASS):
|
||||
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 []
|
||||
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
|
||||
return cand
|
||||
|
||||
|
||||
def verify_apk(apk):
|
||||
def verify_apk(apk, tools, signer_jar=None, expect_signed=True):
|
||||
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)
|
||||
sig_checks = 0 # real signature-verification tools that actually ran
|
||||
|
||||
# 1) apksigner -- the authoritative check, but ONLY with an explicit sdk range.
|
||||
#
|
||||
# READ THIS BEFORE CONCLUDING THE SIGNATURE IS BROKEN:
|
||||
# `apksigner verify` with no range checks the signature schemes implied by the
|
||||
# APK's own minSdkVersion. With minSdk >= 24 it prints v1/v2 as false while the
|
||||
# files in META-INF are perfectly fine, which looks exactly like a failed signing
|
||||
# step. Always pass --min-sdk-version / --max-sdk-version so the v1/v2/v3 results
|
||||
# are meaningful.
|
||||
if tools.get('apksigner'):
|
||||
sig_checks += 1
|
||||
r = run([tools['apksigner'], 'verify', '--print-certs', '--verbose',
|
||||
'--min-sdk-version', '21', '--max-sdk-version', '34', apk])
|
||||
log('[verify:apksigner] rc=%d\n%s' % (r.returncode, ((r.stdout or '') +
|
||||
(r.stderr or '')).strip()[:3000]))
|
||||
if r.returncode != 0:
|
||||
ok = False
|
||||
else:
|
||||
log('[verify:apksigner] skipped: apksigner not on PATH.\n'
|
||||
' Do NOT judge the signature from a bare `apksigner verify`: with\n'
|
||||
' minSdk >= 24 its default range reports v1/v2 as false even when the\n'
|
||||
' signature is valid. The correct invocation is:\n'
|
||||
' apksigner verify --print-certs --verbose --min-sdk-version 21 '
|
||||
'--max-sdk-version 34 <apk>')
|
||||
|
||||
# 2) signer jar's own verify (also re-checks alignment)
|
||||
if signer_jar and os.path.exists(signer_jar) and tools.get('java'):
|
||||
sig_checks += 1
|
||||
r = run([tools['java'], '-jar', signer_jar, '-a', apk, '-y'])
|
||||
log('[verify:signer] rc=%d\n%s' % (r.returncode, (r.stdout or '').strip()))
|
||||
if r.returncode != 0 and 'DOES NOT VERIFY' in (r.stdout or ''):
|
||||
ok = False
|
||||
|
||||
# 3) jarsigner (v1 / JAR signature)
|
||||
if tools.get('jarsigner'):
|
||||
sig_checks += 1
|
||||
r = run([tools['jarsigner'], '-verify', '-certs', apk])
|
||||
txt = ((r.stdout or '') + (r.stderr or '')).strip()
|
||||
log('[verify:jarsigner] rc=%d\n%s' % (r.returncode, txt[:2000]))
|
||||
if 'jar verified' not in txt and 'verified' not in txt.lower():
|
||||
log('[verify:jarsigner] note: not reported as verified (see output above)')
|
||||
|
||||
# 4) keytool cert dump
|
||||
if tools.get('keytool'):
|
||||
r = run([tools['keytool'], '-printcert', '-jarfile', apk])
|
||||
log('[verify:keytool] rc=%d\n%s' % (r.returncode, (r.stdout or '').strip()[:1500]))
|
||||
|
||||
# 5) zipalign check
|
||||
if tools.get('zipalign'):
|
||||
r = run([tools['zipalign'], '-c', '-v', '4', apk])
|
||||
log('[verify:zipalign] rc=%d' % r.returncode)
|
||||
if r.returncode != 0:
|
||||
log((r.stdout or '').strip()[:1500])
|
||||
else:
|
||||
log('[verify:zipalign] skipped: zipalign not on PATH')
|
||||
|
||||
# 6) v2/v3 APK Signing Block presence, read straight from the file
|
||||
with open(apk, 'rb') as fh:
|
||||
blob = fh.read()
|
||||
log('[verify:v2block] %s' % ('present' if b'APK Sig Block 42' in blob else 'MISSING'))
|
||||
|
||||
# A build nobody could verify is not a passing build. Saying OK here would
|
||||
# violate the first rule of this skill ("an APK is not done until it is
|
||||
# verified"): report UNVERIFIED and fail instead.
|
||||
if expect_signed and sig_checks == 0:
|
||||
log('[result] UNVERIFIED: no signature verification tool was available '
|
||||
'(apksigner / jarsigner / signer jar). This is NOT an OK result.')
|
||||
return False
|
||||
return ok
|
||||
|
||||
|
||||
def fingerprint(apk):
|
||||
r = run([KEYTOOL, '-printcert', '-jarfile', apk])
|
||||
sha = re.findall(r'(SHA1|SHA256):\s*([0-9A-F:]+)', r.stdout)
|
||||
return sha
|
||||
def fingerprint(apk, tools):
|
||||
if not tools.get('keytool'):
|
||||
return []
|
||||
r = run([tools['keytool'], '-printcert', '-jarfile', apk])
|
||||
return re.findall(r'(SHA1|SHA256):\s*([0-9A-F:]+)', r.stdout or '')
|
||||
|
||||
|
||||
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)
|
||||
ap = argparse.ArgumentParser(
|
||||
description='Repack an APK with replaced dex files, drop only the signature '
|
||||
'entries, re-sign and verify.')
|
||||
ap.add_argument('--apk', required=True, help='source apk to use as the template')
|
||||
ap.add_argument('--out', required=True, help='output apk path')
|
||||
ap.add_argument('--dexdir', help='directory of classes*.dex to swap in')
|
||||
ap.add_argument('--dex', action='append', default=[],
|
||||
help='name=path, repeatable, e.g. classes8.dex=work/patch/classes8.dex')
|
||||
ap.add_argument('--workdir', default=None,
|
||||
help='scratch directory (default: next to --out)')
|
||||
ap.add_argument('--ks', default=None, help='keystore path (created on first use)')
|
||||
ap.add_argument('--ks-alias', default='apkreverse', help='keystore alias')
|
||||
ap.add_argument('--ks-pass', default=None,
|
||||
help='keystore password; default: <ks>.pass.txt, else generated')
|
||||
ap.add_argument('--signer-jar', default=None,
|
||||
help='jar used for zipalign+sign (default: $APK_SIGNER_JAR or '
|
||||
'uber-apk-signer.jar)')
|
||||
ap.add_argument('--no-sign', action='store_true', help='stop after writing the apk')
|
||||
ap.add_argument('--java', default=None, help='path to java (default: from PATH)')
|
||||
ap.add_argument('--keytool', default=None, help='path to keytool (default: from PATH)')
|
||||
ap.add_argument('--jarsigner', default=None, help='path to jarsigner (default: from PATH)')
|
||||
ap.add_argument('--zipalign', default=None, help='path to zipalign (default: from PATH)')
|
||||
ap.add_argument('--apksigner', default=None, help='path to apksigner (default: from PATH)')
|
||||
args = ap.parse_args()
|
||||
|
||||
tools = resolve_tools(args)
|
||||
signer_jar = args.signer_jar or os.environ.get('APK_SIGNER_JAR', 'uber-apk-signer.jar')
|
||||
|
||||
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)
|
||||
|
||||
# Default the scratch dir next to --out; never to a machine-specific path.
|
||||
workdir = os.path.abspath(args.workdir) if args.workdir else os.path.dirname(out)
|
||||
if not workdir:
|
||||
workdir = os.getcwd()
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
out_folder = os.path.dirname(out)
|
||||
if out_folder:
|
||||
os.makedirs(out_folder, 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())))
|
||||
log('[tools] java=%s keytool=%s apksigner=%s zipalign=%s'
|
||||
% (tools['java'], tools['keytool'], tools['apksigner'], tools['zipalign']))
|
||||
log('[repl] %s' % (', '.join('%s<-%s' % (k, v) for k, v in sorted(repl.items())) or '<none>'))
|
||||
|
||||
unsigned = os.path.join(args.workdir, 'unsigned.apk')
|
||||
unsigned = os.path.join(workdir, 'unsigned.apk')
|
||||
if os.path.exists(unsigned):
|
||||
os.remove(unsigned)
|
||||
build_unsigned(apk, repl, unsigned)
|
||||
@@ -246,19 +433,31 @@ def main():
|
||||
if args.no_sign:
|
||||
shutil.copy2(unsigned, out)
|
||||
else:
|
||||
ks = ensure_keystore(args.ks)
|
||||
signed, _ = sign_apk(unsigned, args.workdir, ks)
|
||||
if not args.ks:
|
||||
raise SystemExit(
|
||||
"error: --ks is required when signing (there is no default keystore).\n"
|
||||
" e.g. --ks work/out/release.keystore")
|
||||
keytool = require(tools, 'keytool', 'create the keystore on first use')
|
||||
password = ensure_keystore(os.path.abspath(args.ks), args.ks_alias,
|
||||
args.ks_pass, keytool)
|
||||
signed = sign_apk(unsigned, workdir, os.path.abspath(args.ks), args.ks_alias,
|
||||
password, tools, signer_jar)
|
||||
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'))
|
||||
|
||||
ok = verify_apk(out, tools, signer_jar, expect_signed=not args.no_sign)
|
||||
if not args.no_sign:
|
||||
for algo, val in fingerprint(out, tools):
|
||||
log('[cert] %s %s' % (algo, val))
|
||||
log('[result] %s' % ('OK' if ok else 'CHECK-FAILED'))
|
||||
else:
|
||||
log('[result] UNSIGNED (--no-sign): good for inspection, not installable as-is')
|
||||
log('[reminder] repacking is not done until the app launches and the changed '
|
||||
'behavior is exercised on a device: see scripts/install_test.py')
|
||||
return 0 if ok else 2
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user