Files
apk-reverse/scripts/so_constpatch.py
T
inliver233 0fc2a30408 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
2026-09-20 00:51:58 +08:00

276 lines
11 KiB
Python

#!/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()