mirror of
https://codeberg.org/reverse-skill-lab/apk-reverse
synced 2026-11-15 20:04:22 +08:00
PR #3 proposed adding mypy and flake8 to CI, with configurations for both. The idea is right and the configurations are kept. Three things about the PR itself needed correcting, and all three were found by running the gate rather than reading the diff: 1. It was written against an older tree. Its own edits reference code that no longer exists there (`unknown, positions, warnings = ...`, a `struct` import at line 857 that is used 600 lines later), so merging it as-is would have removed a live import. The fixes were re-derived against the current tree instead, and the PR is superseded. 2. It added a `paths:` filter to the workflow, watching only `*.py` plus the two lint configs. That would stop `check_refs.py` and `check_routing.py` -- documentation gates -- from running on a documentation-only commit, which is exactly the change they exist to catch. The workflow keeps the PR's `actions/*@v7` bump and gains no filter; the reason is written next to the trigger. 3. `pip install --upgrade pip pytest` was dropped to just the packages, because upgrading pip in CI is not a goal and can fail for reasons unrelated to this repository. What the gate found on the current tree, and what was done about it -- 70 flake8 findings and 3 mypy errors, none of which were cosmetic-only: F401 (23) unused imports, removed. These were safe to delete because the import statement itself cannot have a side effect that matters here. F841 (12) unused bindings, handled three ways rather than one. Dead code was deleted (`mt_mcp_probe.print_waiting_help`'s `host`, `repack.py`'s `signer_jar`); a computed value that was then forgotten was **wired up** (`rasc_build.py` now names the linker target in its build-failure hint instead of discarding it); and a binding that is intentional kept its place with `# noqa: F841` and the reason -- `java2c_probe` reads dex header fields one by one against the format spec, and deleting three of them to please a linter would have made the parser stop lining up with the format it implements. E741 (14) the ambiguous loop name `l`, renamed with an AST pass that resolves each binding to its own scope. A regex rename would have been wrong: several scopes bind their own `l`. That pass had a defect of its own -- it treated a comprehension target as living in the comprehension scope while leaving references to it in the enclosing one -- which produced seven `undefined name 'l'`. Found by running flake8, fixed, and the counting bug is documented in `check_routing.py`, whose own `tables()` had the same class of error in the other direction. style continuation indents, blank lines between top-level definitions, and lines over length. The line limit is 160, not 120, and the number is a **measurement**: this repository's secret-scanner rule table holds raw regex source of 148 and 156 characters, which cannot be split without changing the pattern. mypy (3) two missing annotations on module-level containers, and one optional-dependency rebind (`dexutil = None` after `except ImportError`) that is the repository's own idiom and now says so with a `type: ignore[assignment]` and the reason. No code was reordered to satisfy the checker. Verified on a venv containing only `mypy` and `flake8` -- the poorest environment the configuration must work in, and the one CI uses: **flake8 reports 0, mypy reports "Success: no issues found in 55 source files"**. Behaviour is unchanged: 625 passed / 163 skipped / 25 xfailed, the same five gates green (check_repo 0 problem, check_refs 0 dangling, check_routing coherent, check_budget 0 failure, check_commands 0 drift), and every key script still prints help. .flake8 new, with the line-length and complexity numbers and their reasons mypy.ini new; optional imports declared, nothing excluded from the analysis ci.yml `actions/*@v7`; new `lint` job on one interpreter; no path filter 30 files the findings above
275 lines
11 KiB
Python
275 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Decode (and re-encode) an opaque single-value blob cached by an app.
|
|
|
|
Many apps cache server-issued configuration as ONE opaque string inside a
|
|
preferences file or a key-value store. The encoding is usually some stack of:
|
|
outer: base64, base64url, or hex
|
|
transform: a cyclic rotation of the byte stream (so the compressed stream
|
|
does not begin at its header), sometimes preceded by a short
|
|
header to skip
|
|
inner: raw deflate, zlib, or gzip
|
|
|
|
You do not have to guess the parameters. This script searches them and reports
|
|
every combination that decodes to something structured, then can re-encode your
|
|
edited payload with the same parameters so it can be written back.
|
|
|
|
Why rotation matters: a plain base64->inflate attempt fails on these blobs, and
|
|
the failure looks like "this value is encrypted". Exhaustively trying cyclic
|
|
shifts is cheap and frequently succeeds, which turns an opaque blob into an
|
|
editable JSON/XML document.
|
|
|
|
Usage:
|
|
# decode the value of a key inside a prefs XML
|
|
python blob_decode.py --prefs-xml prefs.xml --name flutter.remoteConfig
|
|
|
|
# decode a blob stored in its own file (or piped on stdin)
|
|
python blob_decode.py --file value.txt
|
|
cat value.txt | python blob_decode.py
|
|
|
|
# keep searching harder (slower) and write the best result out
|
|
python blob_decode.py --file value.txt --max-skip 64 --out decoded.bin
|
|
|
|
# re-encode an edited payload with the parameters that worked
|
|
python blob_decode.py --encode --file decoded.bin --outer base64 --inner raw --cut 15435 --out value.txt
|
|
|
|
Notes:
|
|
* Output is written to --out; nothing on device is touched.
|
|
* The winning parameters are printed as a copy-pasteable line for --encode.
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
import binascii
|
|
import re
|
|
import sys
|
|
import time
|
|
import zlib
|
|
|
|
INNERS = [("raw", -15), ("zlib", 15), ("gzip", 31)]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# outer encodings
|
|
# --------------------------------------------------------------------------- #
|
|
def outer_decode(text: str, which: str) -> bytes | None:
|
|
t = text.strip().replace("\n", "").replace("\r", "").replace(" ", "")
|
|
try:
|
|
if which == "base64":
|
|
return base64.b64decode(t + "=" * (-len(t) % 4))
|
|
if which == "base64url":
|
|
return base64.urlsafe_b64decode(t + "=" * (-len(t) % 4))
|
|
if which == "hex":
|
|
return binascii.unhexlify(re.sub(r"[^0-9a-fA-F]", "", t))
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
def outer_encode(data: bytes, which: str) -> str:
|
|
if which == "base64":
|
|
return base64.b64encode(data).decode("ascii")
|
|
if which == "base64url":
|
|
return base64.urlsafe_b64encode(data).decode("ascii")
|
|
if which == "hex":
|
|
return binascii.hexlify(data).decode("ascii")
|
|
raise ValueError("unknown outer encoding: %s" % which)
|
|
|
|
|
|
def sniff_outer(text: str) -> list[str]:
|
|
"""Cheap guess first so the common case is instant."""
|
|
t = text.strip()
|
|
guesses = []
|
|
if re.fullmatch(r"[0-9a-fA-F\s]+", t) and len(t) >= 16:
|
|
guesses.append("hex")
|
|
if re.fullmatch(r"[A-Za-z0-9+/\s=]+", t):
|
|
guesses.append("base64")
|
|
if re.fullmatch(r"[A-Za-z0-9\-_\s=]+", t):
|
|
guesses.append("base64url")
|
|
# always keep base64 last-resort, then everything else
|
|
for w in ("base64", "base64url", "hex"):
|
|
if w not in guesses:
|
|
guesses.append(w)
|
|
return list(dict.fromkeys(guesses))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# inner transforms
|
|
# --------------------------------------------------------------------------- #
|
|
def try_inflate(body: bytes, max_out: int | None = None) -> tuple[str, bytes] | None:
|
|
best = None
|
|
for name, wbits in INNERS:
|
|
try:
|
|
if max_out is None:
|
|
out = zlib.decompress(body, wbits)
|
|
else:
|
|
d = zlib.decompressobj(wbits)
|
|
out = d.decompress(body, max_out)
|
|
if out:
|
|
if best is None or len(out) > len(best[1]):
|
|
best = (name, out)
|
|
except Exception:
|
|
continue
|
|
return best
|
|
|
|
|
|
def inflate_raw(data: bytes) -> bytes:
|
|
co = zlib.compressobj(9, zlib.DEFLATED, -15)
|
|
return co.compress(data) + co.flush()
|
|
|
|
|
|
def score(out: bytes) -> float:
|
|
"""How much does this look like a real document we can edit?"""
|
|
if not out:
|
|
return -1.0
|
|
printable = sum(1 for b in out[:4096] if 32 <= b < 127 or b in (9, 10, 13))
|
|
ratio = printable / min(len(out), 4096)
|
|
s = ratio * 10.0
|
|
head = out[:64].lstrip()
|
|
if head[:1] in (b"{", b"[", b"<", b"#"):
|
|
s += 6.0
|
|
if b'"' in out[:512] or b"=" in out[:512]:
|
|
s += 2.0
|
|
if len(out) < 32:
|
|
s -= 3.0
|
|
return s
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
def search(blob: bytes, max_skip: int, log) -> list[dict]:
|
|
hits: list[dict] = []
|
|
n = len(blob)
|
|
t0 = time.time()
|
|
|
|
for skip in range(0, min(max_skip, n) + 1):
|
|
base = blob[skip:]
|
|
m = len(base)
|
|
if m < 8:
|
|
break
|
|
for k in range(m):
|
|
cand = base[k:] + base[:k]
|
|
got = try_inflate(cand, max_out=8_000_000)
|
|
if got:
|
|
name, out = got
|
|
hits.append({
|
|
"skip": skip, "cut": k, "inner": name, "size": len(out),
|
|
"score": round(score(out), 2), "data": out,
|
|
})
|
|
if hits and log and skip == 0:
|
|
log(" first hit found with skip=0 after %.1fs" % (time.time() - t0))
|
|
if log and skip and skip % 16 == 0 and time.time() - t0 > 30:
|
|
log(" ... skip=%d, %.0fs elapsed" % (skip, time.time() - t0))
|
|
log(" search finished in %.1fs (%d candidate(s))" % (time.time() - t0, len(hits)))
|
|
return hits
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
src = ap.add_mutually_exclusive_group()
|
|
src.add_argument("--file", help="file containing the encoded value")
|
|
src.add_argument("--prefs-xml", help="a preferences XML file to pull the value out of")
|
|
ap.add_argument("--name", help="key name when using --prefs-xml")
|
|
ap.add_argument("--outer", choices=["base64", "base64url", "hex", "auto"], default="auto")
|
|
ap.add_argument("--max-skip", type=int, default=0,
|
|
help="also try dropping 0..N leading bytes before the rotation search (slower)")
|
|
ap.add_argument("--min-size", type=int, default=200, help="ignore results smaller than this")
|
|
ap.add_argument("--top", type=int, default=5, help="how many candidates to print")
|
|
ap.add_argument("--out", help="write the best decoded result here")
|
|
|
|
ap.add_argument("--encode", action="store_true", help="re-encode instead of decoding")
|
|
ap.add_argument("--inner", choices=[n for n, _ in INNERS], default="raw")
|
|
ap.add_argument("--cut", type=int, default=0, help="rotation used for write-back")
|
|
a = ap.parse_args()
|
|
|
|
def log(msg: str) -> None:
|
|
print(msg, file=sys.stderr)
|
|
|
|
# ------------------------------------------------------------------ encode
|
|
if a.encode:
|
|
if not a.file:
|
|
log("--encode requires --file (the payload to encode)")
|
|
return 2
|
|
payload = open(a.file, "rb").read()
|
|
body = inflate_raw(payload) if a.inner == "raw" else zlib.compress(payload, 9)
|
|
if a.inner == "zlib":
|
|
body = zlib.compress(payload, 9)
|
|
cut = a.cut % max(len(body), 1)
|
|
rotated = body[-cut:] + body[:-cut] if cut else body
|
|
enc = outer_encode(rotated, "base64" if a.outer == "auto" else a.outer)
|
|
if a.out:
|
|
open(a.out, "w", encoding="utf-8", newline="\n").write(enc)
|
|
log("wrote %d chars to %s" % (len(enc), a.out))
|
|
else:
|
|
print(enc)
|
|
log("write-back parameters: outer=%s inner=%s cut=%d"
|
|
% ("base64" if a.outer == "auto" else a.outer, a.inner, cut))
|
|
return 0
|
|
|
|
# ------------------------------------------------------------------ input
|
|
if a.prefs_xml:
|
|
if not a.name:
|
|
log("--prefs-xml requires --name <key>")
|
|
return 2
|
|
xml = open(a.prefs_xml, encoding="utf-8", errors="replace").read()
|
|
m = re.search(r'name="%s"[^>]*>([^<]*)<' % re.escape(a.name), xml)
|
|
if not m:
|
|
log("key not found in XML: %s" % a.name)
|
|
log("keys present: %s" % ", ".join(re.findall(r'name="([^"]+)"', xml)[:40]))
|
|
return 2
|
|
text = m.group(1)
|
|
log("value length: %d" % len(text))
|
|
elif a.file:
|
|
text = open(a.file, encoding="utf-8", errors="replace").read()
|
|
else:
|
|
text = sys.stdin.read()
|
|
|
|
if not text.strip():
|
|
log("empty input")
|
|
return 2
|
|
|
|
# ------------------------------------------------------------------ decode
|
|
candidates = [a.outer] if a.outer != "auto" else sniff_outer(text)
|
|
for which in candidates:
|
|
blob = outer_decode(text, which)
|
|
if blob is None or len(blob) < 8:
|
|
log("outer=%s -> not decodable, skipping" % which)
|
|
continue
|
|
log("outer=%s -> %d bytes; searching rotation (and skip<=%d)" % (which, len(blob), a.max_skip))
|
|
hits = [h for h in search(blob, a.max_skip, log) if h["size"] >= a.min_size]
|
|
if not hits:
|
|
continue
|
|
hits.sort(key=lambda h: (-h["score"], -h["size"]))
|
|
print("=" * 66)
|
|
print("outer=%s candidates=%d" % (which, len(hits)))
|
|
for h in hits[:a.top]:
|
|
print(" score=%-6s skip=%-4d cut=%-7d inner=%-5s size=%d"
|
|
% (h["score"], h["skip"], h["cut"], h["inner"], h["size"]))
|
|
best = hits[0]
|
|
print("-" * 66)
|
|
print("write-back line:")
|
|
print(" python blob_decode.py --encode --file <edited_payload> --outer %s --inner %s --cut %d"
|
|
% (which, best["inner"], best["cut"]))
|
|
print("-" * 66)
|
|
preview = best["data"][:1200]
|
|
try:
|
|
print(preview.decode("utf-8"))
|
|
except UnicodeDecodeError:
|
|
print(preview.decode("latin1"))
|
|
print("(non-UTF-8; first 64 bytes: %s)" % best["data"][:64].hex())
|
|
if a.out:
|
|
open(a.out, "wb").write(best["data"])
|
|
log("wrote %d bytes to %s" % (len(best["data"]), a.out))
|
|
return 0
|
|
|
|
print("no decodable candidate found.", file=sys.stderr)
|
|
print("Next steps:", file=sys.stderr)
|
|
print(" * raise --max-skip, or pass --outer explicitly", file=sys.stderr)
|
|
print(" * the blob may be genuinely encrypted (a keyed cipher) rather than merely", file=sys.stderr)
|
|
print(" encoded -- confirm before spending time on it: a keyed blob should look", file=sys.stderr)
|
|
print(" random, whereas an encoded one still has structure once the framing is", file=sys.stderr)
|
|
print(" removed. See references/runtime-data.md.", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|