Files
apk-reverse/scripts/coldstart.py
T
inliver233 a552157f1b Close the byte-patching gap, add the server-config path, fix two script crashes
Worked end-to-end on a real unobfuscated commercial app (single-dex, no packer,
server-driven launch screen) and folded back everything that cost time. Net +17
files, no new dependencies, no sample-specific identifiers.

New references
  byte-level-patching.md
    The technique that was missing entirely. Equal-length in-place edits beat
    method rebuilding (measured on a real sample: method rewrite grew debug_info
    924 B -> 22.8 KB and the dex 4.32 MB -> 7.73 MB), but four failure modes are
    silent: same-length is not enough, branch polarity, verifier legality, and
    stale dex header fields. Covers locating an instruction's exact offset (why
    listings and byte-pattern guesses both fail), the instruction width table and
    the two traps that desynchronise a decode, neutralise-a-branch vs redirect-it,
    and the header's signature-then-checksum order.
  server-config-and-updates.md
    The most common shape of "ad" in a modern app, and the one usually
    mis-diagnosed: the server supplies UI the client renders. The two-layer fetch
    (local fallback then remote override) that proves it, finding the config DTOs
    through the field names data classes keep, why you patch the decision and not
    the data, deciding the scope of "remove", and remote re-enable / cached config
    durability.

New scripts
  dexutil.py             dependency-free dex reader: structural walk, exact
                         instruction decode, dex header recompute/verify in the
                         correct order, branch-target and operand helpers
  dex_find_insn.py       locate an instruction by decoded semantics, print its exact
                         byte offset with context and BOTH sides of any branch
  dex_patch_bytes.py     equal-length byte patches from a JSON spec: semantic match,
                         polarity pin via expect_next, equal-length enforcement,
                         verifier check, header recompute, re-decode to prove it landed
  dex_check_verifier.py  does any conditional branch target a move-result (bypassing
                         its producer)? Compares builds and separates pre-existing
                         findings from regressions the patch introduced
  coldstart.py           cold-launch capture: timed screenshot burst + logcat signals
                         + installed-build facts + launch timing, and a warning when
                         the foreground activity is not the app
  check_repo.py          maintenance check: every script parses and answers --help,
                         every referenced path resolves, every file is documented

Fixes found while running the above
  repack.py       wrote archives with a plain zipfile writer, which cannot satisfy
                  the Android R+ install gate: resources.arsc must be STORED *and*
                  4-byte aligned, and a zip extra record cannot express a 1-3 byte
                  pad (a stored filler entry is inserted instead). Also fixes the
                  central-directory pointer and adds an alignment gate to the output.
  usb_net_proxy.py, dex_strpatch.py
                  crashed on --help with a traceback instead of printing usage.

Updated
  toolchain.md            ddc added (single-binary dex->Java with query subcommands,
                          with measured timings and its real limits); "not on PATH is
                          not not-installed" with the search commands; which signer
                          to use and why jarsigner breaks alignment
  repack-and-sign.md      alignment requirement and its arithmetic; dex header
                          recompute; jarsigner hazard; vendor install interception
                          ([N] with no symbolic constant -> root pm install); the
                          installer can keep owning the screen after an install
  recon.md                resolve package identity from a tool, not from hand-read
                          AXML strings (a wrong package sends all later device queries
                          nowhere); snapshot the config surface during recon
  long-task-discipline.md captures you never looked at are not evidence (incl. the
                          consistent-but-wrong-window variant); long-context decay and
                          the record shape that survives it
  dynamic-frida.md        when live attach cannot work at all: static instrumentation,
                          static read, and self-recorded evidence, with the control
                          build caveat each one carries
  ad-removal.md           server-driven config is usually the EASIEST case, not the
                          hardest; a three-way zero result is a positive finding;
                          scope note on local brand splash vs promotional content
  pitfalls.md             P37 stale dex header checksum, P38 [-124] alignment,
                          P39 bare-numeric install failure, P40 wrong-window evidence,
                          P41 inverted branch polarity, P42 decode desynchronisation
  SKILL.md, README.md     symptom index, reference/script indexes and requirements
                          updated for all of the above
2026-09-20 02:05:41 +08:00

305 lines
12 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Cold-launch an app and capture a time line of screenshots plus logcat signals.
WHY THIS EXISTS
---------------
"Did the patch work?" for anything user-visible -- a splash/ad gate, a forced
update dialog, a login wall, a crash-on-start -- is answered by looking at the
screen over the first seconds of a launch, and by comparison against the
unmodified build. Doing that by hand produces two failures that look like
findings and are not:
* **Sampling is not observation.** A dialog that appears and is then occluded
can fall entirely between samples, and every frame you happened to take shows
something else, which feels like corroboration. This tool captures on a fixed
short cadence and, crucially, prints what it captured so a human or agent
actually LOOKS at the frames.
* **`am start -W` can block past a naive timeout.** It waits for the first
frame; on some ROMs that never arrives while another window owns the display
and the command simply hangs. This tool launches without `-W` and derives the
time line from captures plus logcat instead.
It also records the two facts that decide whether an install even happened:
the installed version, and the on-disk APK hash. "Install succeeded" describes
the request, not the app on disk.
IMPORTANT -- before trusting any screenshot, confirm the foreground activity is
your target. A vendor package installer left on screen (a pending confirmation
from an earlier `adb install`, for example) will be captured instead of your app
and looks completely plausible. `--expect-activity` makes that check automatic.
Usage
-----
python coldstart.py --serial <SERIAL> --pkg com.example \\
--activity com.example/.MainActivity --duration 14 --interval 0.8 \\
--out work/verify/orig --expect-activity com.example/.MainActivity
Requires: adb. `su` on the device is used when available for version/hash facts,
otherwise it degrades to plain `pm`/`dumpsys`.
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import time
def run(cmd, timeout=30, serial=None):
"""Run a command with a hard timeout; never let a call stall the run."""
try:
return subprocess.run(cmd, capture_output=True, timeout=timeout,
text=True, errors='replace')
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(cmd, 124, '', 'timeout after %ss' % timeout)
class Device(object):
def __init__(self, serial, use_su=True, timeout=30):
self.serial = serial
self.timeout = timeout
self.adb = shutil.which('adb') or 'adb'
self.su = use_su and self._su_works()
def _su_works(self):
if self.serial:
r = run(['adb', '-s', self.serial, 'shell', "su -c 'id'"], 15)
else:
r = run(['adb', 'shell', "su -c 'id'"], 15)
return r.returncode == 0 and 'uid=0' in (r.stdout or '')
def shell(self, cmd, timeout=None):
"""Run a device shell command, preferring root when available."""
t = timeout or self.timeout
if self.serial:
base = ['adb', '-s', self.serial, 'shell']
else:
base = ['adb', 'shell']
if self.su:
return run(base + ["su -c '%s'" % cmd.replace("'", "'\\''")], t)
return run(base + [cmd], t)
def screencap(self, path, timeout=30):
"""Capture one PNG. Returns bytes written, or -1 on failure."""
if self.serial:
base = ['adb', '-s', self.serial]
else:
base = ['adb']
try:
with open(path, 'wb') as fh:
r = subprocess.run(base + ['exec-out', 'screencap', '-p'],
stdout=fh, timeout=timeout)
size = os.path.getsize(path)
if size < 8:
return -1
with open(path, 'rb') as fh:
if fh.read(8) != b'\x89PNG\r\n\x1a\n':
return -1
return size
except subprocess.TimeoutExpired:
return -1
def foreground_activity(dev):
r = dev.shell('dumpsys activity activities | grep -m1 ResumedActivity')
m = re.search(r'([A-Za-z0-9_.]+)/([A-Za-z0-9_.$]+)', r.stdout or '')
return '%s/%s' % (m.group(1), m.group(2)) if m else None
def installed_facts(dev, pkg):
facts = {}
r = dev.shell("dumpsys package %s | grep -E 'versionCode|versionName|firstInstallTime|lastUpdateTime'" % pkg)
for line in (r.stdout or '').splitlines():
m = re.match(r'\s*(versionCode|versionName|firstInstallTime|lastUpdateTime)=(.*)', line)
if m:
facts[m.group(1)] = m.group(2).strip()
r = dev.shell('ls /data/app/*/%s*/base.apk 2>/dev/null' % pkg)
apk_path = (r.stdout or '').strip().splitlines()[:1]
if apk_path:
facts['apk_path'] = apk_path[0]
r2 = dev.shell('sha256sum %s' % apk_path[0])
m = re.search(r'([0-9a-f]{64})', r2.stdout or '')
if m:
facts['apk_sha256'] = m.group(1)
r = dev.shell('dumpsys package %s | grep -m1 userId=' % pkg)
m = re.search(r'userId=(\d+)', r.stdout or '')
if m:
facts['uid'] = m.group(1)
return facts
def signal_counts(log_path, patterns):
"""Count occurrences of each pattern in a logcat dump."""
try:
with open(log_path, encoding='utf-8', errors='replace') as fh:
text = fh.read()
except OSError:
return {}
return {name: len(re.findall(rx, text)) for name, rx in patterns.items()}
SIGNAL_PATTERNS = {
'FATAL EXCEPTION': r'FATAL EXCEPTION',
'VerifyError': r'VerifyError',
'Bad checksum': r'Bad checksum',
'IncompatibleClassChangeError': r'IncompatibleClassChangeError',
'ClassNotFoundException': r'ClassNotFoundException',
'uncaughtException': r'uncaughtException',
'am_crash': r'am_crash',
'ANR': r'ANR in ',
'Displayed': r'Displayed .*\+([0-9]+)ms',
}
def main(argv):
ap = argparse.ArgumentParser(
description='Cold-launch an app and capture a screenshot time line plus '
'logcat signals, for before/after comparison.',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__.split('Usage')[-1][:1200])
ap.add_argument('--serial', help='adb serial (REQUIRED when several devices are '
'online, so the wrong one is not picked at random)')
ap.add_argument('--pkg', required=True, help='package name')
ap.add_argument('--activity', help='component to start (default: monkey/launcher)')
ap.add_argument('--out', default='coldstart', help='output directory')
ap.add_argument('--duration', type=float, default=14.0, help='seconds to capture')
ap.add_argument('--interval', type=float, default=0.8, help='seconds between frames')
ap.add_argument('--expect-activity', help='warn if the foreground activity is not '
'this (and not the package at all)')
ap.add_argument('--no-su', action='store_true', help='never use device root')
ap.add_argument('--keep-old', action='store_true',
help='do not clear existing screenshots in the output dir')
args = ap.parse_args(argv[1:])
if not shutil.which('adb'):
print('error: adb not found on PATH')
return 2
if not args.serial:
r = run(['adb', 'devices'])
online = [l.split()[0] for l in (r.stdout or '').splitlines()[1:]
if len(l.split()) >= 2 and l.split()[1] == 'device']
if len(online) > 1:
print('error: %d devices online (%s) and no --serial given. adb would '
'pick one at random and you would debug the wrong target.'
% (len(online), ', '.join(online)))
return 2
if online:
args.serial = online[0]
print('[i] single device online; using %s' % args.serial)
dev = Device(args.serial, use_su=not args.no_su)
print('[i] serial=%s root=%s' % (args.serial, dev.su))
os.makedirs(args.out, exist_ok=True)
shots = os.path.join(args.out, 'shots')
os.makedirs(shots, exist_ok=True)
if not args.keep_old:
for f in os.listdir(shots):
if f.endswith('.png'):
os.remove(os.path.join(shots, f))
facts = installed_facts(dev, args.pkg)
print('\n== installed build facts')
for k in sorted(facts):
print(' %-18s %s' % (k, facts[k]))
if not facts.get('versionCode'):
print(' WARNING: no versionCode found -- the package may not be installed')
print(' (install success describes the request, not the app on disk; the '
'sha256 above is the on-disk truth)')
print('\n== clearing logcat')
dev.shell('logcat -c', timeout=20)
print('== force-stopping %s' % args.pkg)
dev.shell('am force-stop %s' % args.pkg, timeout=20)
print('== launching')
t0 = time.time()
if args.activity:
r = dev.shell('am start -n %s' % args.activity, timeout=25)
else:
r = dev.shell('monkey -p %s -c android.intent.category.LAUNCHER 1' % args.pkg,
timeout=25)
out = ((r.stdout or '') + (r.stderr or '')).strip()
print(' %s' % (out.splitlines()[0] if out else '(no output)'))
if 'Error' in out or 'Exception' in out:
print(' LAUNCH ERROR -- read the line above before blaming the patch')
# live logcat into a file while we capture
log_path = os.path.join(args.out, 'logcat.txt')
if args.serial:
log_cmd = ['adb', '-s', args.serial, 'logcat', '-v', 'threadtime']
else:
log_cmd = ['adb', 'logcat', '-v', 'threadtime']
log_fh = open(log_path, 'w', encoding='utf-8', errors='replace')
log_proc = subprocess.Popen(log_cmd, stdout=log_fh, stderr=subprocess.DEVNULL)
frames = []
idx = 0
try:
while True:
el = time.time() - t0
if el > args.duration:
break
name = 'f%02d_%05.2fs.png' % (idx, el)
path = os.path.join(shots, name)
n = dev.screencap(path)
frames.append((name, n, el))
print(' %-20s %s' % (name, ('%d bytes' % n) if n > 0 else 'CAPTURE FAILED'))
idx += 1
time.sleep(max(0.0, args.interval))
finally:
time.sleep(0.5)
log_proc.terminate()
try:
log_proc.wait(timeout=10)
except subprocess.TimeoutExpired:
log_proc.kill()
log_fh.close()
fg = foreground_activity(dev)
print('\n== foreground at end: %s' % fg)
if args.expect_activity and fg and args.pkg not in fg:
print(' *** FOREGROUND IS NOT YOUR APP (%s) ***' % fg)
print(' Everything captured above shows something else -- most often a '
'vendor package-installer confirmation left over from an install.')
print(' Clear it (e.g. close the installer, press HOME) and re-run; do not '
'read these frames as evidence about your build.')
counts = signal_counts(log_path, SIGNAL_PATTERNS)
print('\n== logcat signals (%s)' % log_path)
for name in SIGNAL_PATTERNS:
print(' %-32s %s' % (name, counts.get(name, 0)))
m = re.findall(r'Displayed ([^:]+): \+([0-9]+)ms', open(log_path, encoding='utf-8',
errors='replace').read())
if m:
print('\n== launch timing (logcat Displayed)')
for comp, ms in m:
print(' %s %s ms' % (comp.strip(), ms))
else:
print('\n no "Displayed" line: the launch may not have reached a first '
'frame (a hang or a blocking dialog looks exactly like this)')
zero = sum(1 for _n, n, _e in frames if n <= 0)
print('\n== %d frame(s) in %s, %d failed' % (len(frames), shots, zero))
# the point of the tool: make inspection happen
print('\n== INSPECT THE FRAMES. A capture nobody looked at is not evidence.')
nonzero = [f for f in sorted(os.listdir(shots)) if f.endswith('.png')]
if nonzero:
print(' start with: %s' % os.path.join(shots, nonzero[0]))
mid = nonzero[len(nonzero) // 2]
print(' and: %s' % os.path.join(shots, mid))
print(' Byte-identical consecutive frames mean the screen is static -- that '
'is a finding (a hang, or a dialog waiting), not a capture problem.')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))