Add code-virtualization/custom-linker reference, doctor and so_constpatch tools

New reference: references/code-virtualization-and-custom-linkers.md
  Covers the layer between "packed" and "clean" - manifests whose Application is the
  app's own and whose dex is readable, yet a re-signed build dies before user code runs.
  Contents: three cheap identification tells (whole classes as native declarations, the
  registerNativesForClass/special_clinit pair, SONAME-vs-filename mismatch); the
  keep-the-library / delete-the-library deadlock and why both routes fail; the
  checker-vs-implementation question that resolves it; the string-redirect technique
  (equal-length rewrite of an isolated constant so a loadLibrary call reaches an
  already-mapped library, so no offsets move and no death path needs neutralizing);
  expected private-loader log shapes; a single-variable experiment to determine whether
  the native check reads in-APK certificate material or the PMS-reported signature; and
  why a Java-layer signature killer logging success proves nothing.

New tool: scripts/doctor.py
  Cheapest first command. Capability report and per-script runnability, with
  APKREV_TOOLS support so tools installed off-PATH or as runnable jars are found rather
  than reported missing. Surfaces the environment facts that silently poison
  experiments: clock skew, leftover adb forward entries, a leftover device proxy, and a
  frida process already running on the device (which makes the target look like it
  detects instrumentation when nothing has been attached yet).

New tool: scripts/so_constpatch.py
  Implements the string-redirect technique. Enforces equal length, refuses to patch a
  substring of a longer identifier, reports whether each hit sits in an ELF constant
  pool, and patches inside an APK or a bare .so. Field-tested on a real protected
  library: find, equal-length replace, unequal-length refusal, multi-hit guard, --force
  and absent-name paths all verified.

Updated:
  SKILL.md            fix question count (ten -> thirteen); index the new reference and
                      both new scripts; make doctor.py the first step of the workflow;
                      add the "no packer but still dying" pointer during recon
  packers.md          cross-reference the new layer so "no packer" is not read as
                      "editable"
  ad-removal.md       make log evidence a count rather than an impression (n -> 0 is a
                      measurement; a tag that goes quiet without reaching zero is a
                      weaker result)
  repack-and-sign.md  warn that a full apktool rebuild rewrites obfuscated resource
                      paths (hundreds of rename pairs) - a tool artifact that must not be
                      read as breakage - and prefer a dex-only swap when that is the only
                      edit
  pitfalls.md         generalize the ServiceLoader example so it is not tied to one
                      sample's third-party library
  README.md           document the new files and scripts; document APKREV_TOOLS and the
                      cross-platform intent
This commit is contained in:
inliver233
2026-09-20 00:51:58 +08:00
parent 02c832f847
commit 0fc2a30408
9 changed files with 993 additions and 6 deletions
+428
View File
@@ -0,0 +1,428 @@
#!/usr/bin/env python3
"""Environment doctor: what can actually run here, and what is missing.
Why this exists
---------------
This skill ships a lot of scripts, and the most common failure mode reported by
users is not "the script is wrong" but "the script would not start and I could not
tell why" - a missing jar, a Python module that does not exist on this interpreter,
a device that is not connected. That wastes a whole round before any reverse
engineering happens.
Run this once before a work block. It answers three questions:
1. Which capabilities are available right now (static / native / dynamic / device)?
2. For every script, is it runnable as-is, or what exactly is missing?
3. Are there environment facts that will silently poison experiments
(clock skew, a leftover proxy, a dead device server, a wrong-ABI device)?
It never installs anything and never touches a target. Read the report, then go.
Usage
-----
python doctor.py # short capability report
python doctor.py --scripts # per-script runnability table
python doctor.py --device <serial> # include device checks
python doctor.py --json # machine-readable
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import platform
import shutil
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
# ---------------------------------------------------------------- probing
def extra_tool_dirs():
"""Directories to search beyond PATH.
Tools like jadx and apktool are frequently installed into a project-local or
versioned directory and invoked by full path, so a pure PATH probe reports them
missing and the capability table lies. Point APKREV_TOOLS at one or more
directories (os.pathsep-separated) and they are searched too.
"""
dirs = []
env = os.environ.get('APKREV_TOOLS')
if env:
dirs.extend(d for d in env.split(os.pathsep) if d)
# a tools/ dir beside the skill is a common convention and safe to probe
dirs.append(os.path.join(os.path.dirname(HERE), 'tools'))
return [d for d in dirs if os.path.isdir(d)]
def which(name: str):
hit = shutil.which(name)
if hit:
return hit
exts = ['']
if os.name == 'nt':
exts = ['.exe', '.bat', '.cmd', '.ps1', '']
for d in extra_tool_dirs():
for root, _dirs, files in os.walk(d):
if root.count(os.sep) - d.count(os.sep) > 3:
continue
for f in files:
stem, ext = os.path.splitext(f)
if stem.lower() == name.lower() and ext.lower() in exts:
return os.path.join(root, f)
return None
def run(cmd, timeout=15):
"""Run a command, returning (rc, stdout+stderr). Never raises, always bounded."""
try:
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=timeout)
return p.returncode, p.stdout.decode('utf-8', 'replace').strip()
except FileNotFoundError:
return 127, 'not found'
except subprocess.TimeoutExpired:
return 124, 'TIMEOUT after %ss' % timeout
except Exception as e: # pragma: no cover
return 1, '%s: %s' % (type(e).__name__, e)
def have_module(name: str) -> bool:
try:
return importlib.util.find_spec(name) is not None
except Exception:
return False
# ---------------------------------------------------------------- capability matrix
def python_info():
v = sys.version_info
ok = (v.major, v.minor) >= (3, 9)
return {
'version': '%d.%d.%d' % (v.major, v.minor, v.micro),
'ok': ok,
'note': '' if ok else 'scripts target 3.9+; older interpreters may fail on syntax',
}
def tool_info():
"""name -> (found_path, version_string)"""
out = {}
probes = [
('java', ['java', '-version']),
('javac', ['javac', '-version']),
('adb', ['adb', 'version']),
('frida', ['frida', '--version']),
('frida-ps', ['frida-ps', '--version']),
('objection', ['objection', '--version']),
('apktool', ['apktool', '--version']),
('jadx', ['jadx', '--version']),
('python', [sys.executable, '--version']),
('unzip', ['unzip', '-v']),
('git', ['git', '--version']),
('sqlite3', ['sqlite3', '--version']),
('node', ['node', '--version']),
('tshark', ['tshark', '-v']),
('mitmdump', ['mitmdump', '--version']),
('rizin', ['rizin', '-v']),
('rabin2', ['rabin2', '-v']),
('gdb', ['gdb', '--version']),
('readelf', ['readelf', '--version']),
('adb-devices', ['adb', 'devices']),
]
for name, cmd in probes:
if which(cmd[0]) is None and cmd[0] != sys.executable:
# A tool that ships as a runnable .jar is not on PATH but is still usable
# via `java -jar`. Reporting it as missing makes the capability table lie.
jar_hit = None
for d in [HERE, os.path.dirname(HERE)] + extra_tool_dirs():
if not os.path.isdir(d):
continue
for root, _dirs, files in os.walk(d):
if root.count(os.sep) - d.count(os.sep) > 3:
continue
for f in files:
if f.lower() == (name + '.jar').lower():
jar_hit = os.path.join(root, f)
break
if jar_hit:
break
if jar_hit:
break
if jar_hit:
out[name] = {'path': jar_hit,
'version': 'runnable as: java -jar %s' % os.path.basename(jar_hit),
'note': 'jar-only (not a PATH command)'}
else:
out[name] = {'path': None, 'version': None, 'note': 'not on PATH'}
continue
rc, txt = run(cmd)
first = ''
for line in txt.splitlines():
line = line.strip()
if line:
first = line
break
out[name] = {
'path': which(cmd[0]) or cmd[0],
'version': first[:160],
'note': '' if rc == 0 else 'exit %d: %s' % (rc, txt[:120]),
}
return out
# jar/libraries the dex tooling needs; kept as a list so doctor can name them
JAR_HINTS = ['baksmali', 'smali', 'dexlib2', 'apksigner', 'uber-apk-signer', 'd2j']
def java_toolchain():
"""Find the jars a smali round-trip needs, wherever they live."""
found = {}
roots = [os.path.join(HERE, 'dexpatch'), HERE,
os.path.join(HERE, 'libs'), os.path.join(HERE, 'jar')]
extra = os.environ.get('APKREV_JARS')
if extra:
roots.extend(extra.split(os.pathsep))
for r in roots:
if not os.path.isdir(r):
continue
for dirpath, _dirs, files in os.walk(r):
for f in files:
if f.lower().endswith('.jar'):
found.setdefault(f, os.path.join(dirpath, f))
return found
def script_dependencies():
"""Static, best-effort dependency map: script -> (required_modules, required_bins)."""
return {
'smtool.py': ([], ['java']),
'patch_smali.py': ([], []),
'find_refs.py': ([], []),
'dex_strings.py': ([], []),
'dex_classdiff.py': ([], []),
'dex_strpatch.py': ([], []),
'repack.py': ([], ['java']),
'install_test.py': ([], ['adb']),
'preflight.py': ([], ['adb']),
'lib_map.py': ([], ['adb']),
'apk_diff.py': ([], []),
'snap.py': ([], ['adb']),
'native_crash.py': ([], []),
'grab_crash.py': ([], ['adb']),
'blob_decode.py': ([], []),
'datastore_inject.py': ([], []),
'sig_probe.py': ([], ['java']),
'probe_api.py': ([], []),
'tls_check.py': ([], []),
'usb_net_proxy.py': ([], ['adb']),
'devsh.py': ([], ['adb']),
'elf_plt.py': ([], []),
'so_constpatch.py': ([], []),
'dart_pool_strings.py': ([], []),
'dart_pprefs.py': ([], []),
'dart_disasm.py': ([], []),
'run_probe.py': (['frida'], ['adb']),
'frida_probe.js': ([], []), # needs a frida host, checked separately
}
def script_report(tools, jars):
deps = script_dependencies()
rows = []
for name in sorted(deps):
path = os.path.join(HERE, name)
mods, bins = deps[name]
missing_mods = [m for m in mods if not have_module(m)]
missing_bins = [b for b in bins if which(b) is None]
# scripts that shell out to the frida host
if name in ('run_probe.py', 'frida_probe.js') and which('frida') is None:
if 'frida' not in missing_bins:
missing_bins.append('frida')
ok = os.path.isfile(path) and not missing_mods and not missing_bins
rows.append({
'script': name,
'present': os.path.isfile(path),
'runnable': ok,
'missing': missing_mods + missing_bins,
})
return rows, deps
def device_report(serial=None):
if which('adb') is None:
return {'available': False, 'reason': 'adb not on PATH'}
cmd = ['adb'] + (['-s', serial] if serial else []) + ['devices', '-l']
rc, txt = run(cmd, timeout=20)
if rc != 0:
return {'available': False, 'reason': txt[:200]}
devices = []
for line in txt.splitlines()[1:]:
line = line.strip()
if not line or line.startswith('*'):
continue
parts = line.split()
if len(parts) >= 2:
devices.append({'serial': parts[0], 'state': parts[1],
'info': ' '.join(parts[2:])[:160]})
out = {'available': True, 'devices': devices, 'raw': txt[:400]}
if not devices:
out['reason'] = 'no device/emulator attached'
return out
tgt = serial or devices[0]['serial']
props = {}
for key in ('ro.product.cpu.abi', 'ro.product.cpu.abilist', 'ro.build.version.release',
'ro.build.version.sdk', 'ro.product.model'):
rc, v = run(['adb', '-s', tgt, 'shell', 'getprop', key], timeout=15)
props[key] = v if rc == 0 else ''
out['target'] = tgt
out['props'] = props
# root?
rc, v = run(['adb', '-s', tgt, 'shell', 'su -c id'], timeout=15)
out['root'] = (rc == 0 and 'uid=0' in v)
out['root_raw'] = v[:120]
# clock skew: a classic silent experiment-poisoner
rc, dev_epoch = run(['adb', '-s', tgt, 'shell', 'date +%s'], timeout=15)
try:
dev_t = int(dev_epoch.strip())
out['clock_skew_s'] = abs(time.time() - dev_t)
except Exception:
out['clock_skew_s'] = None
# leftover forwards and proxies
rc, fwd = run(['adb', '-s', tgt, 'forward', '--list'], timeout=15)
out['forwards'] = [l for l in fwd.splitlines() if l.strip()][:10]
rc, prox = run(['adb', '-s', tgt, 'shell', 'settings get global http_proxy'], timeout=15)
out['http_proxy'] = prox.strip()[:120]
# is a frida server already running on device? (a common source of "the app
# suddenly detects instrumentation" while you believe nothing is attached)
rc, ps = run(['adb', '-s', tgt, 'shell', 'ps -A'], timeout=20)
hits = [l for l in ps.splitlines()
if 'frida' in l.lower() and 'grep' not in l.lower()]
out['device_frida_processes'] = [h.strip()[:140] for h in hits[:6]]
return out
# ---------------------------------------------------------------- reporting
def capability_summary(tools, dev, py):
caps = {}
caps['static (dex/zip/strings)'] = py['ok']
caps['repack + sign'] = bool(tools['java']['path']) and py['ok']
caps['smali round-trip'] = bool(tools['java']['path']) and py['ok']
caps['native ELF patching'] = py['ok']
caps['jadx decompile'] = bool(tools['jadx']['path'])
caps['apktool unpack'] = bool(tools['apktool']['path'])
caps['device work'] = bool(dev.get('available') and dev.get('devices'))
caps['root on device'] = bool(dev.get('root'))
caps['frida dynamic'] = bool(tools['frida']['path'])
caps['emulator/home'] = bool(tools['adb']['path'])
return caps
def main():
ap = argparse.ArgumentParser(description='Check what this environment can actually do.')
ap.add_argument('--device', default=None, metavar='SERIAL', help='probe this device')
ap.add_argument('--scripts', action='store_true', help='per-script runnability table')
ap.add_argument('--json', action='store_true', help='emit JSON')
args = ap.parse_args()
py = python_info()
tools = tool_info()
jars = java_toolchain()
dev = device_report(args.device)
rows, _deps = script_report(tools, jars)
caps = capability_summary(tools, dev, py)
if args.json:
print(json.dumps({'python': py, 'tools': tools, 'jars': jars,
'device': dev, 'capabilities': caps, 'scripts': rows},
indent=2, ensure_ascii=False))
return 0
print('=' * 74)
print('apk-reverse environment doctor')
print('=' * 74)
print('platform : %s %s / %s' % (platform.system(), platform.release(), platform.machine()))
print('python : %s %s' % (py['version'], '' if py['ok'] else '<-- ' + py['note']))
print('\n--- capabilities ---')
for k, v in caps.items():
print(' [%s] %s' % ('OK ' if v else '-- ', k))
print('\n--- tools ---')
for name, info in tools.items():
if name in ('adb-devices',):
continue
mark = 'OK ' if info['path'] else '-- '
ver = info['version'] or info['note']
print(' [%s] %-12s %s' % (mark, name, (ver or '')[:96]))
print('\n--- java jars found near this skill ---')
if jars:
for k, v in sorted(jars.items()):
print(' %s' % k)
else:
print(' (none) - a smali round-trip needs baksmali/smali/dexlib2 jars.')
print(' Set APKREV_JARS=<dir-with-jars> to point this script at them.')
print('\n--- device ---')
if not dev.get('available'):
print(' unavailable: %s' % dev.get('reason'))
elif not dev.get('devices'):
print(' no device attached (adb works)')
else:
print(' target : %s' % dev.get('target'))
for k, v in (dev.get('props') or {}).items():
print(' %-9s: %s' % (k.replace('ro.', ''), v))
print(' root : %s' % ('yes' if dev.get('root') else 'no'))
skew = dev.get('clock_skew_s')
if skew is not None:
flag = ' <-- FIX THIS, clock drift poisons time-based checks' if skew > 30 else ''
print(' clock skew: %ss%s' % (skew, flag))
if dev.get('forwards'):
print(' leftovers : adb forward entries still present: %s' % dev['forwards'])
if dev.get('http_proxy', '').strip() not in ('', 'null', ':0'):
print(' leftovers : device http_proxy = %s' % dev['http_proxy'])
if dev.get('device_frida_processes'):
print(' WARNING : a frida process is already running on device:')
for p in dev['device_frida_processes']:
print(' %s' % p)
print(' If the target dies only while this is up, you are looking at')
print(' a probe aimed at YOU. Stop it before concluding anything.')
if args.scripts:
print('\n--- scripts ---')
ready = [r for r in rows if r['runnable']]
notready = [r for r in rows if not r['runnable']]
for r in ready:
print(' [OK ] %s' % r['script'])
for r in notready:
why = []
if not r['present']:
why.append('file missing')
if r['missing']:
why.append('missing: ' + ', '.join(r['missing']))
print(' [-- ] %-24s %s' % (r['script'], '; '.join(why)))
print('\n runnable: %d / %d' % (len(ready), len(rows)))
else:
ready = sum(1 for r in rows if r['runnable'])
print('\n scripts runnable here: %d / %d (use --scripts for the table)'
% (ready, len(rows)))
print('\nRead references/long-task-discipline.md before a long block:')
print(' bound every wait, look at the screen while you wait, and record the '
'time-to-death before patching.')
return 0
if __name__ == '__main__':
sys.exit(main())
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Redirect a native library load by rewriting an isolated string constant in place.
Why this exists
---------------
Protected targets frequently name a *checker* library at a single call site
(``System.loadLibrary("X")`` reached from a JNI constant pool) rather than through
``DT_NEEDED``. When that checker does the integrity validation, deleting the loader
deadlocks you: keep it and validation kills the process, delete it and the load
throws.
Rewriting the *name* is the way out. Point that call at a library that is guaranteed
already mapped (on Android, ``android`` / ``libandroid.so``): the load succeeds, the
checker is never mapped, and **no byte offsets move** because the replacement is
exactly the same length. Nothing for an integrity check to notice about layout, and
no death path to neutralize.
Usage
-----
# inspect: where does this name occur, and is it an isolated constant?
python so_constpatch.py libfoo.so --find apkhuan
# patch a bare .so
python so_constpatch.py libfoo.so --replace apkhuan=android -o libfoo.patched.so
# patch inside an APK (rebuilds the zip, entry order preserved)
python so_constpatch.py app.apk --entry lib/arm64-v8a/libfoo.so \
--replace apkhuan=android -o app.patched.apk
# only patch occurrences that sit in an ELF constant-pool section
python so_constpatch.py libfoo.so --replace apkhuan=android --section-aware
Design notes
------------
* Equal length is mandatory and enforced; there is no padding mode that can be safe
when the next constant lives immediately after the NUL.
* ``--find`` reports whether each hit is isolated (NUL on both sides). Patching a
substring of a longer identifier corrupts that identifier, so non-isolated hits are
refused unless ``--allow-nonisolated`` is given explicitly.
* The ELF section map is only used for *reporting* which constant pool a hit lives in.
It never gates a patch on section validity, because hardened libraries ship forged
section headers (see references/native-tamper-and-suicide.md) while the bytes you
need are still exactly where the loader reads them.
"""
from __future__ import annotations
import argparse
import io
import os
import struct
import sys
import zipfile
# ---------------------------------------------------------------- ELF helpers
def elf_sections(data: bytes):
"""Return [(name, sh_type, addr, offset, size)] or [] if unusable.
Deliberately tolerant: forged/truncated section headers are common in this
domain, and a failed parse must not stop the string scan.
"""
try:
if data[:4] != b'\x7fELF':
return []
e_shoff = struct.unpack_from('<Q', data, 0x28)[0]
e_shentsize = struct.unpack_from('<H', data, 0x3a)[0]
e_shnum = struct.unpack_from('<H', data, 0x3c)[0]
e_shstrndx = struct.unpack_from('<H', data, 0x3e)[0]
if e_shoff == 0 or e_shnum == 0 or e_shoff >= len(data):
return []
raw = []
for i in range(e_shnum):
o = e_shoff + i * e_shentsize
if o + 0x40 > len(data):
break
name, stype, _flags, addr, offset, size = struct.unpack_from('<IIQQQQ', data, o)
raw.append([name, stype, addr, offset, size])
if e_shstrndx >= len(raw):
return []
base = raw[e_shstrndx][3]
out = []
for name, stype, addr, offset, size in raw:
try:
end = data.index(b'\x00', base + name)
nm = data[base + name:end].decode('ascii', 'replace')
except Exception:
nm = '?'
out.append((nm, stype, addr, offset, size))
return out
except Exception:
return []
def section_of(sections, off: int):
for nm, stype, _addr, soff, ssize in sections:
if stype != 8 and soff <= off < soff + ssize:
return nm
return None
POOLS = ('.rodata', '.data', '.data.rel.ro', '.rodata.str1.1', '.dynstr', '.strtab')
def occurrences(data: bytes, needle: bytes):
"""Yield (offset, isolated, section_name, context)."""
sections = elf_sections(data)
start = 0
while True:
i = data.find(needle, start)
if i < 0:
return
prev = data[i - 1:i]
nxt = data[i + len(needle):i + len(needle) + 1]
isolated = (prev == b'\x00' or i == 0) and (nxt == b'\x00')
yield i, isolated, section_of(sections, i), data[max(0, i - 24):i + len(needle) + 24]
start = i + 1
def describe(ctx: bytes) -> str:
return ''.join(chr(b) if 32 <= b < 127 else ('\\0' if b == 0 else '.') for b in ctx)
# ---------------------------------------------------------------- operations
def do_find(data: bytes, needle: bytes) -> int:
hits = list(occurrences(data, needle))
if not hits:
print('no occurrence of %r' % needle.decode('utf-8', 'replace'))
return 1
print('%d occurrence(s) of %r:' % (len(hits), needle.decode('utf-8', 'replace')))
for off, iso, sec, ctx in hits:
print(' 0x%-8x isolated=%-5s section=%-12s | %s' % (off, iso, sec or '-', describe(ctx)))
n_iso = sum(1 for _, iso, _, _ in hits if iso)
print('\nisolated (safe to rewrite in place): %d / %d' % (n_iso, len(hits)))
return 0
def do_replace(data: bytes, old: bytes, new: bytes, section_aware: bool,
allow_nonisolated: bool, force: bool) -> tuple[bytes, list]:
if len(old) != len(new):
raise SystemExit(
'REFUSING: %r (%d bytes) -> %r (%d bytes).\n'
'Equal length is mandatory - a different length shifts every byte after it '
'and invalidates the ELF.\nPass a same-length name (e.g. pad with a shorter '
'already-loaded library name).' % (
old.decode('utf-8', 'replace'), len(old),
new.decode('utf-8', 'replace'), len(new)))
hits = list(occurrences(data, old))
if not hits:
raise SystemExit('REFUSING: %r not present' % old.decode('utf-8', 'replace'))
selected = []
for off, iso, sec, ctx in hits:
if not iso and not allow_nonisolated:
print(' skip 0x%-8x not an isolated constant (would corrupt a neighbour)' % off)
continue
if section_aware and (sec not in POOLS):
print(' skip 0x%-8x section=%s (not a constant pool; --section-aware)' % (off, sec))
continue
selected.append(off)
if not selected:
raise SystemExit('REFUSING: no eligible occurrence (see remarks above)')
if len(selected) > 1 and not force:
print('\n%d eligible occurrence(s). Re-run with --force to patch all of them, '
'or narrow the search.' % len(selected))
for off in selected:
print(' 0x%x' % off)
raise SystemExit(2)
out = bytearray(data)
applied = []
for off in selected:
before = bytes(out[off:off + len(old)])
out[off:off + len(old)] = new
applied.append((off, before, bytes(new)))
print(' patched 0x%-8x %r -> %r' % (off, before.decode('utf-8', 'replace'),
new.decode('utf-8', 'replace')))
print('\n%d byte(s) changed, file length unchanged (%d).' % (len(applied) * len(old), len(data)))
return bytes(out), applied
# ---------------------------------------------------------------- containers
def load_target(path: str, entry: str | None):
if path.lower().endswith('.apk') or path.lower().endswith('.zip'):
if not entry:
raise SystemExit('--entry is required when patching an APK '
'(e.g. --entry lib/arm64-v8a/libfoo.so)')
z = zipfile.ZipFile(path)
if entry not in z.namelist():
cands = [n for n in z.namelist() if n.endswith('.so')]
raise SystemExit('entry %r not in %s\navailable .so entries:\n %s'
% (entry, path, '\n '.join(cands[:40])))
return z.read(entry), 'apk'
with open(path, 'rb') as f:
return f.read(), 'file'
def write_target(path: str, kind: str, src_path: str, entry: str | None,
new_data: bytes, out_path: str):
if kind == 'file':
with open(out_path, 'wb') as f:
f.write(new_data)
return
src = zipfile.ZipFile(src_path)
total = 0
with zipfile.ZipFile(out_path, 'w', zipfile.ZIP_DEFLATED) as dst:
for item in src.infolist():
blob = new_data if item.filename == entry else src.read(item.filename)
dst.writestr(zipfile.ZipInfo(item.filename, date_time=item.date_time),
blob, item.compress_type)
total += 1
print(' rebuilt %s (%d entries)' % (out_path, total))
# ---------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser(
description='In-place same-length rewrite of a string constant (library-load redirection).',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='examples:\n'
' so_constpatch.py libfoo.so --find checkername\n'
' so_constpatch.py libfoo.so --replace checkername=android -o libfoo.patched.so\n'
' so_constpatch.py app.apk --entry lib/arm64-v8a/libfoo.so '
'--replace checkername=android -o app.patched.apk\n')
ap.add_argument('target', help='.so file or .apk')
ap.add_argument('--entry', default=None, help='zip entry path when target is an APK')
ap.add_argument('--find', default=None, metavar='STR', help='report occurrences and exit')
ap.add_argument('--replace', default=None, metavar='OLD=NEW',
help='same-length rewrite of OLD with NEW')
ap.add_argument('-o', '--out', default=None, help='output path (required for --replace)')
ap.add_argument('--section-aware', action='store_true',
help='only patch hits inside known constant-pool sections')
ap.add_argument('--allow-nonisolated', action='store_true',
help='permit patching a substring of a longer identifier (dangerous)')
ap.add_argument('--force', action='store_true', help='patch every eligible occurrence')
args = ap.parse_args()
data, kind = load_target(args.target, args.entry)
print('loaded %s (%s, %d bytes)\n' % (args.target, kind, len(data)))
if args.find:
sys.exit(do_find(data, args.find.encode()))
if not args.replace:
ap.error('one of --find or --replace is required')
if '=' not in args.replace:
ap.error('--replace expects OLD=NEW')
old_s, new_s = args.replace.split('=', 1)
old, new = old_s.encode(), new_s.encode()
if not args.out and kind == 'file':
args.out = args.target + '.patched'
if not args.out:
ap.error('-o/--out is required when patching an APK')
if os.path.abspath(args.out) == os.path.abspath(args.target):
ap.error('refusing to overwrite the input in place; choose a different -o')
patched, applied = do_replace(data, old, new, args.section_aware,
args.allow_nonisolated, args.force)
write_target(args.out, kind, args.target, args.entry, patched, args.out)
print('\nwrote %s' % args.out)
print('next: re-sign, then verify the loader\'s log tag count is 0 on a cold start '
'(see references/code-virtualization-and-custom-linkers.md).')
if __name__ == '__main__':
main()