reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
369
vendor-new/apk-reverse/tests/fixtures/make_fixture_apk.py
vendored
Normal file
369
vendor-new/apk-reverse/tests/fixtures/make_fixture_apk.py
vendored
Normal file
@@ -0,0 +1,369 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Build the four tiny APKs that pin the APK-rebuild contract of `so_constpatch.py`.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
`so_constpatch.py` rebuilt an APK through `zipfile.ZipInfo(filename, date_time)`, which
|
||||
drops `extra`, `external_attr`, `create_system` and re-deflates every entry. An entry
|
||||
that must stay STORED or 4-byte aligned (`resources.arsc`, an uncompressed `lib/*.so`)
|
||||
can therefore come back compressed or unaligned, and the result is refused by the
|
||||
installer with `[-124]` -- or simply not loadable when the manifest asks for
|
||||
`extractNativeLibs="false"`. A regression test for that fix needs inputs that actually
|
||||
carry the metadata which was being dropped, at a size worth keeping in the repository:
|
||||
|
||||
plain.apk DEFLATED entries, no `resources.arsc` -> an unsafe rebuild is not
|
||||
in play, so this is the "rebuild is allowed" control
|
||||
arsc_aligned.apk `resources.arsc` STORED and 4-byte aligned, plus a STORED and a
|
||||
DEFLATED `lib/*.so` -> the storage/alignment contract must survive
|
||||
noextract.apk the manifest declares `android:extractNativeLibs="false"` and both
|
||||
`resources.arsc` and `lib/*.so` are STORED+aligned -> a rebuild must
|
||||
be REFUSED unless `--unsafe-rebuild` is passed
|
||||
services.apk `META-INF/services/**` entries, a UTF-8 entry name and a non-empty
|
||||
archive comment -> untouched entries must come back byte-identical
|
||||
|
||||
Nothing here is target data: every fixture is synthesised from the constants below. Two
|
||||
details exist so the fixtures are not weaker than real input:
|
||||
|
||||
* `AndroidManifest.xml` is a real binary AXML document (string pool + start element), so
|
||||
the manifest check reads the same bytes a compiler-produced manifest would give it.
|
||||
* `resources.arsc` is a valid `RES_TABLE_TYPE` header plus filler: enough to pin the
|
||||
storage/alignment contract, not a renderable resource table.
|
||||
* the `lib/*.so` payload is a real (tiny) aarch64 shared object with `.rodata` and
|
||||
`.shstrtab` section headers, so `--section-aware` exercises the section map instead of
|
||||
falling back to "no sections".
|
||||
|
||||
USAGE
|
||||
-----
|
||||
python make_fixture_apk.py --out tests/fixtures/apk
|
||||
|
||||
The generated files are committed. Regenerate only when the contract itself changes;
|
||||
the output is deterministic (fixed timestamps, fixed compression level, no randomness).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
ALIGN = 4
|
||||
STAMP_UNIX = 1577836800 # 2020-01-01T00:00:00Z
|
||||
FORCE_UTF8 = 0x800 # general purpose bit 11
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ small builders
|
||||
|
||||
def _dos(dt):
|
||||
"""(time, date) MS-DOS words, the way a zip local header stores them."""
|
||||
y, mo, d, h, mi, s = dt
|
||||
return (h << 11) | (mi << 5) | (s // 2), ((y - 1980) << 9) | (mo << 5) | d
|
||||
|
||||
|
||||
def _deflate(data):
|
||||
"""Raw deflate (no zlib wrapper) -- what a method-8 zip entry holds."""
|
||||
c = zlib.compressobj(9, zlib.DEFLATED, -15)
|
||||
return c.compress(data) + c.flush()
|
||||
|
||||
|
||||
def ext_stamp(unix=STAMP_UNIX):
|
||||
"""An extended-timestamp extra field (id 0x5455), so `extra` is never empty.
|
||||
|
||||
Real APKs carry this on entries written by a toolchain that stamps mtimes, and it is
|
||||
exactly the field a rebuilt archive used to lose.
|
||||
"""
|
||||
return b'\x55\x54' + struct.pack('<H', 5) + b'\x01' + struct.pack('<I', unix)
|
||||
|
||||
|
||||
def align_record(pad):
|
||||
"""A private extra record of `4 + pad` bytes, i.e. `pad` bytes of alignment.
|
||||
|
||||
A zip extra area is a sequence of (id, 2-byte size, payload) records, so the smallest
|
||||
record is 4 bytes and a 1-3 byte pad cannot be expressed on its own. Appending a
|
||||
record of length `4 + pad` shifts the data offset by `pad` (mod 4), which is exactly
|
||||
what is needed, without inserting a filler entry.
|
||||
"""
|
||||
if pad <= 0:
|
||||
return b''
|
||||
return b'\xca\xca' + struct.pack('<H', pad) + b'\x00' * pad
|
||||
|
||||
|
||||
def make_elf(const=b'apkhuan', decoy=b'libapkhuanx.so'):
|
||||
"""A minimal aarch64 ET_DYN object: ELF header, `.rodata`, `.shstrtab`, shdrs.
|
||||
|
||||
`const` sits alone between NULs (an isolated constant, safe to rewrite in place);
|
||||
`decoy` contains the same bytes as a substring, which is the case `--find` must
|
||||
report as NOT isolated.
|
||||
"""
|
||||
rodata = b'\x00' + const + b'\x00' + decoy + b'\x00'
|
||||
shstr = b'\x00.rodata\x00.shstrtab\x00'
|
||||
|
||||
ehsize, shentsize, shnum, shstrndx = 64, 64, 3, 2
|
||||
ro_off = ehsize
|
||||
shstr_off = ro_off + len(rodata)
|
||||
sh_off = (shstr_off + len(shstr) + 7) & ~7
|
||||
|
||||
ident = b'\x7fELF' + bytes([2, 1, 1, 0]) + b'\x00' * 8
|
||||
ehdr = ident + struct.pack('<HHIQQQIHHHHHH',
|
||||
3, # e_type = ET_DYN
|
||||
183, # e_machine= EM_AARCH64
|
||||
1, # e_version
|
||||
0, 0, # e_entry, e_phoff
|
||||
sh_off, # e_shoff
|
||||
0, # e_flags
|
||||
ehsize, 0, 0, shentsize, shnum, shstrndx)
|
||||
|
||||
def shdr(name, typ, flags, addr, off, size, align=1):
|
||||
return struct.pack('<IIQQQQIIQQ', name, typ, flags, addr, off, size,
|
||||
0, 0, align, 0)
|
||||
|
||||
shdrs = (shdr(0, 0, 0, 0, 0, 0)
|
||||
+ shdr(1, 1, 0x2, 0x1000, ro_off, len(rodata)) # .rodata PROGBITS
|
||||
+ shdr(9, 3, 0, 0, shstr_off, len(shstr))) # .shstrtab STRTAB
|
||||
pad = b'\x00' * (sh_off - (shstr_off + len(shstr)))
|
||||
return ehdr + rodata + shstr + pad + shdrs
|
||||
|
||||
|
||||
def _string_pool(strings):
|
||||
"""A UTF-8 (flag 0x100) RES_STRING_POOL chunk."""
|
||||
offsets, blob = [], b''
|
||||
for s in strings:
|
||||
raw = s.encode('utf-8')
|
||||
assert len(raw) < 0x80 and len(s) < 0x80, 'fixture pool is ASCII/short on purpose'
|
||||
offsets.append(len(blob))
|
||||
blob += bytes([len(raw), len(s)]) + raw + b'\x00'
|
||||
strings_start = 28 + 4 * len(strings)
|
||||
header = struct.pack('<HHI', 0x0001, 28, strings_start + len(blob))
|
||||
header += struct.pack('<IIIII', len(strings), 0, 0x100, strings_start, 0)
|
||||
return header + b''.join(struct.pack('<I', o) for o in offsets) + blob
|
||||
|
||||
|
||||
def make_axml(package='com.example.fixture', extract_native_libs=None):
|
||||
"""A minimal but structurally valid binary AndroidManifest.xml.
|
||||
|
||||
Layout: file header, one string pool, one START_ELEMENT chunk for <manifest>. The
|
||||
attribute shape matches what aapt2 emits: `extractNativeLibs` is a boolean
|
||||
(dataType 0x12) with no raw string, namespaced to the android URI.
|
||||
"""
|
||||
android_uri = 'http://schemas.android.com/apk/res/android'
|
||||
strings = ['android', android_uri, 'manifest', 'package', 'extractNativeLibs', package]
|
||||
s = {v: i for i, v in enumerate(strings)}
|
||||
|
||||
attrs = [(0xFFFFFFFF, s['package'], 0xFFFFFFFF, 0x03, s[package])]
|
||||
if extract_native_libs is not None:
|
||||
attrs.append((s[android_uri], s['extractNativeLibs'], 0xFFFFFFFF, 0x12,
|
||||
1 if extract_native_libs else 0))
|
||||
|
||||
pool = _string_pool(strings)
|
||||
|
||||
def chunk(ctype, node, tail):
|
||||
# chunk size covers the 8-byte chunk header as well as `node` and `tail`.
|
||||
body = node + tail
|
||||
return struct.pack('<HHI', ctype, 16, 8 + len(body)) + body
|
||||
|
||||
node = struct.pack('<II', 1, 0xFFFFFFFF) # lineNumber, comment
|
||||
body = struct.pack('<II', 0xFFFFFFFF, s['manifest']) # ns, name
|
||||
body += struct.pack('<HHHHHH', 20, 20, len(attrs), 0, 0, 0)
|
||||
for ns, name, raw, dtype, data in attrs:
|
||||
body += struct.pack('<III', ns, name, raw)
|
||||
body += struct.pack('<HBBI', 8, 0, dtype, data)
|
||||
element = chunk(0x0102, node, body)
|
||||
end = chunk(0x0103, node, struct.pack('<II', 0xFFFFFFFF, s['manifest']))
|
||||
|
||||
total = 8 + len(pool) + len(element) + len(end)
|
||||
return struct.pack('<HHI', 0x0003, 8, total) + pool + element + end
|
||||
|
||||
|
||||
def make_arsc(filler=60):
|
||||
"""A `RES_TABLE_TYPE` header with zero packages, plus filler bytes."""
|
||||
body = struct.pack('<I', 0) + b'\x00' * filler
|
||||
return struct.pack('<HHI', 0x0002, 12, 12 + len(body)) + body
|
||||
|
||||
|
||||
def make_dex(classes=2):
|
||||
"""A dex header whose file_size matches, so a reader does not reject it outright.
|
||||
|
||||
Only the header is written: the fixtures exist to pin zip behaviour, not dex
|
||||
semantics, and a full dex would be the largest file in the repository's test tree.
|
||||
"""
|
||||
header_size, endian = 0x70, 0x12345678
|
||||
size = header_size + 4 * classes
|
||||
h = bytearray(header_size)
|
||||
h[0:8] = b'dex\n035\x00'
|
||||
h[32:36] = struct.pack('<I', size)
|
||||
h[36:40] = struct.pack('<I', header_size)
|
||||
h[40:44] = struct.pack('<I', endian)
|
||||
h[104:108] = struct.pack('<I', 0) # data_size
|
||||
h[108:112] = struct.pack('<I', header_size)
|
||||
return bytes(h) + b'\x00' * (size - header_size)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ zip writer
|
||||
|
||||
class Entry(object):
|
||||
"""One fixture entry, with the metadata the rebuild must carry over."""
|
||||
|
||||
def __init__(self, name, data, method=8, date_time=(2020, 1, 1, 0, 0, 0),
|
||||
extra=None, external_attr=0o100644 << 16, internal_attr=0,
|
||||
create_system=3, comment=b'', align=False, utf8=False):
|
||||
self.name = name
|
||||
self.data = data
|
||||
self.method = method
|
||||
self.date_time = date_time
|
||||
self.extra = ext_stamp() if extra is None else extra
|
||||
self.external_attr = external_attr
|
||||
self.internal_attr = internal_attr
|
||||
self.create_system = create_system
|
||||
self.comment = comment
|
||||
self.align = align
|
||||
self.utf8 = utf8
|
||||
|
||||
@property
|
||||
def flags(self):
|
||||
return FORCE_UTF8 if self.utf8 else 0
|
||||
|
||||
|
||||
def write_apk(path, entries, archive_comment=b''):
|
||||
"""Write a zip the way zipalign does: STORED stays STORED, aligned stays aligned.
|
||||
|
||||
Written by hand rather than through `zipfile` because alignment is a property of the
|
||||
local header length, which `zipfile` cannot express -- the same reason `repack.py`
|
||||
hand-rolls its writer.
|
||||
"""
|
||||
out = bytearray()
|
||||
cd = []
|
||||
for e in entries:
|
||||
name_b = e.name.encode('utf-8')
|
||||
payload = e.data if e.method == 0 else _deflate(e.data)
|
||||
crc = zlib.crc32(e.data) & 0xFFFFFFFF
|
||||
extra = e.extra
|
||||
if e.align:
|
||||
head = len(out) + 30 + len(name_b) + len(extra)
|
||||
extra += align_record((ALIGN - head % ALIGN) % ALIGN)
|
||||
t, d = _dos(e.date_time)
|
||||
local_off = len(out)
|
||||
out += struct.pack('<IHHHHHIIIHH', 0x04034B50, 20, e.flags, e.method, t, d,
|
||||
crc, len(payload), len(e.data), len(name_b), len(extra))
|
||||
out += name_b + extra + payload
|
||||
data_off = local_off + 30 + len(name_b) + len(extra)
|
||||
if e.align and data_off % ALIGN:
|
||||
raise RuntimeError('%s is not %d-byte aligned' % (e.name, ALIGN))
|
||||
cd.append((e, name_b, crc, len(payload), len(extra), local_off))
|
||||
|
||||
cd_off = len(out)
|
||||
for e, name_b, crc, csize, elen, local_off in cd:
|
||||
t, d = _dos(e.date_time)
|
||||
out += struct.pack('<IHHHHHHIIIHHHHHII', 0x02014B50,
|
||||
(e.create_system << 8) | 20, 20, e.flags, e.method, t, d,
|
||||
crc, csize, len(e.data), len(name_b), len(e.extra),
|
||||
len(e.comment), 0, e.internal_attr, e.external_attr,
|
||||
local_off)
|
||||
out += name_b + e.extra + e.comment
|
||||
cd_size = len(out) - cd_off
|
||||
out += struct.pack('<IHHHHIIH', 0x06054B50, 0, 0, len(cd), len(cd), cd_size,
|
||||
cd_off, len(archive_comment))
|
||||
out += archive_comment
|
||||
|
||||
folder = os.path.dirname(os.path.abspath(path))
|
||||
if folder:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
with open(path, 'wb') as fh:
|
||||
fh.write(bytes(out))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the fixtures
|
||||
|
||||
def definitions():
|
||||
"""name -> (entries, archive comment). Deterministic; no timestamps from the clock.
|
||||
|
||||
Every STORED entry is 4-byte aligned, the way `zipalign` lays a real APK out: that is
|
||||
what makes "the rebuild did not break alignment" a checkable claim, and what keeps a
|
||||
rebuilt fixture acceptable to `zipalign -c`.
|
||||
"""
|
||||
elf = make_elf()
|
||||
dex = make_dex()
|
||||
strings_xml = b'<?xml version="1.0" encoding="utf-8"?>\n<resources/>\n'
|
||||
plain_manifest = make_axml('com.example.plain')
|
||||
noextract_manifest = make_axml('com.example.noextract', extract_native_libs=False)
|
||||
|
||||
st = dict(method=0, align=True, date_time=(2021, 6, 15, 12, 34, 56))
|
||||
plain = [
|
||||
Entry('AndroidManifest.xml', plain_manifest, **st),
|
||||
Entry('classes.dex', dex, external_attr=0o100600 << 16),
|
||||
Entry('res/values/strings.xml', strings_xml),
|
||||
Entry('lib/arm64-v8a/libfoo.so', elf),
|
||||
Entry('META-INF/MANIFEST.MF', b'Manifest-Version: 1.0\r\n\r\n'),
|
||||
]
|
||||
|
||||
arsc_aligned = [
|
||||
Entry('AndroidManifest.xml', plain_manifest, **st),
|
||||
Entry('classes.dex', dex),
|
||||
# DEFLATED and patched: its compressed size moves, so resources.arsc has to be
|
||||
# re-aligned on output. This is the case the old writer silently broke.
|
||||
Entry('lib/arm64-v8a/libfoo.so', elf),
|
||||
# STORED: must stay STORED, must not be re-compressed, must stay aligned.
|
||||
Entry('lib/arm64-v8a/libbar.so', elf, method=0, align=True,
|
||||
external_attr=0o100755 << 16),
|
||||
Entry('resources.arsc', make_arsc(), method=0, align=True),
|
||||
Entry('res/values/strings.xml', strings_xml),
|
||||
Entry('META-INF/services/com.example.Provider',
|
||||
b'com.example.ProviderImpl\n'),
|
||||
]
|
||||
|
||||
noextract = [
|
||||
Entry('AndroidManifest.xml', noextract_manifest, **st),
|
||||
Entry('classes.dex', dex),
|
||||
Entry('lib/arm64-v8a/libfoo.so', elf, method=0, align=True),
|
||||
Entry('resources.arsc', make_arsc(), method=0, align=True),
|
||||
]
|
||||
|
||||
services = [
|
||||
Entry('AndroidManifest.xml', plain_manifest, **st),
|
||||
Entry('META-INF/services/com.example.Provider', b'com.example.ProviderImpl\n'),
|
||||
Entry('META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory',
|
||||
b'kotlinx.coroutines.internal.MainDispatcherFactoryImpl\n', method=0,
|
||||
align=True),
|
||||
Entry('lib/x86_64/libfoo.so', elf, external_attr=0),
|
||||
# A non-ASCII name with the UTF-8 flag (bit 11) set: the raw name bytes and the
|
||||
# flag must both survive a rebuild, and this entry must not be touched at all.
|
||||
Entry('res/raw/\u6d4b\u8bd5.bin', b'fixture-payload\x00', utf8=True,
|
||||
create_system=0, external_attr=0, internal_attr=1,
|
||||
comment=b'fixture comment'),
|
||||
]
|
||||
|
||||
return {
|
||||
'plain.apk': (plain, b''),
|
||||
'arsc_aligned.apk': (arsc_aligned, b''),
|
||||
'noextract.apk': (noextract, b''),
|
||||
'services.apk': (services, b'fixture-archive-comment'),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
|
||||
ap.add_argument('--out', default=os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
'apk'),
|
||||
help='directory to write the fixtures into')
|
||||
ap.add_argument('--list', action='store_true', help='print names and exit')
|
||||
a = ap.parse_args()
|
||||
|
||||
defs = definitions()
|
||||
if a.list:
|
||||
for name in sorted(defs):
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
for name in sorted(defs):
|
||||
entries, comment = defs[name]
|
||||
path = os.path.join(a.out, name)
|
||||
blob = write_apk(path, entries, comment)
|
||||
print('%-18s %6d bytes %2d entries sha256=%s'
|
||||
% (name, len(blob), len(entries), hashlib.sha256(blob).hexdigest()[:16]))
|
||||
print('wrote %d fixture(s) to %s' % (len(defs), os.path.abspath(a.out)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user