#!/usr/bin/env python3 # capability: native_const_patch # requires: python3 stdlib only (argparse, hashlib, json, struct, sys, zipfile, zlib) # exits: 0 success / 1 negative finding or refused rebuild / 2 usage+input error # / 3 this tool cannot (safely) do it / 4 internal error, output discarded """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 # machine-readable, for a long task that decides from tokens python so_constpatch.py app.apk --entry lib/arm64-v8a/libfoo.so \ --replace apkhuan=android -o out.apk --json 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. Rebuilding an APK is the dangerous half --------------------------------------- Rewriting one zip entry means writing a new archive, and a zip is an install contract, not a bag of files. The first version of this script wrote every entry through ``zipfile.ZipInfo(filename, date_time)`` and re-deflated the whole archive. That drops the ``extra`` field, ``external_attr``, ``create_system`` and permissions, and it can turn ``resources.arsc`` from STORED into DEFLATED. Android R+ then refuses the install:: Failure [-124: Failed parse during installPackageLI: Targeting R+ (version 30 and above) requires the resources.arsc of installed APKs to be stored uncompressed and aligned on a 4-byte boundary] and a package built with ``extractNativeLibs="false"`` can fail to load at all, because its ``lib/*.so`` are mapped straight out of the archive. This script now rebuilds the container the way a packager does: 1. **Every entry keeps its own metadata byte-for-byte**: raw local name bytes, the local ``extra`` field, the central-directory ``extra``, ``external_attr``, ``internal_attr``, ``create_system``, ``create_version``, the entry comment, the DOS time/date words and the original ``version needed``. 2. **STORED stays STORED.** An untouched entry's compressed stream is copied verbatim, so its CRC and ``compress_size`` are identical by construction. Only the entry you asked to rewrite is re-compressed, and then only if it was DEFLATED to begin with. 3. **Alignment is recomputed deliberately.** ``resources.arsc`` and uncompressed ``lib/*.so`` are padded to a 4-byte data offset with a private extra record. The original local extra stays a byte-for-byte prefix; the pad is appended, never substituted. 4. **An unsafe rebuild is refused by default.** If the manifest declares ``android:extractNativeLibs="false"``, or ``resources.arsc`` is present and is not STORED + 4-byte aligned, the script prints why and writes nothing. Pass ``--unsafe-rebuild`` to proceed anyway. If what you actually want is a resource- safe pack, use ``scripts/repack.py``: it owns the STORED+aligned writer and the signing pipeline. 5. **The result is verified before it is reported.** Entry count, per-entry CRC, payload sha256, metadata equality and the arsc storage/alignment gate are all re-read from the file that was just written. Exit codes (see references/long-task-discipline.md and the kit-wide convention):: 0 success RESULT=patched | found 1 negative finding RESULT=not_found | refused_unsafe_rebuild 2 usage or input error RESULT=usage_error 3 this tool cannot do it RESULT=unsupported_container 4 internal error, no output RESULT=internal_error ``--json`` prints one JSON object (with ``status``, ``exit_code``, ``capability``, ``evidence``, ``warnings``, ``next_action``) followed by the same final ``RESULT=`` line every other script in this kit ends with. """ from __future__ import annotations import argparse import hashlib import json import os import re import struct import sys import zipfile import zlib CAPABILITY = 'native_const_patch' TOOL = 'so_constpatch.py' ALIGN = 4 # Entries Android R+ requires to be STORED, and (for the ones it names) 4-byte aligned. ALIGNED_STORED = ('resources.arsc',) # `lib//.so` -- the ABI directory is part of the path, so `[^/]+` would match # nothing on a real APK and silently skip the alignment of every native library. ALIGNED_SO_RE = re.compile(r'^lib/.+\.so$') # Private extra-field id used only for alignment padding. It never replaces an existing # record; it is appended, so the entry's original extra field stays a prefix. PAD_EXTRA_ID = 0xCA MAX_ENTRIES = 0xFFFF MAX_U32 = 0xFFFFFFFF # ---------------------------------------------------------------- reporting class Reporter(object): """Collects the log, prints it unless --json, and holds the machine answer. In --json mode stdout must stay parseable, so human lines are collected into ``evidence``/``warnings`` and only the JSON object plus the final RESULT line are printed. """ def __init__(self, as_json=False, verbose=False): self.as_json = as_json self.verbose = verbose self.lines = [] self.warnings = [] self.notes = [] def log(self, msg=''): self.lines.append(msg) if not self.as_json: print(msg, flush=True) def warn(self, msg): self.warnings.append(msg) self.log('WARNING: %s' % msg) def note(self, msg): self.notes.append(msg) @property def evidence(self): # A long package produces a long log; the tail is what matters, so keep the # first lines (what was requested) and the last ones (what happened). if len(self.lines) <= 60: return list(self.lines) return self.lines[:20] + ['... %d line(s) omitted ...' % (len(self.lines) - 40)] \ + self.lines[-20:] class Refused(Exception): """The requested work is not something this tool should do. Carries its exit code. ``data`` carries the measurement that justified the refusal into the JSON payload, so a caller does not have to scrape the log to find out *why* nothing was written. """ def __init__(self, message, exit_code=2, token='usage_error', data=None): Exception.__init__(self, message) self.exit_code = exit_code self.token = token self.data = data or {} # ---------------------------------------------------------------- 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('= 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('= 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(rep, data: bytes, needle: bytes) -> str: hits = list(occurrences(data, needle)) if not hits: rep.log('no occurrence of %r' % needle.decode('utf-8', 'replace')) return 'not_found' rep.log('%d occurrence(s) of %r:' % (len(hits), needle.decode('utf-8', 'replace'))) for off, iso, sec, ctx in hits: rep.log(' 0x%-8x isolated=%-5s section=%-12s | %s' % (off, iso, sec or '-', describe(ctx))) n_iso = sum(1 for _, iso, _, _ in hits if iso) rep.log('\nisolated (safe to rewrite in place): %d / %d' % (n_iso, len(hits))) return 'found' def do_replace(rep, data: bytes, old: bytes, new: bytes, section_aware: bool, allow_nonisolated: bool, force: bool) -> tuple[bytes, list]: if len(old) != len(new): raise Refused( '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)), 2) hits = list(occurrences(data, old)) if not hits: raise Refused('REFUSING: %r not present' % old.decode('utf-8', 'replace'), 1, 'not_found') selected = [] for off, iso, sec, ctx in hits: if not iso and not allow_nonisolated: rep.log(' skip 0x%-8x not an isolated constant (would corrupt a neighbour)' % off) continue if section_aware and (sec not in POOLS): rep.log(' skip 0x%-8x section=%s (not a constant pool; --section-aware)' % (off, sec)) continue selected.append(off) if not selected: raise Refused('REFUSING: no eligible occurrence (see remarks above)', 2) if len(selected) > 1 and not force: rep.log('\n%d eligible occurrence(s). Re-run with --force to patch all of them, ' 'or narrow the search.' % len(selected)) for off in selected: rep.log(' 0x%x' % off) raise Refused('more than one eligible occurrence; --force not given', 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))) rep.log(' patched 0x%-8x %r -> %r' % (off, before.decode('utf-8', 'replace'), new.decode('utf-8', 'replace'))) changed = sum(1 for _off, before, after in applied for a, b in zip(before, after) if a != b) rep.log('\n%d byte(s) actually changed inside %d same-length overwrite(s), ' 'file length unchanged (%d).' % (changed, len(applied), len(data))) return bytes(out), applied # ---------------------------------------------------------------- AXML helpers def axml_root_attributes(blob: bytes): """{name: value} for the root element of a binary AndroidManifest.xml. An independent implementation of the same walk ``scripts/repack.py`` performs (that script must stay usable on its own, and so must this one). Both are exercised on the same committed fixture, so a disagreement shows up as a test failure rather than as two tools quietly disagreeing about `extractNativeLibs`. """ if len(blob) < 36 or struct.unpack_from('= len(blob): return None try: if utf8: n = blob[p] p += 1 if n & 0x80: n = ((n & 0x7F) << 8) | blob[p] p += 1 m = blob[p] p += 1 if m & 0x80: m = ((m & 0x7F) << 8) | blob[p] p += 1 strings.append(blob[p:p + m].decode('utf-8', 'replace')) else: n = struct.unpack_from(' end: return None if ctype == 0x0102: # RES_XML_START_ELEMENT try: attr_start, attr_size, attr_count = struct.unpack_from(' off + csize: return None name_i, raw_i = struct.unpack_from('= (1 << 31) else data) elif dtype == 0x12: val = 'true' if data else 'false' else: val = '@0x%x' % data attrs[name] = val return attrs off += csize return None def manifest_extract_native_libs(blob: bytes): """'true' / 'false' when declared, None when absent, 'unreadable' when undecidable. A plain-text placeholder manifest is accepted: a fixture or a hand-built archive may not carry binary XML, and refusing to look would turn "no declaration" into a false alarm. """ attrs = axml_root_attributes(blob) if attrs is not None: for key, val in attrs.items(): if key == 'extractNativeLibs' or key.endswith(':extractNativeLibs'): return str(val).strip().lower() return None text = blob.decode('utf-8', 'replace') m = re.search(r'extractNativeLibs\s*=\s*"([^"]*)"', text) if m: return m.group(1).strip().lower() if ' len(tail): raise Refused('no end-of-central-directory record: not a zip archive', 3, 'unsupported_container') disk, cd_disk, n_disk, n_total = struct.unpack_from(' MAX_ENTRIES: raise Refused('%d entries exceeds the classic zip limit (%d)' % (len(infos), MAX_ENTRIES), 3, 'unsupported_container') names = [i.filename for i in infos] for item in infos: if (item.compress_size > MAX_U32 or item.file_size > MAX_U32 or item.header_offset > MAX_U32): facts['zip64'] = True raise Refused('entry %r needs zip64 fields; this writer does not ' 'reproduce zip64 structures. Use scripts/repack.py.' % item.filename, 3, 'unsupported_container') if item.flag_bits & 0x08: facts['data_descriptors'] += 1 if item.is_dir(): continue if item.compress_type == 0: off = entry_data_offset(path, item, fh) aligned = off % ALIGN == 0 if needs_alignment(item.filename, 0, off): facts['aligned_entries'].append( {'name': item.filename, 'offset': off, 'aligned': aligned}) if ALIGNED_SO_RE.match(item.filename): facts['stored_so'].append(item.filename) if not aligned: facts['unaligned_stored'].append(item.filename) if item.filename == 'resources.arsc': off = entry_data_offset(path, item, fh) facts['arsc'] = {'method': item.compress_type, 'offset': off, 'stored': item.compress_type == 0, 'aligned': off % ALIGN == 0, 'size': item.file_size} if item.filename == 'AndroidManifest.xml': try: manifest = z.read(item.filename) except (zipfile.BadZipFile, RuntimeError, NotImplementedError) as exc: rep.warn('AndroidManifest.xml could not be read (%s); the ' 'extractNativeLibs state is unknown.' % exc) manifest = b'' facts['extract_native_libs'] = manifest_extract_native_libs(manifest) if 'AndroidManifest.xml' not in names: facts['extract_native_libs'] = 'absent' enl = facts['extract_native_libs'] if enl in FALSE_VALUES: unsafe.append( 'AndroidManifest.xml declares android:extractNativeLibs="false": the platform ' 'maps lib/*.so straight out of the archive, so the archive layout is part of ' 'the load contract (STORED bytes at the recorded offset).') if enl == 'unreadable': rep.warn('AndroidManifest.xml is present but its attributes could not be read; ' 'the extractNativeLibs state is unknown and the rebuild is treated as ' 'safe only because nothing said otherwise.') arsc = facts['arsc'] if arsc is not None and not (arsc['stored'] and arsc['aligned']): unsafe.append( 'resources.arsc is present and is not STORED + 4-byte aligned ' '(method=%d, data offset 0x%x): Android R+ refuses to install that, and a ' 'container rebuild cannot make it compliant without re-compressing it.' % (arsc['method'], arsc['offset'])) facts['unsafe'] = unsafe return facts def describe_audit(facts, rep): rep.log('== container audit: %s' % facts['path']) rep.log(' entries=%d archive comment=%d bytes data descriptors=%d' % (facts['entries'], facts.get('archive_comment_bytes', 0), facts['data_descriptors'])) enl = facts['extract_native_libs'] label = {None: 'not declared', 'absent': 'AndroidManifest.xml absent'}.get(enl, enl) suffix = '' if enl in (None, 'true', 'false', 'absent') \ else ' (unreadable manifest: treated as undeclared)' rep.log(' extractNativeLibs=%s%s' % (label, suffix)) arsc = facts['arsc'] if arsc is None: rep.log(' resources.arsc: absent') else: rep.log(' resources.arsc: %s, data offset 0x%x (%s), %d bytes' % ('STORED' if arsc['stored'] else 'COMPRESSED(method=%d)' % arsc['method'], arsc['offset'], '4-byte aligned' if arsc['aligned'] else 'NOT aligned', arsc['size'])) for row in facts['aligned_entries']: if row['name'] in ALIGNED_STORED: continue # already reported on its own line above rep.log(' %s: data offset 0x%x (%s)' % (row['name'], row['offset'], '4-byte aligned' if row['aligned'] else 'NOT aligned')) if facts['stored_so']: rep.log(' STORED lib/*.so: %s' % ', '.join(facts['stored_so'])) if facts['unaligned_stored']: rep.log(' STORED entries that are NOT 4-byte aligned in the input: %s' % ', '.join(facts['unaligned_stored'][:8]) + ('' if len(facts['unaligned_stored']) <= 8 else ' (+%d more)' % (len(facts['unaligned_stored']) - 8))) for reason in facts['unsafe']: rep.log(' UNSAFE: %s' % reason) # ---------------------------------------------------------------- bundle readers class Item(object): """One source entry with everything needed to reproduce it byte-for-byte. The compressed stream of an untouched entry is *not* loaded into memory: its ``src_data_offset`` and ``csize`` are enough to copy it across, which keeps a rebuild of a few-hundred-megabyte APK at constant memory. """ __slots__ = ('name', 'raw_name', 'local_extra', 'cd_extra', 'comment', 'method', 'flags', 'ver_need', 'time_word', 'date_word', 'crc', 'csize', 'usize', 'create_system', 'create_version', 'extract_version', 'internal_attr', 'external_attr', 'header_offset', 'src_data_offset', 'payload', 'is_dir', 'is_target') def __repr__(self): return '' % (self.name, self.method) def _deflate_raw(data): """Raw deflate (no zlib wrapper), which is what a zip method-8 entry holds. zlib.compress() prepends a 2-byte zlib header. Some readers tolerate it, some do not, and the failure reads as a corrupt entry rather than as a bad compressor call. """ c = zlib.compressobj(9, zlib.DEFLATED, -15) return c.compress(data) + c.flush() def read_raw_entries(path, entry): """Every entry of `path` as an Item, with its original compressed stream in place.""" items = [] try: with zipfile.ZipFile(path, 'r') as z, open(path, 'rb') as fh: for info in z.infolist(): ver_need, flags, method, time_w, date_w, nlen, elen = _local_layout( fh, info.header_offset) fh.seek(info.header_offset + 30) raw_name = fh.read(nlen) local_extra = fh.read(elen) src_data_offset = fh.tell() if info.header_offset + 30 + nlen + elen + info.compress_size \ > os.path.getsize(path): raise Refused('entry %r: data runs past the end of the file' % info.filename, 3, 'unsupported_container') it = Item() it.name = info.filename it.raw_name = raw_name it.local_extra = local_extra it.cd_extra = info.extra it.comment = info.comment it.method = info.compress_type it.flags = flags it.ver_need = ver_need it.time_word = time_w it.date_word = date_w it.crc = info.CRC it.csize = info.compress_size it.usize = info.file_size it.create_system = info.create_system it.create_version = info.create_version it.extract_version = info.extract_version it.internal_attr = info.internal_attr it.external_attr = info.external_attr it.header_offset = info.header_offset it.src_data_offset = src_data_offset it.payload = None it.is_dir = info.is_dir() it.is_target = (info.filename == entry) items.append(it) except zipfile.BadZipFile as exc: raise Refused('not a readable zip archive: %s' % exc, 3, 'unsupported_container') except OSError as exc: raise Refused('cannot read %s: %s' % (path, exc), 2, 'usage_error') if entry is not None and not any(i.is_target for i in items): so_names = [i.name for i in items if i.name.endswith('.so')] raise Refused('entry %r not in %s\navailable .so entries:\n %s' % (entry, path, '\n '.join(so_names[:40]) or ' (none)'), 2, 'usage_error') return items # ---------------------------------------------------------------- the writer def _pad_extra(pad): """An appended private extra record contributing exactly `pad` bytes (mod 4). A zip extra area is a sequence of (id, 2-byte size, payload) records, so its smallest record is 4 bytes and a 1-3 byte pad cannot be expressed on its own. A record of length `4 + pad` shifts the data offset by `pad` (mod 4), which is exactly what is needed -- and it is *appended*, so the entry keeps its original extra bytes. """ if pad <= 0: return b'' return struct.pack(' 0: block = src_fh.read(min(chunk, remaining)) if not block: raise Refused('internal: short read while copying %d byte(s) at 0x%x' % (size, offset), 4, 'internal_error') dst.write(block) remaining -= len(block) def _sha_range(path, offset, size, chunk=1 << 20): """sha256 of a byte range, streamed.""" h = hashlib.sha256() with open(path, 'rb') as fh: fh.seek(offset) remaining = size while remaining > 0: block = fh.read(min(chunk, remaining)) if not block: break h.update(block) remaining -= len(block) return h.hexdigest() def rebuild_apk(src_path, out_path, entry, new_data, rep): """Rewrite the archive keeping every entry's own metadata. Returns a report. Only `entry` may change. Every other entry's compressed stream is copied across verbatim, so its CRC and compress_size are identical by construction rather than by hope. """ items = read_raw_entries(src_path, entry) comment, n_total, _disks = _read_eocd(src_path) if n_total != len(items): rep.warn('EOCD reports %d entries, the central directory lists %d' % (n_total, len(items))) order = [] tmp = out_path + '.tmp' with open(src_path, 'rb') as src_fh, open(tmp, 'wb') as out: for it in items: method = it.method crc, csize, usize = it.crc, it.csize, it.usize payload = None if it.is_target: if method == 0: payload = new_data # STORE stays STORE elif method == 8: payload = _deflate_raw(new_data) else: raise Refused( 'entry %r uses compression method %d; this tool rewrites STORED ' '(0) and DEFLATED (8) entries only. Use scripts/repack.py.' % (it.name, method), 3, 'unsupported_container') crc = zlib.crc32(new_data) & 0xFFFFFFFF csize = len(payload) usize = len(new_data) extra = it.local_extra pad = 0 if needs_alignment(it.name, method, it.src_data_offset): head = out.tell() + 30 + len(it.raw_name) + len(extra) pad = (ALIGN - head % ALIGN) % ALIGN extra = extra + _pad_extra(pad) flags = it.flags & ~0x08 # data descriptor is resolved local_offset = out.tell() out.write(struct.pack(' %d' % (report['entries'], len(out_items))) src_by_name = {i.name: i for i in (r['item'] for r in report['order'])} crc_ok = sha_ok = meta_ok = 0 for got in out_items: want = src_by_name.get(got.name) if want is None: problems.append('unexpected entry %r in the output' % got.name) continue same_fields = (got.method == want.method and got.external_attr == want.external_attr and got.internal_attr == want.internal_attr and got.create_system == want.create_system and got.create_version == want.create_version and got.extract_version == want.extract_version and got.comment == want.comment and got.cd_extra == want.cd_extra and got.raw_name == want.raw_name and (got.flags & ~0x08) == (want.flags & ~0x08) and got.time_word == want.time_word and got.date_word == want.date_word) out_sha = _sha_range(out_path, got.src_data_offset, got.csize) src_sha = _sha_range(src_path, want.src_data_offset, want.csize) if want.is_target: if out_sha == src_sha: warnings.append('%s: the rewritten entry came back byte-identical to the ' 'input (the replacement may be the same string as the old ' 'one)' % got.name) if detailed: rep.log(' CHANGED %-52s crc=%08x -> %08x %d -> %d bytes sha256=%s' % (got.name, want.crc, got.crc, want.csize, got.csize, out_sha[:16])) if not same_fields: problems.append('rewritten entry %r: metadata changed' % got.name) continue crc_same = got.crc == want.crc and got.csize == want.csize \ and got.usize == want.usize sha_same = out_sha == src_sha crc_ok += 1 if crc_same else 0 sha_ok += 1 if sha_same else 0 meta_ok += 1 if same_fields else 0 if not crc_same: problems.append('untouched entry %r: CRC/size changed ' '(%08x/%d -> %08x/%d)' % (got.name, want.crc, want.csize, got.crc, got.csize)) if not sha_same: problems.append('untouched entry %r: payload sha256 changed' % got.name) if not same_fields: problems.append('untouched entry %r: metadata changed' % got.name) if detailed: rep.log(' OK %-52s crc=%08x sha256=%s%s' % (got.name, got.crc, out_sha[:16], '' if same_fields else ' METADATA-DIFF')) n_untouched = len(untouched) rep.log('== rebuild self-check') rep.log(' entry count : %d -> %d %s' % (report['entries'], len(out_items), 'identical' if report['entries'] == len(out_items) else 'DIFFERENT')) rep.log(' untouched entries : %d/%d CRC+size identical, %d/%d payload sha256 ' 'identical, %d/%d metadata identical' % (crc_ok, n_untouched, sha_ok, n_untouched, meta_ok, n_untouched)) if report['descripped']: rep.log(' data descriptors : resolved into the local header for %s' % ', '.join(report['descripped'])) for name, pad in report['padded']: rep.log(' alignment padding : %s += %d byte(s) in a private local extra ' 'record' % (name, pad)) with zipfile.ZipFile(out_path, 'r') as z, open(out_path, 'rb') as fh: in_align = [] for info in z.infolist(): want = src_by_name.get(info.filename) src_off = want.src_data_offset if want is not None else 0 if not needs_alignment(info.filename, info.compress_type, src_off): continue off = entry_data_offset(out_path, info, fh) if info.compress_type != 0: warnings.append('%s is COMPRESSED (method=%d) in the output, but an ' 'uncompressed entry is what the layout requires' % (info.filename, info.compress_type)) rep.log(' %-20s : COMPRESSED (method=%d) FAIL' % (info.filename, info.compress_type)) continue in_align.append((info.filename, off, info.header_offset)) if off % ALIGN: warnings.append('%s data offset 0x%x is not 4-byte aligned' % (info.filename, off)) rep.log(' %-20s : STORED but data offset 0x%x is NOT aligned FAIL' % (info.filename, off)) for name, off, hdr in in_align: rep.log(' %-20s : STORED, data offset 0x%x (4-byte aligned), ' 'header_offset 0x%x OK' % (name, off, hdr)) if in_align: rep.log(' alignment gate : the field that must be 4-byte aligned is ' 'the ENTRY DATA offset (what zipalign -c and apksigner check); the ' 'local header offset is not part of that gate') else: rep.log(' (no entry requires 4-byte alignment in this archive)') for w in warnings: rep.warn(w) if problems: for p in problems: rep.log(' FAIL %s' % p) raise Refused('the rebuilt archive failed its own self-check (%d problem(s)); the ' 'output was removed rather than reported as good' % len(problems), 4, 'internal_error') return {'entries': report['entries'], 'untouched': n_untouched, 'crc_identical': crc_ok, 'sha256_identical': sha_ok, 'metadata_identical': meta_ok, 'padded': report['padded'], 'data_descriptors_resolved': report['descripped'], 'failures': [], 'warnings': warnings} # ---------------------------------------------------------------- containers def load_target(path: str, entry): """The bytes to patch: a bare file, or one entry read out of an APK.""" if path.lower().endswith('.apk') or path.lower().endswith('.zip'): if not entry: raise Refused('--entry is required when patching an APK ' '(e.g. --entry lib/arm64-v8a/libfoo.so)', 2) try: with zipfile.ZipFile(path, 'r') as z: names = z.namelist() if entry not in names: cands = [n for n in names if n.endswith('.so')] raise Refused('entry %r not in %s\navailable .so entries:\n %s' % (entry, path, '\n '.join(cands[:40]) or ' (none)'), 2) if entry.endswith('/'): raise Refused('entry %r is a directory' % entry, 2) return z.read(entry), 'apk' except zipfile.BadZipFile as exc: raise Refused('not a readable zip archive: %s' % exc, 3, 'unsupported_container') except OSError as exc: raise Refused('cannot read %s: %s' % (path, exc), 2) try: with open(path, 'rb') as f: return f.read(), 'file' except OSError as exc: raise Refused('cannot read %s: %s' % (path, exc), 2) def write_target(rep, out_path: str, kind: str, src_path: str, entry, new_data: bytes, unsafe): """Write the patched artifact. A bare file is copied; an APK is rebuilt.""" if kind == 'file': with open(out_path, 'wb') as f: f.write(new_data) rep.log(' wrote %d bytes (bare file, container untouched)' % len(new_data)) return None audit = audit_apk_for_rebuild(src_path, rep) describe_audit(audit, rep) if audit['unsafe'] and not unsafe: rep.log('') rep.log('REFUSED: this package cannot be rebuilt safely by this tool (see the ' 'UNSAFE line(s) above). Nothing was written.') rep.log(' * If you want a resource-safe repack, use scripts/repack.py: it owns ' 'the STORED+aligned writer and the signing pipeline.') rep.log(' * If you have already confirmed the package is safe to rewrite, ' 're-run with --unsafe-rebuild to proceed anyway.') raise Refused('unsafe APK rebuild refused by default', 1, 'refused_unsafe_rebuild', {'audit': audit}) if audit['unsafe'] and unsafe: for reason in audit['unsafe']: rep.warn('--unsafe-rebuild accepted: %s' % reason) report = rebuild_apk(src_path, out_path, entry, new_data, rep) verified = verify_apk_rebuild(src_path, out_path, entry, report, rep) rep.log(' rebuilt %s (%d entries, order and metadata preserved)' % (out_path, report['entries'])) return {'audit': audit, 'verify': verified} # ---------------------------------------------------------------- main EXIT_CODES = {'patched': 0, 'found': 0, 'not_found': 1, 'refused_unsafe_rebuild': 1, 'usage_error': 2, 'unsupported_container': 3, 'internal_error': 4} def _status_for(token): if token in ('patched', 'found'): return 'ok' if token == 'not_found': return 'negative' if token == 'refused_unsafe_rebuild': return 'refused' if token == 'usage_error': return 'usage_error' if token == 'unsupported_container': return 'capability_missing' return 'internal_error' def main(): ap = argparse.ArgumentParser( description='In-place same-length rewrite of a string constant (library-load ' 'redirection), including APK rebuilds that preserve zip metadata.', 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' '\n' 'an APK rebuild is refused by default when the manifest declares\n' 'android:extractNativeLibs="false", or when resources.arsc is not\n' 'STORED + 4-byte aligned. Use scripts/repack.py for a resource-safe\n' 'repack, or pass --unsafe-rebuild to override after checking the audit.\n' '\n' 'exit codes: 0 ok / 1 negative finding or refused rebuild / 2 usage or\n' 'input error / 3 this tool cannot do it / 4 internal error (no output)\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') ap.add_argument('--unsafe-rebuild', action='store_true', help='proceed with an APK rebuild even when the audit says the ' 'package cannot survive it (see the UNSAFE lines)') ap.add_argument('--json', action='store_true', help='print one JSON object plus the final RESULT line') ap.add_argument('--verbose', action='store_true', help='print a per-entry self-check line instead of a summary') args = ap.parse_args() rep = Reporter(args.json, args.verbose) token = 'internal_error' exit_code = 4 extra = {} next_action = None try: data, kind = load_target(args.target, args.entry) rep.log('loaded %s (%s, %d bytes)\n' % (args.target, kind, len(data))) if args.find: token = do_find(rep, data, args.find.encode()) exit_code = EXIT_CODES[token] if token == 'not_found': next_action = ('check the name: --find matches raw bytes, so try a shorter ' 'substring of the constant.') return finish(rep, args, token, exit_code, extra, next_action) 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') if args.unsafe_rebuild and kind == 'file': rep.warn('--unsafe-rebuild only affects an APK rebuild; a bare .so is copied ' 'as-is.') patched, applied = do_replace(rep, data, old, new, args.section_aware, args.allow_nonisolated, args.force) extra['patch'] = {'occurrences': len(applied), 'offsets': ['0x%x' % off for off, _b, _a in applied]} result = write_target(rep, args.out, kind, args.target, args.entry, patched, args.unsafe_rebuild) if result: extra.update(result) rep.log('\nwrote %s' % args.out) token = 'patched' exit_code = 0 next_action = ('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 kind == 'apk': next_action = ('sign the rebuilt APK (scripts/repack.py --no-sign writes the ' 'same container; apksigner/zipalign applies the signature), ' 'then verify on a cold start.') except Refused as exc: token = exc.token exit_code = exc.exit_code extra.update(exc.data) if token == 'refused_unsafe_rebuild': next_action = ('use scripts/repack.py for a resource-safe repack, or re-run ' 'with --unsafe-rebuild.') elif token == 'usage_error': next_action = 'fix the invocation (see --help).' elif token == 'unsupported_container': next_action = ('use scripts/repack.py, or rewrite the container with a tool ' 'that handles zip64 and non-deflate entries.') rep.log('\n%s' % exc) if exit_code == 4: out = args.out or '' for candidate in (out, out + '.tmp'): if candidate and os.path.exists(candidate): try: os.remove(candidate) rep.log('removed %s' % candidate) except OSError: pass except SystemExit: raise return finish(rep, args, token, exit_code, extra, next_action) def finish(rep, args, token, exit_code, extra, next_action): """Emit the machine-readable answer: JSON (when asked) plus the RESULT token.""" if args.json: payload = {'status': _status_for(token), 'exit_code': exit_code, 'capability': CAPABILITY, 'tool': TOOL, 'result': token, 'target': args.target, 'entry': args.entry, 'out': args.out, 'evidence': rep.evidence, 'warnings': rep.warnings} for key, val in extra.items(): payload[key] = val if next_action: payload['next_action'] = next_action print(json.dumps(payload, indent=2, ensure_ascii=False)) else: if next_action: print('next: %s' % next_action) print('RESULT=%s' % token) return exit_code if __name__ == '__main__': sys.exit(main())