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
280 lines
12 KiB
Python
280 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Extract native crash blocks from a log or tombstone and locate the fault.
|
|
|
|
Native deaths are the expensive kind: no Java stack, often no obvious cause, and
|
|
the interesting question ("which library, which offset, is this a real fault or
|
|
an arranged one?") is buried in a debug dump that is tedious to read by hand.
|
|
|
|
This reads a saved `logcat` capture or a tombstone file and reports, per crash:
|
|
|
|
* the signal, fault address, and cause
|
|
* the register state that matters (the faulting pointer)
|
|
* the backtrace, split into your own libraries vs system libraries
|
|
* a verdict hint when the fault address looks *arranged* rather than accidental
|
|
|
|
With `--lib NAME=PATH` it also resolves a frame to a local copy of the library
|
|
and prints the bytes at that offset, plus a disassembly window when a decoder is
|
|
available.
|
|
|
|
Why the verdict hint matters: a hardening layer that decides the environment is
|
|
hostile rarely calls `kill`. It more often arranges a fault -- load a small
|
|
constant, use it as a pointer -- so the death looks like an ordinary bug. The
|
|
tell is a fault address that is a small integer combined with a register holding
|
|
that same integer. This script flags that shape so you look at the right place
|
|
instead of hunting a bug that is not there.
|
|
|
|
Usage
|
|
-----
|
|
python native_crash.py capture.txt
|
|
python native_crash.py tombstone_07
|
|
python native_crash.py capture.txt --lib libfoo.so=/path/to/local/libfoo.so
|
|
python native_crash.py capture.txt --lib libfoo.so=./libfoo.so --disasm 16
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import struct
|
|
import sys
|
|
|
|
SIGNAL_RE = re.compile(
|
|
r"signal\s+(\d+)\s+\((\w+)\)\s*,?\s*code\s+(\d+)\s*\(([^)]+)\)\s*,?\s*fault addr\s+(0x[0-9a-fA-F]+)")
|
|
PID_RE = re.compile(r"pid:\s*(\d+),\s*tid:\s*(\d+),\s*name:\s*(\S+)\s*>>>\s*(\S+)")
|
|
PROC_RE = re.compile(r"Cmdline:\s*(\S+)")
|
|
UPTIME_RE = re.compile(r"Process uptime:\s*(\d+)")
|
|
FRAME_RE = re.compile(r"#(\d+)\s+pc\s+([0-9a-fA-F]+)\s+(\S+)")
|
|
REG_RE = re.compile(r"^\s*([xX]\d{1,2}|sp|lr|pc|pst)\s+([0-9a-fA-F]{8,16})")
|
|
SYSTEM_LIB_RE = re.compile(r"^/(apex|system|vendor|data/dalvik-cache|memfd)")
|
|
|
|
|
|
def is_system_lib(path):
|
|
if not path.startswith("/"):
|
|
return True
|
|
return bool(SYSTEM_LIB_RE.match(path))
|
|
|
|
|
|
def parse(text):
|
|
"""Yield crash dicts."""
|
|
lines = text.splitlines()
|
|
crashes = []
|
|
i = 0
|
|
while i < len(lines):
|
|
m = SIGNAL_RE.search(lines[i])
|
|
# some ROMs print the signal line without "F DEBUG"; accept both
|
|
if not m:
|
|
i += 1
|
|
continue
|
|
block = {"signal": int(m.group(1)), "signame": m.group(2),
|
|
"si_code": m.group(4), "fault": m.group(5),
|
|
"frames": [], "regs": {}, "raw": []}
|
|
# walk up a little to pick up pid/cmdline/uptime printed just before
|
|
for j in range(max(0, i - 25), i):
|
|
ln = lines[j]
|
|
mm = PID_RE.search(ln)
|
|
if mm and "pid" not in block:
|
|
block["pid"], block["tid"], block["tname"], block["proc"] = mm.groups()
|
|
mm = PROC_RE.search(ln)
|
|
if mm and "proc" not in block:
|
|
block["proc"] = mm.group(1)
|
|
mm = UPTIME_RE.search(ln)
|
|
if mm:
|
|
block["uptime"] = mm.group(1)
|
|
if "Cause:" in ln and "cause" not in block:
|
|
block["cause"] = ln.split("Cause:", 1)[1].strip()
|
|
# walk down through the dump
|
|
j = i
|
|
while j < len(lines) and j < i + 120:
|
|
ln = lines[j]
|
|
if j > i + 3 and SIGNAL_RE.search(ln) and j > i + 3:
|
|
break
|
|
block["raw"].append(ln.strip())
|
|
mm = PID_RE.search(ln)
|
|
if mm and "pid" not in block:
|
|
block["pid"], block["tid"], block["tname"], block["proc"] = mm.groups()
|
|
mm = PROC_RE.search(ln)
|
|
if mm and "proc" not in block:
|
|
block["proc"] = mm.group(1)
|
|
mm = UPTIME_RE.search(ln)
|
|
if mm:
|
|
block["uptime"] = mm.group(1)
|
|
if "Cause:" in ln and "cause" not in block:
|
|
block["cause"] = ln.split("Cause:", 1)[1].strip()
|
|
mm = FRAME_RE.search(ln)
|
|
if mm:
|
|
# Backtrace lines look like either of:
|
|
# #00 pc 00000000000128cc /data/app/.../lib/arm64/libfoo.so
|
|
# #02 pc 00000000000cccd0 /apex/.../libc.so (__pthread_start+256)
|
|
# The path is group(3); anything after it is a symbol in parens.
|
|
path = mm.group(3)
|
|
rest = ln.split(path, 1)[1].strip() if path in ln else ""
|
|
sym = ""
|
|
m2 = re.search(r"\(([^)]+)\)", rest)
|
|
if m2:
|
|
sym = m2.group(1)
|
|
block["frames"].append({
|
|
"n": mm.group(1), "pc": int(mm.group(2), 16),
|
|
"path": path, "symbol": sym,
|
|
})
|
|
# Register dumps put several registers on one line, so collect all of
|
|
# them rather than only the leading one.
|
|
for k, v in re.findall(r"\b([xX]\d{1,2}|sp|lr|pc|pst)\s+([0-9a-fA-F]{8,16})\b",
|
|
ln.replace("F DEBUG :", " ")):
|
|
block["regs"][k] = int(v, 16)
|
|
if "backtrace:" in ln:
|
|
pass
|
|
j += 1
|
|
i = j if j > i else i + 1
|
|
crashes.append(block)
|
|
return crashes
|
|
|
|
|
|
def verdict_hint(crash):
|
|
hints = []
|
|
fault = int(crash["fault"], 16)
|
|
regs = crash["regs"]
|
|
if fault <= 0x100:
|
|
holder = [k for k, v in regs.items() if v == fault]
|
|
hints.append("fault address is a small integer (%s)." % crash["fault"])
|
|
if holder:
|
|
hints.append("register(s) %s hold exactly that value -- this is the shape of an "
|
|
"ARRANGED fault, not an accident." % ", ".join(sorted(holder)))
|
|
hints.append("check whether the instruction at the faulting pc loaded that constant "
|
|
"a few instructions earlier and used it as a pointer.")
|
|
if crash["signame"] == "SIGKILL":
|
|
hints.append("SIGKILL with no tombstone: this is a deliberate termination, not a bug. "
|
|
"No crash dump exists because nothing faulted.")
|
|
if crash.get("cause") and "null pointer" in crash["cause"] and fault > 0x100:
|
|
hints.append("a genuine null-ish dereference; treat as an ordinary bug unless a small "
|
|
"constant is involved.")
|
|
return hints
|
|
|
|
|
|
def load_libs(specs):
|
|
out = {}
|
|
for s in specs or []:
|
|
if "=" not in s:
|
|
sys.exit("--lib expects NAME=PATH, got %r" % s)
|
|
name, path = s.split("=", 1)
|
|
with open(path, "rb") as fh:
|
|
out[name] = fh.read()
|
|
return out
|
|
|
|
|
|
def maybe_disasm(data, offset, count):
|
|
try:
|
|
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM, CS_ARCH_X86, CS_MODE_64
|
|
except Exception:
|
|
return None
|
|
# guess arch from ELF e_machine
|
|
if data[:4] != b"\x7fELF":
|
|
return None
|
|
machine = struct.unpack_from("<H", data, 18)[0]
|
|
if machine == 183:
|
|
md = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
|
|
start = offset & ~3
|
|
elif machine == 62:
|
|
md = Cs(CS_ARCH_X86, CS_MODE_64)
|
|
start = max(0, offset - 16)
|
|
else:
|
|
return None
|
|
out = []
|
|
for ins in md.disasm(data[start:start + count * 8], start):
|
|
mark = " <<<" if ins.address <= offset < ins.address + ins.size else ""
|
|
out.append(" 0x%06x %-10s %s%s" % (ins.address, ins.mnemonic, ins.op_str, mark))
|
|
if len(out) >= count:
|
|
break
|
|
if not out:
|
|
# capstone stopped immediately -- say so rather than implying emptiness
|
|
return [" (decoder produced nothing from this offset; a silent stop is not "
|
|
"evidence there is no code here)"]
|
|
return out
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Locate native crashes from a log or tombstone.")
|
|
ap.add_argument("logfile", help="logcat capture or tombstone file")
|
|
ap.add_argument("--lib", action="append", metavar="NAME=PATH",
|
|
help="resolve frames for this library name to a local file")
|
|
ap.add_argument("--disasm", type=int, default=0, metavar="N",
|
|
help="disassemble N instructions around the faulting pc")
|
|
ap.add_argument("--frames", type=int, default=12, help="how many frames to print")
|
|
args = ap.parse_args()
|
|
|
|
if not os.path.exists(args.logfile):
|
|
sys.exit("no such file: %s" % args.logfile)
|
|
with open(args.logfile, "r", encoding="utf-8", errors="replace") as fh:
|
|
text = fh.read()
|
|
|
|
crashes = parse(text)
|
|
if not crashes:
|
|
print("no native crash block found in %s" % args.logfile)
|
|
print("")
|
|
print("This is a real negative only if the capture covers the death. Check:")
|
|
print(" - was the crash buffer cleared *after* the build under test was installed?")
|
|
print(" - does the capture include the tombstone? (a SIGKILL leaves none)")
|
|
print(" - did the process die at all, or did it merely restart?")
|
|
return
|
|
|
|
libs = load_libs(args.lib)
|
|
|
|
for idx, c in enumerate(crashes, 1):
|
|
print("=" * 72)
|
|
print("CRASH %d" % idx)
|
|
print(" process : %s" % c.get("proc", "?"))
|
|
print(" pid/tid : %s / %s" % (c.get("pid", "?"), c.get("tid", "?")))
|
|
if c.get("uptime"):
|
|
print(" uptime : %ss <-- time from launch to death; your test window must "
|
|
"exceed this" % c["uptime"])
|
|
print(" signal : %s (%d), si_code=%s" % (c["signame"], c["signal"], c["si_code"]))
|
|
print(" fault addr: %s" % c["fault"])
|
|
if c.get("cause"):
|
|
print(" cause : %s" % c["cause"])
|
|
|
|
hints = verdict_hint(c)
|
|
if hints:
|
|
print(" HINTS")
|
|
for h in hints:
|
|
print(" - %s" % h)
|
|
|
|
app_frames = [f for f in c["frames"] if not is_system_lib(f["path"])]
|
|
if app_frames:
|
|
print(" backtrace (your libraries):")
|
|
for f in app_frames[:args.frames]:
|
|
print(" #%s pc 0x%x %s%s"
|
|
% (f["n"], f["pc"], os.path.basename(f["path"]),
|
|
(" (%s)" % f["symbol"]) if f["symbol"] else ""))
|
|
else:
|
|
print(" backtrace: no frame inside an app library")
|
|
print(" -> the fault is in the runtime or a system library; an app-side")
|
|
print(" patch is unlikely to be the cause. Look at what called in.")
|
|
|
|
if c["frames"]:
|
|
print(" full backtrace:")
|
|
for f in c["frames"][:args.frames]:
|
|
tag = "system" if is_system_lib(f["path"]) else "APP"
|
|
print(" #%s [%s] pc 0x%x %s%s"
|
|
% (f["n"], tag, f["pc"], (f["path"] or "?"),
|
|
(" (%s)" % f["symbol"]) if f["symbol"] else ""))
|
|
|
|
# resolve against local libraries
|
|
for f in c["frames"]:
|
|
base = os.path.basename(f["path"])
|
|
for name, data in libs.items():
|
|
if name in base or base in name:
|
|
print(" local %s @ 0x%x:" % (name, f["pc"]))
|
|
if args.disasm and f is c["frames"][0]:
|
|
for line in maybe_disasm(data, f["pc"], args.disasm) or []:
|
|
print(line)
|
|
else:
|
|
print(" raw: %s" % data[f["pc"]:f["pc"] + 16].hex(" "))
|
|
print("")
|
|
|
|
print("=" * 72)
|
|
print("RECORD THIS: signal, fault addr, and the time-to-death above. They are the")
|
|
print("baseline an attempted fix must be measured against -- without them a later")
|
|
print("run cannot distinguish 'fixed' from 'the timing changed'.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|