1197 lines
54 KiB
Python
Executable File
1197 lines
54 KiB
Python
Executable File
#!/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=<token>`` line every other script in this kit ends with.
|
|
"""
|
|
|
|
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/<abi>/<name>.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('<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(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('<H', blob, 0)[0] != 0x0003:
|
|
return None # not binary XML
|
|
if struct.unpack_from('<H', blob, 8)[0] != 0x0001:
|
|
return None # no string pool where one must be
|
|
hdr_size = struct.unpack_from('<H', blob, 10)[0]
|
|
try:
|
|
count, _styles, flags, strings_start, _styles_start = struct.unpack_from(
|
|
'<IIIII', blob, 16)
|
|
except struct.error:
|
|
return None
|
|
if not 0 < count <= 1000000:
|
|
return None
|
|
try:
|
|
offsets = struct.unpack_from('<%dI' % count, blob, 8 + hdr_size)
|
|
except struct.error:
|
|
return None
|
|
utf8 = bool(flags & (1 << 8))
|
|
base = 8 + strings_start
|
|
strings = []
|
|
for off in offsets:
|
|
p = base + off
|
|
if p >= 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('<H', blob, p)[0]
|
|
p += 2
|
|
if n & 0x8000:
|
|
n = ((n & 0x7FFF) << 16) | struct.unpack_from('<H', blob, p)[0]
|
|
p += 2
|
|
strings.append(blob[p:p + n * 2].decode('utf-16-le', 'replace'))
|
|
except (IndexError, struct.error):
|
|
return None
|
|
|
|
total = struct.unpack_from('<I', blob, 4)[0] or len(blob)
|
|
end = min(len(blob), total)
|
|
off = 8
|
|
while off + 8 <= end:
|
|
ctype, _hsize, csize = struct.unpack_from('<HHI', blob, off)
|
|
if csize < 8 or off + csize > end:
|
|
return None
|
|
if ctype == 0x0102: # RES_XML_START_ELEMENT
|
|
try:
|
|
attr_start, attr_size, attr_count = struct.unpack_from('<HHH', blob, off + 24)
|
|
except struct.error:
|
|
return None
|
|
attrs = {}
|
|
first = off + 16 + attr_start
|
|
for i in range(attr_count):
|
|
p = first + i * attr_size
|
|
if attr_size < 20 or p + 20 > off + csize:
|
|
return None
|
|
name_i, raw_i = struct.unpack_from('<II', blob, p + 4)
|
|
_size, _res0, dtype = struct.unpack_from('<HBB', blob, p + 12)
|
|
data = struct.unpack_from('<I', blob, p + 16)[0]
|
|
name = strings[name_i] if name_i < len(strings) else '?'
|
|
if dtype == 0x03:
|
|
val = strings[data] if data < len(strings) else ''
|
|
elif raw_i != 0xFFFFFFFF and raw_i < len(strings):
|
|
val = strings[raw_i]
|
|
elif dtype == 0x10:
|
|
val = str(data - (1 << 32) if data >= (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 '<manifest' in text:
|
|
return None
|
|
return 'unreadable'
|
|
|
|
|
|
FALSE_VALUES = ('false', '0')
|
|
TRUE_VALUES = ('true', '1')
|
|
|
|
|
|
# ---------------------------------------------------------------- container audit
|
|
|
|
def _local_layout(fh, header_offset):
|
|
"""(version_needed, flags, method, time_word, date_word, nlen, elen) of a local header."""
|
|
fh.seek(header_offset)
|
|
head = fh.read(30)
|
|
if len(head) != 30 or head[:4] != b'PK\x03\x04':
|
|
raise Refused('entry at 0x%x has no zip local header' % header_offset, 3,
|
|
'unsupported_container')
|
|
ver_need, flags, method = struct.unpack_from('<HHH', head, 4)
|
|
time_w, date_w = struct.unpack_from('<HH', head, 10)
|
|
nlen, elen = struct.unpack_from('<HH', head, 26)
|
|
return ver_need, flags, method, time_w, date_w, nlen, elen
|
|
|
|
|
|
def entry_data_offset(path, item, fh=None):
|
|
"""File offset of an entry's data, read from its own local header."""
|
|
close = False
|
|
if fh is None:
|
|
fh = open(path, 'rb')
|
|
close = True
|
|
try:
|
|
_v, _f, _m, _tw, _dw, nlen, elen = _local_layout(fh, item.header_offset)
|
|
return item.header_offset + 30 + nlen + elen
|
|
finally:
|
|
if close:
|
|
fh.close()
|
|
|
|
|
|
def needs_alignment(name, method, src_offset):
|
|
"""Should this entry land on a 4-byte data offset in the rebuilt archive?
|
|
|
|
Three cases, in order of force:
|
|
|
|
* `resources.arsc` -- Android R+ refuses the install outright, and uncompressed
|
|
`lib/**/*.so` -- the platform maps those straight out of the archive, so the
|
|
layout is part of the load contract. Always aligned.
|
|
* any other STORED entry that was **already** aligned in the input -- `zipalign`
|
|
aligns every uncompressed entry, so a rebuild that shifts one of them is a
|
|
regression even though the installer tolerates it. Preserved, not introduced.
|
|
* everything else -- untouched. A STORED entry that arrived unaligned is left
|
|
unaligned: this tool does not silently re-lay-out an archive it was not asked to
|
|
fix, and a DEFLATED entry has no alignment requirement at all.
|
|
"""
|
|
if name in ALIGNED_STORED:
|
|
return True
|
|
if ALIGNED_SO_RE.match(name) and method == 0:
|
|
return True
|
|
return method == 0 and src_offset % ALIGN == 0
|
|
|
|
|
|
def _read_eocd(path):
|
|
"""(comment, entry_count, disk numbers). Raises when the EOCD is missing/zip64."""
|
|
size = os.path.getsize(path)
|
|
with open(path, 'rb') as fh:
|
|
tail_len = min(size, 66000)
|
|
fh.seek(size - tail_len)
|
|
tail = fh.read(tail_len)
|
|
idx = tail.rfind(b'PK\x05\x06')
|
|
if idx < 0 or idx + 22 > 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('<HHHH', tail, idx + 4)
|
|
comment_len = struct.unpack_from('<H', tail, idx + 20)[0]
|
|
if 0xFFFF in (n_disk, n_total) or disk or cd_disk:
|
|
raise Refused('zip64 or multi-disk archive: this writer does not reproduce '
|
|
'zip64 structures safely. Use scripts/repack.py, or 7z to '
|
|
'rewrite the container first.', 3, 'unsupported_container')
|
|
return tail[idx + 22:idx + 22 + comment_len], n_total, (disk, cd_disk)
|
|
|
|
|
|
def audit_apk_for_rebuild(path, rep):
|
|
"""What Android would say about this archive, plus the rebuild-refusal reasons.
|
|
|
|
Returns a dict with the measured facts and an ``unsafe`` list. The audit is a
|
|
*measurement*, not a guess: everything printed here is read from the file.
|
|
"""
|
|
facts = {'path': os.path.abspath(path), 'entries': 0, 'zip64': False,
|
|
'extract_native_libs': None, 'arsc': None, 'stored_so': [],
|
|
'aligned_entries': [], 'data_descriptors': 0, 'unaligned_stored': []}
|
|
unsafe = []
|
|
comment, n_total, _disks = _read_eocd(path)
|
|
facts['archive_comment_bytes'] = len(comment)
|
|
facts['entries_in_eocd'] = n_total
|
|
|
|
with zipfile.ZipFile(path, 'r') as z, open(path, 'rb') as fh:
|
|
infos = z.infolist()
|
|
facts['entries'] = len(infos)
|
|
if len(infos) > 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 '<Item %r method=%d>' % (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('<HH', PAD_EXTRA_ID, pad) + b'\x00' * pad
|
|
|
|
|
|
def _copy_range(src_fh, dst, offset, size, chunk=1 << 20):
|
|
"""Copy `size` raw bytes from `offset` without loading them all at once."""
|
|
src_fh.seek(offset)
|
|
remaining = size
|
|
while remaining > 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('<IHHHHHIIIHH', 0x04034B50, it.ver_need, flags, method,
|
|
it.time_word, it.date_word, crc, csize, usize,
|
|
len(it.raw_name), len(extra)))
|
|
out.write(it.raw_name)
|
|
out.write(extra)
|
|
data_off = out.tell()
|
|
if needs_alignment(it.name, method, it.src_data_offset) and data_off % ALIGN:
|
|
raise Refused('internal: %s landed at 0x%x, not %d-byte aligned'
|
|
% (it.name, data_off, ALIGN), 4, 'internal_error')
|
|
if out.tell() != local_offset + 30 + len(it.raw_name) + len(extra):
|
|
raise Refused('internal: offset bookkeeping drift for %s' % it.name,
|
|
4, 'internal_error')
|
|
if payload is None:
|
|
_copy_range(src_fh, out, it.src_data_offset, it.csize)
|
|
else:
|
|
out.write(payload)
|
|
order.append({'item': it, 'method': method, 'flags': flags, 'crc': crc,
|
|
'csize': csize, 'usize': usize, 'extra': extra,
|
|
'local_offset': local_offset, 'data_offset': data_off,
|
|
'pad': pad})
|
|
|
|
cd_start = out.tell()
|
|
for row in order:
|
|
it = row['item']
|
|
vm = (it.create_system << 8) | it.create_version
|
|
out.write(struct.pack('<IHHHHHHIIIHHHHHII', 0x02014B50, vm,
|
|
it.extract_version, row['flags'], row['method'],
|
|
it.time_word, it.date_word, row['crc'], row['csize'],
|
|
row['usize'], len(it.raw_name), len(it.cd_extra),
|
|
len(it.comment), 0, it.internal_attr,
|
|
it.external_attr, row['local_offset']))
|
|
out.write(it.raw_name)
|
|
out.write(it.cd_extra)
|
|
out.write(it.comment)
|
|
cd_size = out.tell() - cd_start
|
|
out.write(struct.pack('<IHHHHIIH', 0x06054B50, 0, 0, len(order), len(order),
|
|
cd_size, cd_start, len(comment)))
|
|
out.write(comment)
|
|
|
|
os.replace(tmp, out_path)
|
|
report = {'entries': len(order), 'order': order,
|
|
'padded': [(r['item'].name, r['pad']) for r in order if r['pad']],
|
|
'descripped': [r['item'].name for r in order
|
|
if r['item'].flags & 0x08]}
|
|
return report
|
|
|
|
|
|
def verify_apk_rebuild(src_path, out_path, entry, report, rep):
|
|
"""Re-read the written file and check the contract it was supposed to keep.
|
|
|
|
Nothing here is inferred from the writing loop: entry count, per-entry CRC and
|
|
payload sha256, the metadata fields and the arsc storage/alignment gate are all read
|
|
back from disk. A structural failure (unreadable output, entry count, untouched
|
|
entry content) is an internal error; a storage/alignment failure is reported as a
|
|
warning under --unsafe-rebuild and never silently accepted.
|
|
"""
|
|
problems = []
|
|
warnings = []
|
|
untouched = [i for i in report['order'] if not i['item'].is_target]
|
|
detailed = rep.verbose or len(report['order']) <= 32
|
|
|
|
try:
|
|
out_items = read_raw_entries(out_path, None)
|
|
except Refused as exc:
|
|
raise Refused('the written archive is not readable: %s' % exc, 4, 'internal_error')
|
|
|
|
if len(out_items) != report['entries']:
|
|
problems.append('entry count changed: %d -> %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())
|