Files
apk-reverse/scripts/apk_diff.py
T
inliver233 ae739bb0c1 Add native tamper/suicide guidance, toolchain map, and three scripts
references/native-tamper-and-suicide.md - how a hardened library terminates its own process: the mechanisms and their signatures, how to tell which one actually fires, arranged faults that call nothing (defeating every PLT-level fix), resolving a stub to its symbol, forged section headers, function boundaries from PT_GNU_EH_FRAME, scanner traps, and neutralising by returning rather than spinning.

references/toolchain.md - what to install, non-interactive invocation, GUI-only tools (ask for a human instead of silently substituting a weaker method), version-alignment traps, and working offline.

scripts/elf_plt.py - PLT stub to imported symbol on x86_64 and aarch64 from the relocation table; caller listing; library byte-diff naming each changed stub's symbol. scripts/apk_diff.py - entry-level diff by content hash, so same-size replacements are caught. scripts/native_crash.py - locate a native death from a log or tombstone and flag an arranged fault.

pitfalls P31-P36; SKILL.md gains a decision question, a hard constraint on neutralising terminate paths, and cross-references from packers/native-and-so.
2026-09-19 22:47:35 +08:00

165 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Entry-level APK diff: what actually changed between two builds.
Two jobs, both recurring:
1. **Audit a build you did not produce.** Given an original and a
"cracked"/"modded" copy, this lists every entry that differs, every entry
the author ADDED, and every entry they REMOVED. Added entries under
`lib/`, `assets/`, or new dex files are the interesting ones -- that is
where injected payloads live.
2. **Verify your own build was surgical.** After repacking, the diff against
the original must contain exactly the entries you intended to touch and
nothing else. A surprise entry is a bug in your pipeline, not in the app.
It compares *content*, not just sizes: two builds can have identical sizes and
different bytes, and a same-size replacement is exactly the case a careless
comparison misses.
Usage
-----
python apk_diff.py original.apk modified.apk
python apk_diff.py original.apk modified.apk --hide-identical
python apk_diff.py original.apk modified.apk --only 'classes.*\\.dex|lib/.*'
python apk_diff.py original.apk modified.apk --summary
"""
import argparse
import hashlib
import re
import sys
import zipfile
# Directories worth naming explicitly -- this is where injected code lives.
INTERESTING = ("lib/", "assets/", "META-INF/services/")
def snapshot(path):
"""{entry_name: (size, sha256, compress_type, crc)}"""
out = {}
with zipfile.ZipFile(path) as z:
for it in z.infolist():
if it.is_dir():
continue
data = z.read(it.filename)
out[it.filename] = (
len(data),
hashlib.sha256(data).hexdigest(),
it.compress_type,
it.CRC,
)
return out
def classify(name):
if re.match(r"^classes\d*\.dex$", name):
return "dex"
if name.startswith("META-INF/") and name.endswith((".RSA", ".SF", ".MF", ".DSA", ".EC")):
return "signature"
if name.startswith(INTERESTING):
return "code/assets"
return "other"
def main():
ap = argparse.ArgumentParser(description="Entry-level APK diff.")
ap.add_argument("original")
ap.add_argument("modified")
ap.add_argument("--only", help="regex: restrict output to matching entry names")
ap.add_argument("--hide-identical", action="store_true",
help="omit the identical-count line")
ap.add_argument("--summary", action="store_true",
help="counts only, no per-entry listing")
ap.add_argument("--max", type=int, default=200,
help="max entries listed per section (default 200)")
args = ap.parse_args()
try:
a = snapshot(args.original)
b = snapshot(args.modified)
except Exception as e:
sys.exit("error reading APK: %s" % e)
only_a = sorted(set(a) - set(b))
only_b = sorted(set(b) - set(a))
common = sorted(set(a) & set(b))
changed = [n for n in common if a[n][1] != b[n][1]]
rx = re.compile(args.only) if args.only else None
def keep(n):
return rx.search(n) if rx else True
def sha(path, n):
return a[n][1][:16] if n in a else b[n][1][:16]
print("A (original) %s entries=%d" % (args.original, len(a)))
print("B (modified) %s entries=%d" % (args.modified, len(b)))
print("")
print("only in A (removed) : %d" % len(only_a))
print("only in B (added) : %d" % len(only_b))
print("content changed : %d" % len(changed))
if not args.hide_identical:
print("identical : %d" % (len(common) - len(changed)))
print("")
# Signature entries always differ; say so once instead of listing them.
sig_only = [n for n in (only_a + only_b) if classify(n) == "signature"]
if sig_only:
print("(note: %d signature entries account for most of the add/remove noise)"
% len(sig_only))
if args.summary:
return
if only_b:
print("\n=== ADDED in B (injection candidates) ===")
shown = 0
for n in only_b:
if not keep(n) or classify(n) == "signature":
continue
print(" + %-52s %9d B [%s]" % (n, b[n][0], classify(n)))
shown += 1
if shown >= args.max:
print(" ... (truncated)")
break
if only_a:
print("\n=== REMOVED from A ===")
shown = 0
for n in only_a:
if not keep(n) or classify(n) == "signature":
continue
print(" - %-52s %9d B [%s]" % (n, a[n][0], classify(n)))
shown += 1
if shown >= args.max:
print(" ... (truncated)")
break
if changed:
print("\n=== CONTENT CHANGED ===")
shown = 0
for n in changed:
if not keep(n):
continue
sa, sb = a[n], b[n]
cmark = "" if sa[2] == sb[2] else " ctype %d->%d" % (sa[2], sb[2])
same_size = " (SAME SIZE)" if sa[0] == sb[0] else ""
print(" ~ %-52s A %9d %s B %9d %s%s%s"
% (n, sa[0], sa[1][:16], sb[0], sb[1][:16], same_size, cmark))
shown += 1
if shown >= args.max:
print(" ... (truncated)")
break
if any(a[n][0] == b[n][0] for n in changed if keep(n)):
print("\n (SAME SIZE means the change is not a length difference --")
print(" a size-only comparison would have missed it entirely)")
if not (only_a or only_b or changed):
print("no differences at all")
if __name__ == "__main__":
main()