Broaden skill with unpacking, TLS triage, third-party build auditing, runtime probe
references: recon gains packer identification + unpacking workflow (payload/plaintext size mapping, dump validation, hash de-duplication, business-dex heuristic); repack-and-sign gains the de-shelled repack pipeline, why apktool over binary AXML, and the apksigner scheme-selection caveat; new tls-and-cert documents feature-scoped network failures, dual trust chains and the fix template; new third-party-builds covers auditing a cracked/modded APK before trusting it; dynamic-frida gains version pinning, remote-device path, the four-layer probe and hook-never-fires triage; environment gains UI automation pitfalls; ad-removal gains the single convergence point, dynamic-plugin SDKs and the global timestamp gate; pitfalls gains P13-P17; verification gains rung 7 and the three-signal ad check. scripts: new frida_probe.js (four-layer runtime probe), run_probe.py and tls_check.py; repack.py fixes two crash-on-default bugs and a misleading META-INF note; datastore_inject.py, PatchMethodExample.java and build_scripts.py lose hardcoded values - the example class name did not match its file and could not compile.
This commit is contained in:
+167
-63
@@ -1,84 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""One-shot builder: copy the battle-tested tools from the project workspace into
|
||||
the skill, stripping machine-specific hardcoding.
|
||||
"""Sync a script directory to a publish location, and audit it for machine-specific leftovers.
|
||||
|
||||
Run from anywhere: python build_scripts.py <project_work_dir> <skill_scripts_dir>
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
An Agent Skill gets copied across machines and across runtimes (Claude Code, Codex,
|
||||
DeepSeek Harness). Anything machine-specific that survives the copy becomes a
|
||||
landmine: a baked-in JDK path, a keystore password, a home directory that only
|
||||
exists on the author's box. This tool does two things:
|
||||
|
||||
1. AUDIT (default, read-only): report every absolute-path literal and every
|
||||
credential-looking constant, with file and line number.
|
||||
2. SYNC (--apply): copy matching files to the destination, optionally after
|
||||
neutralising the absolute-path literals it found (--scrub).
|
||||
|
||||
The audit is the default on purpose: a silent copy is exactly how the leftovers
|
||||
travel from the author's machine into someone else's first run.
|
||||
|
||||
USAGE
|
||||
-----
|
||||
python build_scripts.py scripts2 out # audit only, nothing written
|
||||
python build_scripts.py scripts out --apply # copy
|
||||
python build_scripts.py scripts out --apply --scrub # copy, neutralising abs paths
|
||||
python build_scripts.py scripts out --pattern '*.py' --pattern '*.js'
|
||||
|
||||
Exit codes: 0 = clean, 2 = findings reported (audit mode), 1 = usage or IO error.
|
||||
|
||||
NOTES
|
||||
-----
|
||||
* A finding is a heuristic, not a verdict: `/data/local/tmp/...` is a legitimate
|
||||
Android device path and is deliberately NOT reported; a drive-letter path is.
|
||||
* --scrub rewrites `'<abs path>'` literals into `''`, which keeps the file
|
||||
syntactically valid but leaves the decision (and the fix) to a human. It never
|
||||
runs unless --apply is also given.
|
||||
"""
|
||||
import argparse
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
SRC = sys.argv[1] if len(sys.argv) > 1 else r'E:\ciyuan\work'
|
||||
DST = sys.argv[2] if len(sys.argv) > 2 else r'E:\ciyuan\apk-reverse\scripts'
|
||||
DEFAULT_PATTERNS = ('*.py', '*.js', '*.java', '*.md', '*.txt')
|
||||
|
||||
# file -> (new name, list of (pattern, replacement))
|
||||
COPY = {
|
||||
r'tools\dex_strpatch.py': ('dex_strpatch.py', []),
|
||||
r'tools\dex_classdiff.py': ('dex_classdiff.py', []),
|
||||
r'tools\patch_smali.py': ('patch_smali.py', []),
|
||||
r'tools\repack.py': ('repack.py', [
|
||||
# strip machine-specific defaults; make them explicit args
|
||||
(re.compile(r'KS_PATH\s*=\s*r?["\'][^"\']*["\']'), 'KS_PATH = None # pass --ks on the command line'),
|
||||
(re.compile(r"workdir'?,?\s*default=r?['\"][^'\"]*['\"]"), "workdir', default=None"),
|
||||
]),
|
||||
r'tools\cyc_proxy.py': ('usb_net_proxy.py', []),
|
||||
r'tools\mk_video_prefs.py': ('datastore_inject.py', []),
|
||||
r'tools\probe_adverts.py': ('probe_api.py', []),
|
||||
r'tools\grab_crash.py': ('grab_crash.py', []),
|
||||
}
|
||||
# Absolute-path literals that belong to one particular machine.
|
||||
# Deliberately excluded: /data/... (an Android device path, not a host path).
|
||||
WINDOWS_LITERAL = re.compile(r"(?P<prefix>(?:r|u|b)?r?)(?P<quote>['\"])"
|
||||
r"(?P<path>[A-Za-z]:[\\/][^'\"]*)(?P=quote)")
|
||||
POSIX_LITERAL = re.compile(r"(?P<prefix>(?:r|u|b)?r?)(?P<quote>['\"])"
|
||||
r"(?P<path>/(?:home|Users|mnt)/[^'\"]*)(?P=quote)")
|
||||
|
||||
# Any drive-letter or foreign-home path outside a quoted literal as well.
|
||||
# Tuned to avoid the two false-positive families that made an earlier version of
|
||||
# this audit useless: a URL scheme ("https://" contains "s:" + "/") and a Python
|
||||
# escape sequence ("is:\n" contains "s:" + "\"). Hence: the previous character must
|
||||
# not be alphanumeric, a drive letter must be followed by a real backslash (not an
|
||||
# escape letter, not a second separator), and the POSIX form must start a path.
|
||||
BARE_ABS = re.compile(
|
||||
r"(?<![A-Za-z0-9])(?:"
|
||||
r"[A-Za-z]:\\(?![ntr0bxu'\"\\])\S{2,}"
|
||||
r"|/(?:home|Users|mnt)/[A-Za-z0-9_.\-]{2,}"
|
||||
r")")
|
||||
|
||||
CREDENTIAL_HINT = re.compile(
|
||||
r"(?i)(BEGIN [A-Z ]*PRIVATE KEY|storepass\s*=\s*['\"][^'\"]|"
|
||||
r"keypass\s*=\s*['\"][^'\"]|password\s*=\s*['\"][^'\"]|"
|
||||
r"api[_-]?key\s*=\s*['\"][^'\"]|secret\s*=\s*['\"][^'\"]{6,})")
|
||||
|
||||
|
||||
def clean(text):
|
||||
"""Remove absolute paths that point at one particular machine."""
|
||||
text = re.sub(r"r?'[A-Za-z]:\\\\[^'\"]*'", "''", text)
|
||||
text = re.sub(r'"[A-Za-z]:\\\\[^"]*"', '""', text)
|
||||
return text
|
||||
def collect(src, patterns):
|
||||
"""Return [(absolute_path, relative_path)] for every matching file."""
|
||||
out = []
|
||||
for root, _dirs, files in os.walk(src):
|
||||
for name in sorted(files):
|
||||
if any(fnmatch.fnmatch(name, p) for p in patterns):
|
||||
full = os.path.join(root, name)
|
||||
out.append((full, os.path.relpath(full, src)))
|
||||
return out
|
||||
|
||||
|
||||
def audit(path, rel):
|
||||
"""Return [(kind, lineno, detail)] for one file."""
|
||||
findings = []
|
||||
try:
|
||||
with open(path, encoding='utf-8', errors='replace') as fh:
|
||||
lines = fh.readlines()
|
||||
except OSError as exc:
|
||||
return [('io-error', 0, str(exc))]
|
||||
|
||||
for lineno, line in enumerate(lines, 1):
|
||||
for match in BARE_ABS.finditer(line):
|
||||
findings.append(('abs-path', lineno, match.group(0)[:90]))
|
||||
if CREDENTIAL_HINT.search(line):
|
||||
findings.append(('credential', lineno, line.strip()[:110]))
|
||||
return findings
|
||||
|
||||
|
||||
def scrub(text):
|
||||
"""Neutralise absolute-path literals; returns (new_text, count)."""
|
||||
total = 0
|
||||
|
||||
def repl(_match):
|
||||
return "''"
|
||||
|
||||
for pattern in (WINDOWS_LITERAL, POSIX_LITERAL):
|
||||
text, count = pattern.subn(repl, text)
|
||||
total += count
|
||||
return text, total
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(DST, exist_ok=True)
|
||||
os.makedirs(os.path.join(DST, 'dexpatch'), exist_ok=True)
|
||||
for rel, (new, subs) in COPY.items():
|
||||
src = os.path.join(SRC, rel)
|
||||
if not os.path.isfile(src):
|
||||
print('[skip missing] %s' % rel)
|
||||
ap = argparse.ArgumentParser(
|
||||
description='Sync a script directory to a publish location and audit it for '
|
||||
'machine-specific leftovers (absolute paths, hardcoded credentials).')
|
||||
ap.add_argument('src', help='source directory to publish from')
|
||||
ap.add_argument('dst', help='destination directory to publish into')
|
||||
ap.add_argument('--pattern', action='append', default=list(DEFAULT_PATTERNS),
|
||||
help='glob to include, repeatable (default: %s)'
|
||||
% ' '.join(DEFAULT_PATTERNS))
|
||||
ap.add_argument('--apply', action='store_true',
|
||||
help='actually copy files (default is audit only)')
|
||||
ap.add_argument('--scrub', action='store_true',
|
||||
help='with --apply, neutralise absolute-path literals in the copies')
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isdir(args.src):
|
||||
print('error: source directory not found: %s' % args.src)
|
||||
return 1
|
||||
|
||||
files = collect(args.src, args.pattern)
|
||||
if not files:
|
||||
print('error: no files matched %s in %s'
|
||||
% (', '.join(args.pattern), args.src))
|
||||
return 1
|
||||
|
||||
total_findings = 0
|
||||
scrubbed = 0
|
||||
copied = 0
|
||||
|
||||
for full, rel in files:
|
||||
findings = audit(full, rel)
|
||||
abs_findings = [f for f in findings if f[0] == 'abs-path']
|
||||
cred_findings = [f for f in findings if f[0] == 'credential']
|
||||
other = [f for f in findings if f[0] not in ('abs-path', 'credential')]
|
||||
|
||||
if findings:
|
||||
print('-- %s' % rel)
|
||||
for kind, lineno, detail in abs_findings + cred_findings + other:
|
||||
print(' [%s] line %d: %s' % (kind, lineno, detail))
|
||||
total_findings += len(findings)
|
||||
|
||||
if not args.apply:
|
||||
continue
|
||||
t = open(src, encoding='utf-8', errors='replace').read()
|
||||
for pat, rep in subs:
|
||||
t = pat.sub(rep, t)
|
||||
out = os.path.join(DST, new)
|
||||
with open(out, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write(t)
|
||||
print('[copied] %-24s -> %s' % (rel, new))
|
||||
|
||||
# dexlib2 patcher sources
|
||||
for rel, new in [(r'tools\dexpatch\PatchMethod.java', 'PatchMethod.java'),
|
||||
(r'tools\dexpatch\PatchClasses7v2.java', 'PatchMethodExample.java')]:
|
||||
src = os.path.join(SRC, rel)
|
||||
if os.path.isfile(src):
|
||||
t = open(src, encoding='utf-8', errors='replace').read()
|
||||
with open(os.path.join(DST, 'dexpatch', new), 'w',
|
||||
encoding='utf-8', newline='\n') as f:
|
||||
f.write(t)
|
||||
print('[copied] %-24s -> dexpatch/%s' % (rel, new))
|
||||
else:
|
||||
print('[skip missing] %s' % rel)
|
||||
target = os.path.join(args.dst, rel)
|
||||
os.makedirs(os.path.dirname(target) or '.', exist_ok=True)
|
||||
|
||||
# cp.txt content -> smali_cp.txt template
|
||||
cp = os.path.join(SRC, 'tools', 'dexpatch', 'cp.txt')
|
||||
if os.path.isfile(cp):
|
||||
raw = open(cp, encoding='utf-8').read().strip()
|
||||
jars = [j for j in raw.split(';') if j.strip()]
|
||||
with open(os.path.join(DST, 'smali_cp.txt'), 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write('# One jar path per line. Adjust the directory to your own tools.\n')
|
||||
f.write('# Needed: smali baksmali dexlib2 util antlr-runtime stringtemplate '
|
||||
'jcommander guava\n')
|
||||
for j in jars:
|
||||
f.write(os.path.basename(j) + '\n')
|
||||
print('[wrote] smali_cp.txt (%d jars)' % len(jars))
|
||||
if args.scrub and (abs_findings or cred_findings):
|
||||
with open(full, encoding='utf-8', errors='replace') as fh:
|
||||
text = fh.read()
|
||||
new_text, count = scrub(text)
|
||||
if count:
|
||||
with open(target, 'w', encoding='utf-8', newline='\n') as fh:
|
||||
fh.write(new_text)
|
||||
scrubbed += count
|
||||
print(' -> copied with %d absolute-path literal(s) neutralised' % count)
|
||||
copied += 1
|
||||
continue
|
||||
|
||||
shutil.copy2(full, target)
|
||||
copied += 1
|
||||
|
||||
print('')
|
||||
if not args.apply:
|
||||
print('== audit: %d file(s) checked, %d finding(s). Nothing was written.'
|
||||
% (len(files), total_findings))
|
||||
print(' re-run with --apply to copy, and with --scrub to neutralise '
|
||||
'absolute-path literals.')
|
||||
return 2 if total_findings else 0
|
||||
|
||||
print('== sync: %d file(s) copied to %s (%d literal(s) scrubbed), %d finding(s) reported.'
|
||||
% (copied, args.dst, scrubbed, total_findings))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
sys.exit(main())
|
||||
|
||||
Reference in New Issue
Block a user