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
305 lines
12 KiB
Python
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))
|