add update-suppression and account-gate references; fix two script defects

- references/updates-and-forced-upgrade.md: keeping a patched build alive (locating the
  version check, the two-layer patch, what not to touch, self-update and hot-update
  channels, verifying no version request is issued)
- references/account-gates.md: client-side sign-in/binding gates vs account-scoped
  resources, why forging a session is worse than being signed out, session loss after
  reinstall
- SKILL.md / README.md: wire both into the decision list, workflow and indexes
- scripts/dex_classdiff.py: had no argparse, so --help crashed with IndexError; added
  proper CLI, non-zero exit on structural difference, removed a dead duplicate read
- scripts/dex_strings.py: --find compiled the regex as bytes but matched decoded text,
  raising TypeError on the main path; split into text and byte patterns, added
  --max-print output cap
This commit is contained in:
inliver233
2026-09-19 23:01:54 +08:00
parent 4ec14d0c14
commit 173ad0d463
6 changed files with 471 additions and 56 deletions
+59 -35
View File
@@ -9,12 +9,13 @@ IncompatibleClassChangeError ("Found interface X, but class was expected") at ru
Important limitation: this check compares tables only. It cannot see code-item damage —
see references/patch-audit.md for the checks that can.
Usage: python dex_classdiff.py <a.dex> <b.dex> [filter-substring]
"""
import argparse
import struct
import sys
ACC_INTERFACE = 0x200
def uleb128(data, off):
result = 0
@@ -30,26 +31,22 @@ def uleb128(data, off):
def read_strings(data):
off = struct.unpack('<I', data[0x3C:0x40])[0]
size = struct.unpack('<I', data[0x38:0x40][:4])[0]
size = struct.unpack('<I', data[0x38:0x3C])[0]
strings_size = struct.unpack('<I', data[0x38:0x3C])[0]
strings_off = struct.unpack('<I', data[0x3C:0x40])[0]
out = []
for i in range(size):
sdata_off = struct.unpack('<I', data[off + i * 4: off + i * 4 + 4])[0]
for i in range(strings_size):
sdata_off = struct.unpack('<I', data[strings_off + i * 4: strings_off + i * 4 + 4])[0]
n, p = uleb128(data, sdata_off)
raw = data[p:p + n]
try:
out.append(raw.decode('utf-8', 'replace'))
except Exception:
out.append('')
out.append(raw.decode('utf-8', 'replace'))
return out
def class_map(path):
data = open(path, 'rb').read()
strings = read_strings(data)
type_ids_off = struct.unpack('<I', data[0x44:0x48])[0]
n_types = struct.unpack('<I', data[0x40:0x44])[0]
type_ids_off = struct.unpack('<I', data[0x44:0x48])[0]
types = []
for i in range(n_types):
idx = struct.unpack('<I', data[type_ids_off + i * 4: type_ids_off + i * 4 + 4])[0]
@@ -66,41 +63,68 @@ def class_map(path):
def main():
a, b = sys.argv[1], sys.argv[2]
filt = sys.argv[3] if len(sys.argv) > 3 else None
A = class_map(a)
B = class_map(b)
print('A classes=%d B classes=%d' % (len(A), len(B)))
ap = argparse.ArgumentParser(
description='Compare two dex files at class_defs level (names + access_flags). '
'Proves a reassembly did not damage interface/class relationships.')
ap.add_argument('a', help='first dex (e.g. the original)')
ap.add_argument('b', help='second dex (e.g. the rebuilt one)')
ap.add_argument('filter', nargs='?', default=None,
help='only report names containing this substring')
ap.add_argument('--max-list', type=int, default=10,
help='how many A-only / B-only names to print (default 10)')
ap.add_argument('--max-flags', type=int, default=25,
help='how many access_flags differences to print (default 25)')
ap.add_argument('--quiet', action='store_true',
help='print only the summary counts')
args = ap.parse_args()
A = class_map(args.a)
B = class_map(args.b)
only_a = sorted(set(A) - set(B))
only_b = sorted(set(B) - set(A))
print('only_in_A=%d only_in_B=%d' % (len(only_a), len(only_b)))
for n in only_a[:10]:
print(' A-only:', n)
for n in only_b[:10]:
print(' B-only:', n)
diff_iface = []
diff_flags = []
for name in sorted(set(A) & set(B)):
fa, fb = A[name], B[name]
ia, ib = bool(fa & 0x200), bool(fb & 0x200)
if ia != ib:
diff_iface.append((name, hex(fa), hex(fb)))
if bool(fa & ACC_INTERFACE) != bool(fb & ACC_INTERFACE):
diff_iface.append((name, fa, fb))
elif fa != fb:
diff_flags.append((name, hex(fa), hex(fb)))
diff_flags.append((name, fa, fb))
print('A classes=%d B classes=%d' % (len(A), len(B)))
print('only_in_A=%d only_in_B=%d' % (len(only_a), len(only_b)))
if not args.quiet:
for n in only_a[:args.max_list]:
print(' A-only:', n)
for n in only_b[:args.max_list]:
print(' B-only:', n)
print('\n*** ACC_INTERFACE mismatch: %d ***' % len(diff_iface))
for name, fa, fb in (diff_iface if not filt else [x for x in diff_iface if filt in x[0]]):
print(' %-70s A=%s B=%s' % (name, fa, fb))
if not args.quiet:
for name, fa, fb in diff_iface:
if args.filter and args.filter not in name:
continue
print(' %-70s A=%s B=%s' % (name, hex(fa), hex(fb)))
print('\naccess_flags diff (same interface-ness): %d' % len(diff_flags))
shown = 0
for name, fa, fb in diff_flags:
if filt and filt not in name:
continue
if shown < 25:
print(' %-70s A=%s B=%s' % (name, fa, fb))
if not args.quiet:
shown = 0
for name, fa, fb in diff_flags:
if args.filter and args.filter not in name:
continue
if shown >= args.max_flags:
print(' ... (%d more suppressed)' % (len(diff_flags) - shown))
break
print(' %-70s A=%s B=%s' % (name, hex(fa), hex(fb)))
shown += 1
# Exit non-zero when a structural difference exists, so a caller can gate on it.
verdict = (len(only_a) == 0 and len(only_b) == 0 and len(diff_iface) == 0)
print('\nverdict: %s' % ('class tables identical'
if verdict else 'STRUCTURAL DIFFERENCE — inspect above'))
return 0 if verdict else 1
if __name__ == '__main__':
main()
sys.exit(main())
+42 -21
View File
@@ -29,6 +29,8 @@ Notes
*uses* that feature. Confirm with runtime behavior before acting.
* `--urls` output is the fastest way to see an app's whole API surface and its
third-party endpoints at once.
* A dex may be a multi-dex set: run this per directory, not per file, to see which
split holds a given marker.
"""
import argparse
import glob
@@ -38,7 +40,6 @@ import struct
import sys
URL_RE = re.compile(rb'https?://[A-Za-z0-9\.\-_:/%\.\?=&~#]{4,200}')
ASCII_RE = re.compile(rb'[\x20-\x7e]{4,200}')
def uleb128(data, off):
@@ -55,7 +56,7 @@ def uleb128(data, off):
def dex_strings(path):
"""Yield the dex string table in table order."""
"""Yield the dex string table in table order, as bytes."""
data = open(path, 'rb').read()
if data[:4] != b'dex\n':
return
@@ -74,18 +75,28 @@ def iter_dex(target):
def main():
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument('target')
ap.add_argument('--urls', action='store_true')
ap.add_argument('--strings', action='store_true')
ap.add_argument('--classes', metavar='PREFIX')
ap.add_argument('--find', metavar='REGEX')
ap.add_argument('--per-file', action='store_true')
ap.add_argument('--diff', nargs=2, metavar=('A', 'B'))
ap.add_argument('--min', type=int, default=6)
ap.add_argument('--max', type=int, default=200)
ap = argparse.ArgumentParser(
description='Extract strings / URLs / class descriptors / vendor markers from dex files '
'without a decompiler.')
ap.add_argument('target', nargs='?', default=None,
help='a .dex file or a directory containing .dex files (omit when using --diff)')
ap.add_argument('--urls', action='store_true', help='print only http(s) URLs')
ap.add_argument('--strings', action='store_true', help='dump raw string table entries')
ap.add_argument('--classes', metavar='PREFIX',
help='print only entries starting with this prefix (e.g. Lcom/example/app/)')
ap.add_argument('--find', metavar='REGEX',
help='only keep entries matching this regex (applied to the decoded text)')
ap.add_argument('--per-file', action='store_true',
help='with --find: report one line per dex with a hit count, instead of each string')
ap.add_argument('--diff', nargs=2, metavar=('A', 'B'),
help='compare two dex files string tables')
ap.add_argument('--min', type=int, default=6, help='minimum string length (default 6)')
ap.add_argument('--max', type=int, default=200, help='maximum string length (default 200)')
ap.add_argument('--max-print', type=int, default=5000,
help='stop printing after this many lines (default 5000; use 0 for unlimited)')
a = ap.parse_args()
# --diff mode needs no target
if a.diff:
A = set(dex_strings(a.diff[0]))
B = set(dex_strings(a.diff[1]))
@@ -100,24 +111,32 @@ def main():
print(' +', x.decode('utf-8', 'replace'))
return 0
pat = re.compile(a.find.encode()) if a.find else None
if not a.target:
ap.error('target is required unless --diff is used')
# Two patterns: the per-file mode scans raw bytes (fast), the per-string mode
# matches decoded text. Keeping both avoids the bytes/str mismatch that silently
# breaks one of the two paths.
pat_text = re.compile(a.find) if a.find else None
pat_bytes = re.compile(a.find.encode('utf-8')) if a.find else None
files = iter_dex(a.target)
if a.find and a.per_file:
for fp in files:
data = open(fp, 'rb').read()
hits = len(pat.findall(data))
hits = len(pat_bytes.findall(data))
if hits:
print('%-22s hits=%d' % (os.path.basename(fp), hits))
return 0
printed = 0
seen = set()
for fp in files:
for raw in dex_strings(fp):
s = raw.decode('utf-8', 'replace')
if not (a.min <= len(s) <= a.max):
continue
if pat and not pat.search(s):
if pat_text and not pat_text.search(s):
continue
if a.classes and not s.startswith(a.classes):
continue
@@ -126,18 +145,20 @@ def main():
if s in seen:
continue
seen.add(s)
if a.urls:
print(s)
elif a.classes:
if a.max_print and printed >= a.max_print:
print('[note] output cap reached (%d). Use --max-print 0 to lift.' % a.max_print)
return 0
if a.classes:
print('%-18s %s' % (os.path.basename(fp), s))
else:
print('%s' % s)
print(s)
printed += 1
if a.urls and not seen:
# fall back to raw byte scan (covers URLs stored outside the string table)
# fall back to a raw byte scan: covers URLs stored outside the string table
print('[note] no URLs in string tables; raw scan:')
for fp in files:
for m in set(URL_RE.findall(open(fp, 'rb').read())):
for m in sorted(set(URL_RE.findall(open(fp, 'rb').read()))):
print(os.path.basename(fp), m.decode('utf-8', 'replace'))
return 0