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.
This commit is contained in:
inliver233
2026-09-19 22:47:35 +08:00
parent e50c826d95
commit ae739bb0c1
10 changed files with 1584 additions and 1 deletions
+24 -1
View File
@@ -29,6 +29,12 @@ directly.
than trusting what the manifest ships or what the device claims. than trusting what the manifest ships or what the device claims.
- Working through **packed/hardened targets**: identifying the packer, unpacking, and turning a - Working through **packed/hardened targets**: identifying the packer, unpacking, and turning a
memory dump back into a patched, installable APK. memory dump back into a patched, installable APK.
- Handling a **hardened library that terminates the process on purpose** — including the
deliberate-crash shape (`fault addr 0x4`) that looks exactly like an ordinary null-dereference
bug, and the "neutralise it, but never by making it *not return*" rule that decides whether the
fix works or freezes the whole app in a way that looks nothing like the cause.
- Knowing **which tools to reach for and where each one lies** — including the ones that only
exist as a GUI, so you ask for a human instead of silently substituting a weaker method.
- Keeping a **long task honest**: a live record, graded conclusions, calibrated timeouts, and - Keeping a **long task honest**: a live record, graded conclusions, calibrated timeouts, and
bounded waits, so progress is not lost and the same mistake is not made twice. bounded waits, so progress is not lost and the same mistake is not made twice.
- Avoiding the specific mistakes that produce an APK that builds perfectly and dies at - Avoiding the specific mistakes that produce an APK that builds perfectly and dies at
@@ -50,6 +56,12 @@ references/ loaded on demand, one topic each
native-and-so.md .so hosts, DT_NEEDED vs JNI_OnLoad, relocation limits, native-and-so.md .so hosts, DT_NEEDED vs JNI_OnLoad, relocation limits,
relocation-free bootstrapping, replacing Java methods natively, relocation-free bootstrapping, replacing Java methods natively,
and which ABI/library is *actually loaded and executing* and which ABI/library is *actually loaded and executing*
native-tamper-and-suicide.md how a hardened library kills its own process: the visible
mechanisms, how to tell which one actually fires, how to find the
site, forged section headers, function boundaries from
PT_GNU_EH_FRAME, scanner traps, and neutralising safely
toolchain.md what to install, how to invoke it non-interactively, which tools
are GUI-only, version-alignment traps, working offline
long-task-discipline.md live record, conclusion grading, drift control, timeout and long-task-discipline.md live record, conclusion grading, drift control, timeout and
wait calibration, deliverable-form drift, handover wait calibration, deliverable-form drift, handover
ad-removal.md ad taxonomy, wrapper mapping, callback trap, global gates, verification ad-removal.md ad taxonomy, wrapper mapping, callback trap, global gates, verification
@@ -100,6 +112,14 @@ scripts/ parameterized, path-agnostic
lib_map.py what is *actually mapped* into a live process: per-library path, lib_map.py what is *actually mapped* into a live process: per-library path,
base, architecture, and whether it came from the APK or was base, architecture, and whether it came from the APK or was
materialized at runtime materialized at runtime
elf_plt.py resolve a PLT stub to its imported symbol (x86_64 + aarch64) from
the relocation table; list a symbol's callers; byte-diff two
libraries and name the symbol each changed stub belongs to
apk_diff.py entry-level diff of two builds: changed / added / removed, by
content hash so same-size replacements are caught
native_crash.py locate a native death from a log or tombstone: signal, fault
address, registers, frames split app vs system, the faulting
instruction, and a flag when the fault looks *arranged*
blob_decode.py search, don't guess, the framing of a stored value blob_decode.py search, don't guess, the framing of a stored value
(base64/hex x rotation x deflate); re-encode the edited payload (base64/hex x rotation x deflate); re-encode the edited payload
snap.py bounded burst screenshots + control-tree capture with a stall snap.py bounded burst screenshots + control-tree capture with a stall
@@ -139,7 +159,7 @@ Nothing is mandatory; each script checks what it needs.
`references/pitfalls.md`. It is the most valuable file here — every entry is a failure `references/pitfalls.md`. It is the most valuable file here — every entry is a failure
that produced a broken artifact while looking completely healthy. that produced a broken artifact while looking completely healthy.
The three that hurt most: The four that hurt most:
1. Stripping the whole `META-INF/` during a repack deletes ServiceLoader registrations 1. Stripping the whole `META-INF/` during a repack deletes ServiceLoader registrations
and the app dies at startup with an error that names an unrelated library. and the app dies at startup with an error that names an unrelated library.
@@ -147,6 +167,9 @@ The three that hurt most:
dex rejected, while checksums and signatures verify perfectly. dex rejected, while checksums and signatures verify perfectly.
3. Rebuilding a dex with a whole-tree smali round-trip damages R8 output invisibly — 3. Rebuilding a dex with a whole-tree smali round-trip damages R8 output invisibly —
class tables compare clean, and it only blows up at runtime. class tables compare clean, and it only blows up at runtime.
4. Neutralising a native terminate path by making it **not return**. A spinning stub does not
suppress the check; it freezes the caller and every thread behind it. The app hangs with *no
crash record at all*, and the eventual death gets blamed on whatever killed the frozen process.
## Scope ## Scope
+16
View File
@@ -57,10 +57,20 @@ Answer these before touching a tool. Every one of them changes the whole plan.
routine, the rebuilt APK must hardcode the *original* certificate value at every read site, or routine, the rebuilt APK must hardcode the *original* certificate value at every read site, or
every signed request fails while the app still launches and looks healthy. This is the single every signed request fails while the app still launches and looks healthy. This is the single
most expensive silent failure in a repack, and 15 minutes of grep prevents it. most expensive silent failure in a repack, and 15 minutes of grep prevents it.
11. **Does the app die on its own after a while — with no Java stack trace, or with a native
crash that looks like a bug?**
→ `references/native-tamper-and-suicide.md`. A hardened library that decides the build is
tampered 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 defect, and the system then reports it as an
app "crash" or "abnormal" dialog. Two rules before you touch anything: **enumerate which
mechanism actually fires** (the signal and the tombstone split them apart), and **neutralise by
returning, never by making it not return** — a spinning stub freezes the process and produces a
symptom that looks nothing like the cause.
## The workflow, end to end ## The workflow, end to end
1. **Preflight, then Recon** — `scripts/preflight.py` before anything else if a device is involved (it takes seconds and prevents a whole class of false conclusions), then `references/recon.md`. Manifest, package name, version, ABI, dex count, packer, embedded SDKs, where the app's own code lives. Ten minutes here saves hours. **If it is packed, unpack before anything else** (`references/recon.md` §unpacking): you cannot patch code you cannot read, the encrypted payload lengths tell you which dumped dex is the original, and a memory dump must be de-duplicated by hash and structurally validated before any of it is trusted. 1. **Preflight, then Recon** — `scripts/preflight.py` before anything else if a device is involved (it takes seconds and prevents a whole class of false conclusions), then `references/recon.md`. Manifest, package name, version, ABI, dex count, packer, embedded SDKs, where the app's own code lives. Ten minutes here saves hours. **If it is packed, unpack before anything else** (`references/recon.md` §unpacking): you cannot patch code you cannot read, the encrypted payload lengths tell you which dumped dex is the original, and a memory dump must be de-duplicated by hash and structurally validated before any of it is trusted.
**If the app already dies on its own** — especially at a roughly constant time after launch, or with a native crash — locate the mechanism *before* planning any patch (`references/native-tamper-and-suicide.md`, `scripts/native_crash.py`). Record the observed time-to-death: it is the baseline every later attempt is measured against, and without it a surviving run cannot be told from a changed schedule.
2. **Extract strings and endpoints** — build a picture of the app's API surface and SDK inventory from the dex string tables. No decompiler needed for this, and it is fast. Scripts: `scripts/dex_strings.py`. 2. **Extract strings and endpoints** — build a picture of the app's API surface and SDK inventory from the dex string tables. No decompiler needed for this, and it is fast. Scripts: `scripts/dex_strings.py`.
3. **Trace to the owning class** — find the class that wraps the behavior (the app almost always wraps third-party SDKs in one helper). Reverse-lookup instructions: `references/dex-patching.md` §finding-the-call-site. 3. **Trace to the owning class** — find the class that wraps the behavior (the app almost always wraps third-party SDKs in one helper). Reverse-lookup instructions: `references/dex-patching.md` §finding-the-call-site.
4. **Decide the patch layer** — client SDK call / client rendering / client data consumption / server contract. See the table in `references/ad-removal.md`. 4. **Decide the patch layer** — client SDK call / client rendering / client data consumption / server contract. See the table in `references/ad-removal.md`.
@@ -76,6 +86,7 @@ Answer these before touching a tool. Every one of them changes the whole plan.
- **Verify structure after every dex edit.** `scripts/dex_classdiff.py` must report zero differences in class set and access flags for classes you did not intend to change. - **Verify structure after every dex edit.** `scripts/dex_classdiff.py` must report zero differences in class set and access flags for classes you did not intend to change.
- **Do not patch a method that is widely shared.** Before patching any helper, count its callers (`scripts/find_refs.py`). A `Long.valueOf` wrapper with 30 callers is not an ad-specific hook. - **Do not patch a method that is widely shared.** Before patching any helper, count its callers (`scripts/find_refs.py`). A `Long.valueOf` wrapper with 30 callers is not an ad-specific hook.
- **Do not make an API fail to suppress a UI element.** A 404/400 on an endpoint that other features depend on takes the whole screen down with it. Suppress at the data-consumption or render layer instead. - **Do not make an API fail to suppress a UI element.** A 404/400 on an endpoint that other features depend on takes the whole screen down with it. Suppress at the data-consumption or render layer instead.
- **Neutralise a native terminate path by returning, never by making it not return.** A stub, stub patch, or function entry replaced with a spin or a self-branch does not suppress the check — it freezes the caller, holding whatever lock it had, and unrelated threads wedge behind it. The symptom (a hang, an external kill, a restart loop) looks nothing like the cause, and there is no crash record to explain it. Return a benign value, and prefer success (0) over failure (-1). Never touch the ordinary-path symbols (`pthread_exit`, `exit`, `abort`, `snprintf`, `closedir`). `references/native-tamper-and-suicide.md`
- **Look before you conclude — and look while you wait.** Execute, capture, and **inspect**; do not drive and sleep blind. A screen that is actually looked at answers in one step what coordinate-guessing cannot answer in five: the layout moved, a different dialog is up, a countdown is frozen, the text on screen says exactly why. Where the thing you are waiting on is visible, a sample you can inspect beats a duration you hoped was right, and byte-identical samples mean nothing is going to change. `scripts/snap.py`; `references/environment.md` §look at the screen. - **Look before you conclude — and look while you wait.** Execute, capture, and **inspect**; do not drive and sleep blind. A screen that is actually looked at answers in one step what coordinate-guessing cannot answer in five: the layout moved, a different dialog is up, a countdown is frozen, the text on screen says exactly why. Where the thing you are waiting on is visible, a sample you can inspect beats a duration you hoped was right, and byte-identical samples mean nothing is going to change. `scripts/snap.py`; `references/environment.md` §look at the screen.
- **Put a timeout on every command, and calibrate it from measurement.** An unbounded call turns a stall into "the task stopped making progress", which is indistinguishable from slow work and costs hours silently. Time the operation once, record it, then derive the bound from it — that is what makes slow and hung distinguishable. A deadline that passes is a measurement, not a verdict. `references/long-task-discipline.md` §bound every wait. - **Put a timeout on every command, and calibrate it from measurement.** An unbounded call turns a stall into "the task stopped making progress", which is indistinguishable from slow work and costs hours silently. Time the operation once, record it, then derive the bound from it — that is what makes slow and hung distinguishable. A deadline that passes is a measurement, not a verdict. `references/long-task-discipline.md` §bound every wait.
- **Every claim needs evidence.** "Probably", "should be", "in theory" are not findings. Either you observed it, or you label it unverified. - **Every claim needs evidence.** "Probably", "should be", "in theory" are not findings. Either you observed it, or you label it unverified.
@@ -95,6 +106,8 @@ Load only what the current step needs.
| `references/framework-runtimes.md` | The UI is not native (Flutter / React Native / Unity / Cordova), or Java-layer hooks fire zero times while the UI clearly works | | `references/framework-runtimes.md` | The UI is not native (Flutter / React Native / Unity / Cordova), or Java-layer hooks fire zero times while the UI clearly works |
| `references/dart-aot.md` | The logic lives in a Dart AOT snapshot (`libapp.so`): pinning the Dart version, building a matching decompiler, the object pool and reference indexing, register/boolean conventions, locating and patching Dart code | | `references/dart-aot.md` | The logic lives in a Dart AOT snapshot (`libapp.so`): pinning the Dart version, building a matching decompiler, the object pool and reference indexing, register/boolean conventions, locating and patching Dart code |
| `references/native-and-so.md` | Patching in a `.so`, needing code to run before the app's own code, hand-built native payloads that crash inside the linker, or **deciding which library/ABI is actually loaded and executing** | | `references/native-and-so.md` | Patching in a `.so`, needing code to run before the app's own code, hand-built native payloads that crash inside the linker, or **deciding which library/ABI is actually loaded and executing** |
| `references/native-tamper-and-suicide.md` | The process dies on its own (no Java stack, or a native crash that looks like a bug); you are about to neutralise a `kill`/`exit`/`abort` path; or a hardened library's sections/function boundaries look wrong |
| `references/toolchain.md` | Choosing or invoking tools, something is not installed, a tool's output smells wrong, or you need to know which tools exist only as a GUI |
| `references/ad-removal.md` | Task involves ads, trackers, sponsored cards, splash/interstitial/reward | | `references/ad-removal.md` | Task involves ads, trackers, sponsored cards, splash/interstitial/reward |
| `references/membership-and-limits.md` | Task involves VIP, subscription, paid content, unlock, "fully cracked" | | `references/membership-and-limits.md` | Task involves VIP, subscription, paid content, unlock, "fully cracked" |
| `references/server-api.md` | The behavior is decided by a response, or you need to know if a patch can even matter | | `references/server-api.md` | The behavior is decided by a response, or you need to know if a patch can even matter |
@@ -138,6 +151,9 @@ All scripts are parameterized and path-agnostic; pass paths explicitly. Run `--h
| `scripts/tls_check.py` | Strict certificate check for one or more hosts (expired / wrong host / untrusted CA) | | `scripts/tls_check.py` | Strict certificate check for one or more hosts (expired / wrong host / untrusted CA) |
| `scripts/preflight.py` | Read-only environment check before every experiment block: device, root, ABI/translation, clock skew, leftover proxy/forwards, dead device server. Run this before blaming a patch. | | `scripts/preflight.py` | Read-only environment check before every experiment block: device, root, ABI/translation, clock skew, leftover proxy/forwards, dead device server. Run this before blaming a patch. |
| `scripts/lib_map.py` | What is **actually mapped** into a live process: per-library path, base, architecture (`ELF e_machine`), and classification (system / from-APK / runtime-materialized). Answers "which library and which ABI is really executing". | | `scripts/lib_map.py` | What is **actually mapped** into a live process: per-library path, base, architecture (`ELF e_machine`), and classification (system / from-APK / runtime-materialized). Answers "which library and which ABI is really executing". |
| `scripts/elf_plt.py` | Resolve a PLT stub to its imported symbol on x86_64 and aarch64 (from the **relocation table**, not from position or a comment), list a symbol's callers, and **byte-diff two libraries naming the symbol each changed stub belongs to**. Run this before patching any stub, and to audit a patch set you inherited. |
| `scripts/apk_diff.py` | Entry-level diff of two APKs: what changed, what was **added** (injection candidates), what was removed — by content hash, so same-size replacements are caught. Use it to audit a third-party build and to prove your own build was surgical. |
| `scripts/native_crash.py` | Locate a native death from a logcat capture or tombstone: signal, fault address, register state, backtrace split into your libraries vs system, the faulting instruction — plus an explicit flag when the fault looks **arranged** rather than accidental. |
| `scripts/blob_decode.py` | Decode an opaque stored value by searching the parameter space (base64/base64url/hex × rotation × deflate/zlib/gzip) instead of guessing, then re-encode an edited payload with the same parameters. | | `scripts/blob_decode.py` | Decode an opaque stored value by searching the parameter space (base64/base64url/hex × rotation × deflate/zlib/gzip) instead of guessing, then re-encode an edited payload with the same parameters. |
| `scripts/snap.py` | Bounded burst screenshot + control-tree capture, with a stall detector and an explicit verdict on whether the accessibility tree is usable at all. Use it so you *look* at the screen instead of driving blind. | | `scripts/snap.py` | Bounded burst screenshot + control-tree capture, with a stall detector and an explicit verdict on whether the accessibility tree is usable at all. Use it so you *look* at the screen instead of driving blind. |
| `scripts/sig_probe.py` | Find the exact `signatures[0].toCharsString()` value: offline candidate enumeration from an APK (`--apk`), or the authoritative value read from a live package (`--live`). Feed the result into the hardcoded constant described in `references/signature-derived-keys.md`. | | `scripts/sig_probe.py` | Find the exact `signatures[0].toCharsString()` value: offline candidate enumeration from an APK (`--apk`), or the authoritative value read from a live package (`--live`). Feed the result into the hardcoded constant described in `references/signature-derived-keys.md`. |
+4
View File
@@ -3,6 +3,10 @@
Load this when the Java/dex layer is blocked or unsuitable, when you need code to run **before** the app's Load this when the Java/dex layer is blocked or unsuitable, when you need code to run **before** the app's
own code, or when a native library is the only editable place left. own code, or when a native library is the only editable place left.
If instead the problem is that the app **dies on its own** — hangs, restarts, or crashes natively at a
roughly constant time after launch — that is a terminate mechanism, not a hooking problem, and the
methods differ: read `native-tamper-and-suicide.md` before this file.
The Java layer is usually the right choice. Go native when you need one of these: The Java layer is usually the right choice. Go native when you need one of these:
- execution **earlier than any app code** (before `Application.onCreate`, before static initialisers), - execution **earlier than any app code** (before `Application.onCreate`, before static initialisers),
+264
View File
@@ -0,0 +1,264 @@
# Native Tamper Response — When the Library Kills Its Own Process
Load this when the process dies **without a Java stack trace**, when a build that passed every
static check crashes seconds after launch, or when you are about to neutralise a native check.
This file exists because of one specific, expensive mistake. Read §The rule before you edit a
single byte of a shell's native library.
## The rule
> **Never neutralise a terminate path by making it not return. Make it return.**
A terminate routine is reached from ordinary code paths. If you replace it with something that
**never returns** — an infinite loop, a self-branch, a stub that spins — the caller never resumes,
locks are never released, and every unrelated thread that touches those locks wedges. You have not
suppressed the check; you have frozen the process.
The distinction decides the outcome:
| Neutralisation | Effect on the caller | Result |
|---|---|---|
| `ret` / return a benign value | continues normally | check suppressed, process healthy |
| spin / self-branch / `while(1)` | never resumes | process freezes, watchdog or user kills it, symptom looks unrelated |
**Two failure shapes this produces, both of which have been misread as "the patch did not work":**
- The app hangs with no crash record at all, then disappears. Logs show nothing, because nothing
crashed — it stopped.
- The watchdog or a system supervisor kills the frozen process, producing
`Force stopping … from uid 0` or an app-restart loop. The uid-0 killer is a strong tell: an app
cannot spawn a root-owned killer, so the executioner is **outside** the app, which means the app
froze rather than failed.
Both are self-inflicted and both are avoided by returning instead of spinning.
### The corollary: do not touch normal-path symbols
A hardening library imports a lot of libc. Some of those symbols are terminate paths; most are not.
**Symbols that must stay untouched** — they are on ordinary code paths and freezing them breaks
everything:
`pthread_exit` · `exit` · `abort` · `android_set_abort_message` · `snprintf` · `closedir` ·
`__cxa_atexit` · `__stack_chk_fail`
A concrete case: a set of hand-made patches rewired five PLT stubs to self-branches on one
architecture and a different five on another. On the first architecture two of the five were
`snprintf` and `closedir` — a string formatter and a directory-close — so the app froze on the
first log line. On the second the five included `pthread_exit`, so every thread that finished
wedged. The patches were meant to suppress a suicide check and instead destroyed the process.
**Before touching a stub, resolve what symbol it belongs to.** Do not infer it from the stub's
position or from a comment. See §Resolving a stub to its symbol.
## How a hardened library terminates the process
Enumerate these before you patch anything. Each has a different signature and a different remedy.
| Mechanism | Log signature | Has tombstone? | How to neutralise |
|---|---|---|---|
| `kill(getpid(), SIGKILL)` | `Process N exited due to signal 9 (Killed)`, **no** exit code, **no** tombstone | no | patch the call site or make the `kill` stub return 0 |
| `exit()` / `_exit()` | process ends with an exit code, no signal | no | find the caller; usually one branch of a check |
| `abort()` | `signal 6 (SIGABRT)` + tombstone | yes | usually a genuine assertion — find what tripped it |
| **Deliberate crash** (see §Deliberate-crash stubs) | `signal 11 (SIGSEGV)`, tiny `fault addr` such as `0x4`, `Cause: null pointer dereference` | yes | nop the faulting store |
| `tgkill` / `raise` | same as kill/abort | varies | often not imported at all — check the import table before assuming |
**Distinguish these before patching.** "The app crashed" is not a diagnosis. The signal number and
the presence or absence of a tombstone split the space in half; getting this wrong sends you to the
wrong layer for hours.
### Deliberate-crash stubs
The most deceptive mechanism: the library does not call anything. It **arranges a fault** so the
death looks like an ordinary bug.
```asm
; arm64 shape observed in the wild
mov x0, #4 ; load a small constant…
mov w1, #1
str w1, [x0] ; …and use it as a pointer. fault addr = 0x4
```
Runtime shape: `SIGSEGV`, `SEGV_MAPERR`, `fault addr 0x4` (or `0x0`/`0x8`), `Cause: null pointer
dereference`, and — critically — **the register holding that small constant value**.
How to recognise it rather than treating it as a real bug:
1. The faulting address is a **small integer**, not a plausible pointer.
2. Disassembling backwards from the faulting `pc` shows the constant loaded **a few instructions
earlier, in the same basic block**, with nothing in between that could produce a real pointer.
3. The block sits immediately before a function epilogue (canary check + `ret`), i.e. the normal
exit is right there and this store is bolted on in front of it.
4. There is often a **delay loop just above it** — `sleep`/`usleep` repeated N times — because this
is a *watchdog*: wait a while, then die if the condition still holds. That delay is why the crash
is always "a little while after launch" rather than immediate.
**Neutralise it by nop-ing the faulting store**, leaving the surrounding arithmetic alone. The
thread then falls through into the epilogue and returns normally. Do not remove the whole block:
the two `mov`s are harmless and keeping them makes the diff minimal and reviewable.
**This is why patching `kill` alone is not enough.** A deliberate crash uses no imported symbol at
all, so every PLT-level fix — including a correctly implemented one — leaves it untouched.
### Find every instance, not the first one
Search the binary for the **byte pattern**, not with a linear disassembler. The exact encoding is
architecture-specific and must be derived from the crash you already have, then searched as bytes:
- Take the faulting `pc`, read the instruction bytes backwards until you have the full constant-then-
store sequence.
- Search the whole file for that byte sequence.
- If the search returns more than one hit, patch each; if it returns exactly one, you have the
complete set — record that fact, it is a real result.
A linear disassembler is the wrong tool here for a reason covered in §Scanner traps.
## Resolving a stub to its symbol
When a stub's target is a PLT entry, the symbol comes from the relocation table, **not** from the
stub's bytes or its neighbours. Two architectures encode this differently:
- **x86_64** — the stub is `jmp qword ptr [rip+disp32]` (6 bytes). The target address is
`stub_addr + 6 + disp32`; look that address up in the PLT relocation map.
- **aarch64** — the stub is **four instructions / 16 bytes** (`adrp` → `ldr` → `add` → `br`). The
GOT address is `page(adrp) + add_imm`. Do not assume a 4-byte stub: a 16-byte stub leaves room
for a **two-instruction replacement** (`mov x0, #0` + `ret`), which is exactly what you need, and
assuming 4 bytes pushes you into borrowing the neighbouring slot — which belongs to a different
symbol and breaks it.
`scripts/elf_plt.py` does both architectures, including the relocation lookup.
**Never splice in a neighbouring stub's bytes.** Stubs are laid out back to back; the next one
along is a different function, and corrupting it produces a second, unrelated failure that you will
then spend hours attributing.
## Patching a terminate path correctly
Work in this order.
1. **Confirm the mechanism first.** Get the tombstone or the signal number. Do not patch on a
guess about *how* it dies.
2. **Decide between call-site and stub.** Call sites are precise but you must find all of them.
A stub covers every caller at once, including ones your scan missed — but it changes behaviour
for every symbol user, so it is only safe when that symbol means "terminate" and nothing else.
3. **For a stub, return success, not failure.** Callers commonly inspect the return value. Returning
**0** (success) lets the caller take its "already handled" branch; returning **-1** pushes it
into an error path that may try a different termination method. Make the stub *succeed*.
4. **Preserve every byte you did not intend to change.** Record the original bytes so the patch is
reversible and auditable.
5. **Re-verify the file after patching.** Length unchanged, only the intended offsets differ, and
the normal-path symbols are byte-identical to the original. `scripts/elf_plt.py --diff` prints
exactly that.
### What "faithful suppression" looks like
A library that decides "tampered" and then cannot terminate has an inconsistent state — it believes
it has killed the process and continues. That is **acceptable and usually harmless**, because the
terminate path is the end of the check; nothing downstream re-reads "did I die". But verify it
rather than assuming: after a successful suppression the app must reach its **normal UI and stay
there**, not loop through half-initialised states.
## ELF hardening you will meet in these targets
Hardening libraries are not normal ELF files. Assume the following until measured otherwise.
### Forged section headers
A shell may ship a library whose section header table is deliberately wrong — `.text` sized to a
handful of bytes, `.dynsym` truncated, sections overlapping. Tools that walk sections therefore
produce **confidently wrong** answers (a symbol list with one entry, an import table that is empty).
**Work from the program headers instead.** `PT_LOAD` gives you the real mapped segments and their
permissions; `PT_DYNAMIC` gives you the pointers you need, walked by hand:
```
DT_STRTAB(5) DT_SYMTAB(6) DT_STRSZ(10) DT_SYMENT(11)
DT_JMPREL(23) DT_PLTRELSZ(2) DT_PLTREL(20) DT_PLTGOT(3)
```
Then translate any virtual address to a file offset through the `PT_LOAD` that contains it. A
library with forged sections that yields a full import list this way is a good sign; one that
still yields almost nothing means the hardening is deeper and you should say so rather than
reporting the truncated result as the truth.
### Function boundaries come from `PT_GNU_EH_FRAME`, not from prologue guessing
You will need "where does this function start". Two bad answers and one good one:
- **Reverse-decoding backwards from a call site does not work on aarch64.** Almost any 4-byte
window decodes as *some* instruction, so a naive scanner reports hundreds of "function starts"
that are all fiction.
- **Guessing at prologues** (`sub sp, sp, #imm` + `stp`) produces a plausible set with no guarantee
of completeness.
- **`PT_GNU_EH_FRAME` is authoritative.** If the segment survives (it usually does, because
unwinding is needed at runtime), parsing it yields the real function entry table — every entry,
exactly. It is several lines of code and it removes an entire class of wrong conclusions.
**Check for it first.** If it is present, use it; if it is absent, say your boundary list is
heuristic.
### A function's entry is not where its logic is
Callers reach a routine through several routes: a direct branch, a cached table slot, or a function
pointer in writable data. Rewriting the entry point only covers the first. If a call resolves
through a cached pointer, your entry-point change is invisible to it — so before patching an entry,
scan for the other routes (`scripts/elf_plt.py`, or a byte-pattern scan for the address).
## Scanner traps
**A linear disassembler can stop silently.** Given a buffer whose start is not code, a decoder may
return a handful of instructions and then nothing, with no error. Concluding "there are no such
call sites in this library" from that output is a false negative with real consequences.
Guards, in order of preference:
1. **Byte-pattern search** for a sequence you already know (from a crash or a known call site).
Fast, exhaustive, no decoder involved.
2. **Fixed-bit-pattern scans** for instruction classes whose encoding is regular — the branch
instructions are the useful ones here (`BL`/`B` on aarch64, `call rel32` on x86_64). This finds
every call site without depending on linear decoding.
3. **Resynchronising scan** — decode, and on failure advance by one instruction unit (4 bytes on
fixed-width aarch64, 1 byte on x86_64) and retry. Slower, but it does not stop early.
Whichever you use, **state which one you used and what it can miss**. "No hits" from a scan whose
coverage you cannot describe is not evidence.
## Deciding whether a dynamic-resolution path matters
Hardening libraries frequently build a table of function pointers with `dlopen` + `dlsym` — often
40+ symbols including terminate calls. This raises an obvious worry: *a call through that table
bypasses the PLT stub, so my stub patch does nothing.*
**Measure it instead of assuming either way.** For each symbol of interest, find whether its table
slot is ever **read**:
- Locate the slot's address (the store from the `dlsym` return, or the table base + index).
- Search the code for instructions that load from that address (`adrp`+`add`+`ldr` on aarch64).
- **Zero readers means the entry is written and never used.** The table is a redundant fast path,
not a bypass, and PLT-level patching is complete.
Real outcome from a hardened target: the `kill` slot had **zero** readers while the `exit` slot had
exactly one, inside a routine that was already suppressed — so no dynamic-resolution bypass existed
at all, and the expensive "patch `dlsym` itself" plan would have broken 40 unrelated symbols for
nothing.
**Do not blanket-patch a symbol resolver.** It feeds `open`, `read`, `mmap`, `dlopen` and more.
Changing its behaviour to reach one terminate symbol is a large blast radius for a problem you may
not have.
## Verification
A native suppression earns no credit until:
1. The process **survives the window in which it used to die** — and that window must be measured,
not guessed. If the observed death was ~40 s after launch, a 30-second test proves nothing.
2. It then **keeps surviving** through real interaction, not just idling.
3. `crash`/`tombstone` buffers are clean, **and** the process id never changed (a restart with a new
pid is a death even when nothing logged a crash).
4. The normal-path symbols are byte-identical to the original build.
5. A **control build** — same pipeline, no native patch — still dies the same way. Without the
control you cannot tell suppression from an unrelated change in timing.
**Record the observed time-to-death before patching.** It is the only thing that later tells you
whether your patch worked or merely moved the window.
+7
View File
@@ -134,6 +134,13 @@ The cheapest high-value experiment, and one that is routinely skipped: run the *
build** and the **repackaged build** through the *same* probe and diff what differs. The difference is build** and the **repackaged build** through the *same* probe and diff what differs. The difference is
your detection signal. Doing this early is far cheaper than enumerating candidate checks one by one. your detection signal. Doing this early is far cheaper than enumerating candidate checks one by one.
**When the symptom is a death rather than a dialog** — the process disappears, hangs, or dies at a
roughly constant time after launch — the check is not drawing anything, it is terminating. The
question then becomes *which mechanism*, and the answer decides the whole approach:
`native-tamper-and-suicide.md` covers how to tell an imported terminate call from an **arranged
fault** (which calls nothing and therefore defeats every PLT-level fix), how to find the site, and
why making a routine "not return" freezes the process instead of suppressing the check.
## Reporting ## Reporting
State the boundary you measured, the evidence for each entry, and what remains untested. "The shell State the boundary you measured, the evidence for each entry, and what remains untested. "The shell
+233
View File
@@ -794,3 +794,236 @@ Two close variants of the same error:
*change*, never *what* changed — and it never proves *absence*. Look at the images: a status-bar *change*, never *what* changed — and it never proves *absence*. Look at the images: a status-bar
clock tick or a line of text reflowing changes the hash while a full-screen overlay would not clock tick or a line of text reflowing changes the hash while a full-screen overlay would not
have been missed if the frames had actually been inspected. have been missed if the frames had actually been inspected.
---
## P31. Neutralising a terminate path by making it "not return" freezes the whole process
**Symptom**
The app hangs with **no crash record at all**, then disappears. Or an external process kills it —
`Force stopping … from uid 0`, an app-restart loop — and nothing in the log says "crash".
**Root cause**
A terminate routine (a shell's `kill`/`exit`/`abort` stub, or a self-terminating function entry)
was replaced with something that **never returns**: a self-branch, an infinite loop, a spinning
stub. The caller was written expecting the process to be gone. Instead control never comes back,
whatever lock it held is never released, and unrelated threads wedge behind it.
**Why it is hard to see**
There is no exception, no signal, and no tombstone, because nothing failed — it stopped. Absence of
a crash record reads as "it is still fine", and the eventual death is attributed to whatever the
killer happened to be. The tell is a **uid-0 killer**: an app cannot spawn a root-owned executioner,
so the executioner is outside the app, which means the app froze rather than died.
**Do instead**
**Return. Always return.** A `ret`, or a stub that loads 0 and returns.
- Callers commonly inspect the return value, so make the suppressed call **succeed (0)** rather
than fail (-1) — failure can push the caller into an error branch that tries a *different* way
to terminate.
- Do not touch the ordinary-path symbols: `pthread_exit`, `exit`, `abort`, `snprintf`, `closedir`,
`android_set_abort_message`. Freezing `pthread_exit` wedges every thread that finishes; freezing
`snprintf` wedges the first log line. A five-stub patch intended for terminate symbols has hit
exactly those symbols before.
- If the fix is a delay-loop watchdog, the *faulting store*, not the loop, is the thing to remove.
Full treatment, including how to tell which mechanism is actually firing:
`native-tamper-and-suicide.md`.
---
## P32. Rewiring a stub without resolving which symbol it belongs to
**Symptom**
A patch meant to suppress one check breaks something entirely unrelated — often before that check
would even have run.
**Root cause**
PLT stubs are laid out back to back. A patch recipe that names offsets, or that assumes a fixed
stub width, is one arithmetic slip away from rewriting its neighbour. Real outcome of one such set:
five stubs intended to be terminate symbols resolved to **five different symbols on each
architecture**, including a string formatter and a directory call — so the app froze on its first
log line instead of suppressing anything.
**Why it is hard to see**
The offsets and the symbol names usually come from different sources (a note, a previous round, a
generated table) and nothing cross-checks them. The damage then presents as an unrelated stability
problem, and the real cause is two rounds back.
**Do instead**
Resolve every stub to its symbol **before** writing the patch, from the **relocation table** —
never from a comment, a position, or an assumed width.
`scripts/elf_plt.py` prints `stub address -> symbol` for both x86_64 and aarch64, and
`--diff --name-regions` names the symbol each changed stub belongs to, which is the right way to
audit a patch set you inherited.
**The aarch64 stub is 16 bytes (four instructions), not four.** Assuming the short form pushes you
into borrowing the next slot, which belongs to a different symbol — and a 16-byte stub is exactly
what makes a clean two-instruction replacement (`mov x0, #0; ret`) possible without touching
anything else.
---
## P33. "The scan found no call sites" — from a decoder that stopped early
**Symptom**
A scan for call sites, stubs, or a byte pattern reports zero matches, and that zero becomes a
finding: "this library never calls `kill`", "there is no second site", "the payload is absent".
**Root cause**
A linear disassembler handed a buffer that does not start on an instruction boundary can return a
few instructions and then **stop, silently**. No error, no warning — and its partial output is
indistinguishable from a complete negative. On a fixed-width architecture, decoding from the wrong
offset also produces plausible garbage rather than failing.
**Why it is hard to see**
Zero is a comfortable answer. It usually agrees with what you were hoping ("the check is not
there"), so nothing prompts a second look. (See also P25 — same shape, different mechanism.)
**Do instead**
Never conclude absence from a scan whose coverage you cannot describe.
- Prefer **byte-pattern search** for a sequence you already know, taken from a crash or a known
call site.
- For instruction classes with regular encoding, use a **bit-pattern scan** — branch instructions
are the useful ones, and this finds every occurrence without depending on linear decoding.
- Otherwise use a **resynchronising** scan (advance one instruction unit — 4 bytes on aarch64,
1 on x86_64 — and retry).
- State which method you used and what it can miss.
---
## P34. A deliberate crash read as an ordinary bug
**Symptom**
`SIGSEGV`, a real tombstone, `fault addr 0x4` (or `0x0`/`0x8`), `Cause: null pointer dereference`.
It looks like a plain null-dereference defect, so you go looking for the defect.
**Root cause**
There is no defect. A hardening layer that wants the process dead **without calling anything it
imports** arranges a fault — load a small constant, use it as a pointer:
```
mov x0, #4
mov w1, #1
str w1, [x0] ; fault addr = 0x4
```
**Why it is hard to see**
The tombstone is genuine, the signal is genuine, and `null pointer dereference` is the runtime's
honest description. Nothing distinguishes it from a real bug except the *shape* of the fault — and
"it crashes on a small address" reads like sloppy target code, which is exactly what it is
imitating.
**Do instead**
Read the fault address and the registers **together**:
- Is the fault address a small integer rather than a plausible pointer?
- Does some register hold exactly that value?
- Does the instruction at the faulting `pc` load that constant a few instructions earlier, in the
same basic block?
- Is there a delay loop immediately above it (`sleep` repeated N times)? That is a watchdog, and it
explains why the death always comes "a little while after launch" rather than at once.
- Does the block sit right before a normal epilogue (canary check + `ret`)? The intended exit is
right there.
If all of that holds, **nop the faulting store** and leave the surrounding arithmetic alone — the
thread then falls through into the epilogue and returns normally.
`scripts/native_crash.py` extracts the frames, registers and faulting instruction, and flags this
shape explicitly.
---
## P35. Blocking one termination mechanism and calling the check suppressed
**Symptom**
The `kill` stub now returns success, the import table is clean, the patch is verifiably present —
and the app still dies, with the same signal at the same time.
**Root cause**
A hardening library terminates through **several independent mechanisms that share no choke
point**: an imported `kill`, an imported `exit`, `abort()`, and a **deliberate crash that calls
nothing at all** (P34). A PLT-level fix covers the first two and has literally no effect on the
last one, because no imported symbol is involved.
**Why it is hard to see**
"It imports `kill`, so `kill` is how it dies" is a satisfying and often correct-sounding story. A
*correct* patch to a *real* mechanism produces no visible change when a second mechanism fires
first — so the conclusion drawn is "the patch did not work / the approach is wrong", and a working
route gets discarded.
**Do instead**
Enumerate the mechanisms before patching, then **measure which one actually fires**. The signal
splits the space, and the tombstone confirms it:
| Observation | Mechanism |
|---|---|
| `SIGKILL`, no exit code, **no tombstone** | an imported terminate call |
| `SIGSEGV`, small fault address | arranged crash — no imported symbol involved |
| `SIGABRT` + tombstone | usually a genuine assertion |
| process survives a repack but the library is absent from `maps` | the patch never ran |
**Then verify against the observed time-to-death.** If it died ~40 s after launch before, a
30-second test proves nothing, and a run that survives 45 s has not yet passed.
---
## P36. Parsing a hardened ELF through its section headers
**Symptom**
A tool reports that a library imports one symbol, or has a 200-byte `.text`, or contains no
functions. You build a plan on that.
**Root cause**
Hardened libraries ship **forged section headers**: `.text` sized to a token value, `.dynsym`
truncated, sections overlapping. Anything that walks the section table returns confidently wrong
output — and because the output is well-formed, the wrongness is invisible.
**Why it is hard to see**
A truncated result and a genuinely minimal library look identical. The tool has no way to report
that it was lied to, so the failure is attributed to the target rather than the method.
**Do instead**
Work from the **program headers**, which the loader itself uses and which therefore cannot lie
about what gets mapped:
- `PT_LOAD` → the real segments, their file offsets and their permissions.
- `PT_DYNAMIC` → `DT_STRTAB` / `DT_SYMTAB` / `DT_STRSZ` / `DT_SYMENT` / `DT_JMPREL` /
`DT_PLTRELSZ`, walked by hand.
- Translate any virtual address to a file offset through the containing `PT_LOAD`.
A library that yields a full import list this way is fine. One that still yields almost nothing
means the hardening is deeper than the section table — **say that**, rather than reporting the
truncated answer as a finding.
For **function boundaries**, use `PT_GNU_EH_FRAME` when it survived: it is authoritative and
complete, and it costs a few lines to parse. Do not reverse-decode backwards looking for a prologue
— on aarch64 almost any 4-byte window decodes as *something*, so a naive scanner reports hundreds
of fictional entry points — and prologue pattern-matching yields a plausible set with no
completeness guarantee.
+136
View File
@@ -0,0 +1,136 @@
# Toolchain — What to Use, How to Invoke It, and Where It Lies
Load this when you are choosing tools, when a tool produces an answer that smells wrong, or when
something is not installed. It is a map, not a tutorial: each entry says what the tool is *for*, how
to drive it non-interactively, and the failure mode that wastes time.
**Prefer tools you can drive from a command line.** An agent cannot click. Tools that exist only as
a GUI are listed where they matter, marked as requiring a human, so you can ask for that instead of
stalling or silently substituting a weaker method.
## Tier 0 — present on almost any machine, no install
Use these before reaching for anything heavier. They answer structure questions in seconds and
cannot be broken by a missing dependency.
| Tool | Use it for |
|---|---|
| `file`, `readelf -h/-l/-d/-r`, `objdump -d`, `nm -D`, `strings` | ELF structure, program headers, dynamic section, relocations, dynamic symbols |
| `unzip -l`, `zipinfo` | what is actually inside an APK, entry sizes and compression |
| `sha256sum` / `Get-FileHash` | identity. **Always hash before and after; never trust "it should be the same file"** |
| `xxd` / `hexdump` | byte-level ground truth when a tool disagrees with another tool |
| `python3` | all the scripts here; the standard library alone covers most parsing |
`readelf`/`objdump` walk **section** headers. If a target's section table is forged (see
`native-tamper-and-suicide.md` §Forged section headers) they will print confidently wrong output —
cross-check with the program headers.
## Tier 1 — dex and Java
| Tool | Invoke | Why this one |
|---|---|---|
| `baksmali` / `smali` (jars) | `java -cp <jars> org.jf.baksmali.Main d <dex> -o <dir>` | round-tripping, reading a method precisely |
| `dexlib2` | small Java program | **the preferred patcher** — method-level rewrite, leaves everything else untouched (`dex-patching.md`) |
| `apktool` | `java -jar apktool.jar d/b` | whole-app decode including resources |
| `jadx` | `jadx --no-res -d <out> <apk>` | readable Java for orientation; **not** a source of truth (see below) |
| `aapt2` | `aapt2 dump badging <apk>` | manifest facts, package name, versions |
| `zipalign`, `apksigner` | from build-tools | alignment and signing |
**Classpath gotcha.** `baksmali`/`smali` need their dependency jars on the classpath together
(`smali`, `antlr-runtime`, `stringtemplate`, `baksmali`, `dexlib2`, `util`, `jcommander`, `guava`).
Missing one produces `ClassNotFoundException` that reads like a broken target. `scripts/smtool.py`
carries the classpath so you pass it once.
**Entry-point gotcha.** The main class has moved between versions — `org.jf.*` in older builds,
`com.android.tools.smali.*` in newer ones. If `ClassNotFoundException` names the **main class**, it
is a version mismatch, not a missing jar.
**jadx is for reading, not for concluding.** Decompiled Java reorders and rewrites control flow;
line numbers and even which branch is which can differ from the bytecode. When a decision depends on
it, verify in smali or at runtime. `jadx` writing `(unknown)`/empty method bodies means it failed,
not that the method is empty.
**`smali` assembling can abort silently** — the tree produces no dex while the process still exits
0. Never treat exit code as proof of a build. Assert the artifact exists, is fresh, and is non-empty
(`patch-audit.md`).
## Tier 2 — native
| Tool | Invoke | Notes |
|---|---|---|
| `radare2` / `rizin` | `r2 -q -c '<cmds>' file` | scriptable disassembly/analysis; the practical choice when no GUI is available |
| `Ghidra` (headless) | `analyzeHeadless <proj> <name> -import <file> -postScript <s>` | decompiler without a GUI; slower to script but far better output than a raw disassembler |
| `capstone` (python) | library | decoding in your own scripts — see the silent-stop trap below |
| `keystone` (python) | library | assembling a short patch when you are editing bytes by hand |
| `pyelftools` (python) | library | **section-based — unreliable on hardened targets.** Prefer hand-walking `PT_LOAD`/`PT_DYNAMIC`, as `scripts/elf_plt.py` does |
| IDA Pro | GUI | human-driven. Ask for it when decompiler output is genuinely needed; do not pretend a raw disassembly is equivalent |
| `x64dbg` | GUI, Windows | human-driven live debugging of a native Windows target |
| `gdb` / `lldb` | CLI | live debugging where a device or emulator allows it |
**capstone can stop silently.** Decoding a buffer that does not begin at an instruction boundary may
return a few instructions and then nothing, with no error. A scan built on that reports "no matches"
for a library full of matches. Use byte-pattern search or a resynchronising scan for anything where
completeness matters (`native-tamper-and-suicide.md` §Scanner traps).
**Disassembler output is a hypothesis.** Fixed-width architectures (aarch64) decode almost any
4-byte window into *some* instruction, so a wrong start offset yields plausible-looking garbage.
Bound your window with a known entry point or a known call site.
## Tier 3 — runtime and network
| Tool | Invoke | Notes |
|---|---|---|
| `frida` (host) + `frida-server` (device) | `frida -U -f <pkg> -l script.js` | **host and device versions must match** — skew produces errors that look like a broken target (`pitfalls.md` P15) |
| `objection` | `objection -g <pkg> explore` | quick Java-layer poking on top of Frida |
| `mitmproxy` | `mitmproxy --mode regular` | request/response inspection; a device proxy is usually faster to set up than a transparent one |
| `adb` | everything | the primary device interface |
Version alignment for Frida is a **hard gate**, not a nicety. Check both sides before writing a
script, and check the device architecture — the server binary is per-ABI.
## Tier 4 — cross-platform runtimes
| Runtime | Identifier | Tool |
|---|---|---|
| Flutter / Dart | `libflutter.so` + `libapp.so` | `blutter` (needs the **matching Dart version**) — `dart-aot.md` |
| Unity / IL2CPP | `libil2cpp.so` + `global-metadata.dat` | Il2CppDumper + a native decompiler |
| React Native | `libhermes.so` / `index.android.bundle` | Hermes bytecode tooling, or plain JS if the bundle is unminified |
| Cordova / hybrid | WebView + `assets/www` | read the JS directly; Java layer is usually thin |
**A version mismatch here wastes the most time of any tool in this file.** A Dart decompiler built
for a different engine version produces output that is subtly wrong rather than obviously broken.
Pin the version first (`dart-aot.md` §1) and do not "try it and see".
## The general trap: a tool's failure is not a finding about the target
A missing jar, a version skew, a forged section header, and a genuinely absent symbol all produce
**empty or partial output**. The output looks the same; the conclusions are completely different.
Before reporting "there is nothing there", confirm the tool was actually capable of finding it:
- Did it read the file at all? (entry count, segment count, non-zero output somewhere)
- Does a **known-present** item show up? (search for something you already know exists — a symbol
you saw in the dynamic table, a string you read in the hex dump)
- Would a different method see it? (byte pattern vs decoded scan)
**This is `pitfalls.md` P25 and P26 in tool form.** A self-built analyser that fails quietly reads
exactly like a clean target, and it removes routes from consideration for free.
## Working without a network
Assume nothing can be downloaded. What still works, in order of usefulness:
1. Tier 0 in full, plus the Python standard library — enough for structure, strings, hashing, and
most ELF/dex parsing.
2. Whatever jars and binaries are already present. **Locate them once and reuse the paths** rather
than re-searching on every command.
3. A local package cache or an offline mirror, if the environment has one.
If a tool genuinely cannot be obtained, say which step is blocked and **what a substitute would
give up** — do not silently substitute a weaker method and report its result as equivalent.
## Recording the toolchain actually used
When you report, name the tools and versions that produced each finding. Two agents with different
tool versions will reach different conclusions about the same binary, and without the version that
difference is unresolvable later.
+164
View File
@@ -0,0 +1,164 @@
#!/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()
+457
View File
@@ -0,0 +1,457 @@
#!/usr/bin/env python3
"""PLT stub -> imported symbol mapping, and byte-level .so diffing.
Built for hardened/rebuilt native libraries where the usual paths lie:
* section headers are often forged, so tools that walk sections (readelf -x,
objdump -d on named sections, pyelftools' section API) return confidently
wrong or empty results;
* a linear disassembler can stop silently on a buffer that does not start at
an instruction boundary, so "no matches" from it is not evidence.
Therefore this tool walks PT_LOAD / PT_DYNAMIC by hand and decodes the two
stub shapes with its own decoders (no capstone dependency, no silent stops).
Why you need the stub -> symbol map: when a hardening library terminates the
process, it does so through a PLT stub. To suppress it safely you must know
*which* symbol a given stub resolves to -- inferring from position or from a
comment is how you end up freezing `snprintf` or `pthread_exit` and reporting
it as "the patch did not work".
Usage
-----
# full stub table
python elf_plt.py libfoo.so
# what do these stubs call?
python elf_plt.py libfoo.so --query 0x9920 0x9950
# find the stub for one symbol (and every caller of it)
python elf_plt.py libfoo.so --symbol kill --callers
# what changed between the original and a rebuilt library?
python elf_plt.py original.so --diff rebuilt.so
# same, but also name the symbol each changed stub belongs to
python elf_plt.py original.so --diff rebuilt.so --name-regions
"""
import argparse
import hashlib
import os
import struct
import sys
PT_LOAD = 1
PT_DYNAMIC = 2
DT_PLTRELSZ = 2
DT_PLTGOT = 3
DT_STRTAB = 5
DT_SYMTAB = 6
DT_RELA = 7
DT_RELASZ = 8
DT_STRSZ = 10
DT_SYMENT = 11
DT_PLTREL = 20
DT_JMPREL = 23
EM_X86_64 = 62
EM_AARCH64 = 183
ARCH_NAME = {EM_X86_64: "x86_64", EM_AARCH64: "aarch64"}
# --------------------------------------------------------------------------
# ELF parsing (program headers only -- never trust the section table here)
# --------------------------------------------------------------------------
class Elf:
def __init__(self, data, path=""):
self.data = data
self.path = path
if data[:4] != b"\x7fELF":
raise ValueError("not an ELF file")
if data[4] != 2:
raise ValueError("only 64-bit ELF is supported")
self.machine = struct.unpack_from("<H", data, 18)[0]
self.phoff = struct.unpack_from("<Q", data, 32)[0]
self.phentsize = struct.unpack_from("<H", data, 54)[0]
self.phnum = struct.unpack_from("<H", data, 56)[0]
self.loads = [] # (vaddr, offset, filesz, memsz, flags)
self.dynamic = None # (offset, size)
self.gnu_eh_frame = None
for i in range(self.phnum):
off = self.phoff + i * self.phentsize
p_type, p_flags = struct.unpack_from("<II", data, off)
p_offset, p_vaddr, _pa, p_filesz, p_memsz, _al = struct.unpack_from(
"<QQQQQQ", data, off + 8)
if p_type == PT_LOAD:
self.loads.append((p_vaddr, p_offset, p_filesz, p_memsz, p_flags))
elif p_type == PT_DYNAMIC:
self.dynamic = (p_offset, p_filesz)
elif p_type == 0x6474e550:
self.gnu_eh_frame = (p_vaddr, p_filesz)
@property
def arch(self):
return ARCH_NAME.get(self.machine, "machine=%d" % self.machine)
def exec_segment(self):
for seg in self.loads:
if seg[4] & 1:
return seg
raise ValueError("no executable PT_LOAD segment")
def vaddr_to_off(self, vaddr):
for va, fo, _fs, ms, _fl in self.loads:
if va <= vaddr < va + ms:
return fo + (vaddr - va)
return None
def dynamic_tags(self):
if self.dynamic is None:
raise ValueError("no PT_DYNAMIC segment")
tags = {}
off, size = self.dynamic
for j in range(size // 16):
t, v = struct.unpack_from("<qQ", self.data, off + j * 16)
if t == 0:
break
tags[t] = v
return tags
def reloc_map(elf):
"""{GOT vaddr: imported symbol name} from DT_JMPREL (+ DT_RELA when present)."""
tags = elf.dynamic_tags()
if DT_STRTAB not in tags or DT_SYMTAB not in tags:
return {}
strtab_o = elf.vaddr_to_off(tags[DT_STRTAB])
symtab_o = elf.vaddr_to_off(tags[DT_SYMTAB])
strsz = tags.get(DT_STRSZ, 0)
if strtab_o is None or symtab_o is None:
return {}
def symname(idx):
so = symtab_o + idx * 24
if so + 4 > len(elf.data):
return "?"
(st_name,) = struct.unpack_from("<I", elf.data, so)
if st_name >= strsz:
return "?"
start = strtab_o + st_name
end = elf.data.find(b"\x00", start)
if end < 0:
return "?"
return elf.data[start:end].decode("utf-8", "replace")
out = {}
for tag_off, tag_sz in ((DT_JMPREL, DT_PLTRELSZ), (DT_RELA, DT_RELASZ)):
if tag_off not in tags:
continue
base = elf.vaddr_to_off(tags[tag_off])
if base is None:
continue
n = tags.get(tag_sz, 0) // 24
for k in range(n):
if base + k * 24 + 16 > len(elf.data):
break
r_offset, r_info = struct.unpack_from("<QQ", elf.data, base + k * 24)
out[r_offset] = symname(r_info >> 32)
return out
# --------------------------------------------------------------------------
# Stub decoders -- written out rather than delegated, so nothing stops early
# --------------------------------------------------------------------------
def find_stubs(elf, relocs):
"""{stub_vaddr: (symbol, got_vaddr)} for the library's stub table."""
base, foff, fsz, _ms, _fl = elf.exec_segment()
if elf.machine == EM_X86_64:
return _stubs_x86(elf.data, base, foff, fsz, relocs)
if elf.machine == EM_AARCH64:
return _stubs_arm64(elf.data, base, foff, fsz, relocs)
return {}
def _stubs_x86(data, base, foff, fsz, relocs):
"""x86_64 PLT stub: `ff 25 <disp32>` == jmp qword ptr [rip+disp32]."""
out = {}
i = 0
while i < fsz - 6:
if data[foff + i] == 0xFF and data[foff + i + 1] == 0x25:
disp = struct.unpack_from("<i", data, foff + i + 2)[0]
va = base + i
target = va + 6 + disp
if target in relocs:
out[va] = (relocs[target], target)
i += 1
return out
def _stubs_arm64(data, base, foff, fsz, relocs):
"""aarch64 PLT stub: adrp x16 / ldr x17,[x16,#off] / add x16,x16,#off / br x17.
Note the stub is FOUR instructions (16 bytes), not four bytes. Assuming the
shorter form leads to "borrowing" the next slot, which belongs to a
different symbol.
"""
out = {}
off = 0
while off < fsz - 16:
w0, w1, w2, w3 = struct.unpack_from("<IIII", data, foff + off)
dec = _decode_adrp(w0, base + off)
if dec is not None:
page, rd0 = dec
ldr = _decode_ldr_unsigned(w1)
add = _decode_add_imm(w2)
br = _decode_br(w3)
if (ldr is not None and add is not None and br is not None
and rd0 == 16 and ldr[0] == 16 and ldr[1] == 17
and add[0] == 16 and add[1] == 16
and ldr[2] == add[2] and br == 17):
got = page + add[2]
if got in relocs:
out[base + off] = (relocs[got], got)
off += 4
return out
def _sign_extend(value, bits):
if value & (1 << (bits - 1)):
value -= (1 << bits)
return value
def _decode_adrp(word, pc):
"""adrp Rd, #imm -> (target_page, Rd). None if not an adrp."""
if (word >> 31) & 1 != 1:
return None
if ((word >> 24) & 0x1F) != 0x10:
return None
immlo = (word >> 29) & 0x3
immhi = (word >> 5) & 0x7FFFF
imm = (immhi << 2) | immlo
imm = _sign_extend(imm, 21)
return (pc & ~0xFFF) + (imm << 12), (word & 0x1F)
def _decode_ldr_unsigned(word):
"""ldr Rt, [Rn, #imm] (64-bit unsigned offset) -> (Rn, Rt, byte_offset)."""
if (word >> 30) != 0b11: # size == 64-bit
return None
if ((word >> 27) & 0x7) != 0b111:
return None
if ((word >> 24) & 0x3) != 0b01:
return None
imm12 = (word >> 10) & 0xFFF
rn = (word >> 5) & 0x1F
rt = word & 0x1F
return rn, rt, imm12 * 8
def _decode_add_imm(word):
"""add Rd, Rn, #imm -> (Rn, Rd, imm).
Encoding: sf op S 100010 sh imm12 Rn Rd
bits[31]=sf bits[30]=op bits[29]=S bits[28:23]=100010 (6 bits, not 9)
"""
if (word >> 31) & 1 != 1: # sf must be 1 (64-bit form)
return None
if ((word >> 23) & 0x3F) != 0b100010:
return None
if (word >> 22) & 1: # shifted form -- not what stubs use
return None
imm12 = (word >> 10) & 0xFFF
rn = (word >> 5) & 0x1F
rd = word & 0x1F
return rn, rd, imm12
def _decode_br(word):
"""br Rn -> Rn, or None."""
if (word & 0xFFFFFC1F) == 0xD61F0000:
return (word >> 5) & 0x1F
return None
def find_branch_callers(elf, target_vaddr, limit=500):
"""Addresses of BL/B instructions that branch to target_vaddr."""
base, foff, fsz, _ms, _fl = elf.exec_segment()
hits = []
if elf.machine == EM_AARCH64:
off = 0
while off < fsz - 4:
(w,) = struct.unpack_from("<I", data_slice(elf.data, foff + off, 4))
if (w & 0xFC000000) in (0x94000000, 0x14000000): # BL / B
imm = _sign_extend(w & 0x03FFFFFF, 26) << 2
if base + off + imm == target_vaddr:
hits.append(base + off)
if len(hits) >= limit:
break
off += 4
elif elf.machine == EM_X86_64:
i = 0
while i < fsz - 5:
if elf.data[foff + i] == 0xE8:
disp = struct.unpack_from("<i", elf.data, foff + i + 1)[0]
if base + i + 5 + disp == target_vaddr:
hits.append(base + i)
if len(hits) >= limit:
break
i += 1
return hits
def data_slice(data, off, n):
return data[off:off + n]
# --------------------------------------------------------------------------
# Commands
# --------------------------------------------------------------------------
def load(path):
with open(path, "rb") as fh:
return Elf(fh.read(), path)
def cmd_list(elf, args):
relocs = reloc_map(elf)
stubs = find_stubs(elf, relocs)
base, foff, fsz, _ms, _fl = elf.exec_segment()
print("== %s" % elf.path)
print(" arch=%s exec seg: vaddr=0x%x file=0x%x size=0x%x" % (elf.arch, base, foff, fsz))
print(" imported relocations=%d stubs resolved=%d" % (len(relocs), len(stubs)))
if not stubs:
print(" NOTE: zero stubs resolved. Either the library has no PLT, or the")
print(" hardening removed/forged the relocation tables. Do not read")
print(" this as 'the library imports nothing'.")
for va in sorted(stubs):
sym, got = stubs[va]
print(" 0x%08x -> %-34s (GOT 0x%x)" % (va, sym, got))
return stubs
def cmd_query(elf, args):
relocs = reloc_map(elf)
stubs = find_stubs(elf, relocs)
for a in args.query:
va = int(a, 0)
hit = stubs.get(va)
if hit:
print("0x%x -> %s (GOT 0x%x)" % (va, hit[0], hit[1]))
else:
print("0x%x -> not a resolved stub" % va)
def cmd_symbol(elf, args):
relocs = reloc_map(elf)
stubs = find_stubs(elf, relocs)
wanted = args.symbol
found = [(va, sym, got) for va, (sym, got) in stubs.items() if sym == wanted]
if not found:
near = sorted({s for _v, (s, _g) in stubs.items()
if wanted in s or s in wanted})[:10]
print("no stub resolves to %r" % wanted)
if near:
print(" similar: %s" % ", ".join(near))
return
for va, sym, got in sorted(found):
print("stub 0x%x -> %s (GOT 0x%x)" % (va, sym, got))
if args.callers:
callers = find_branch_callers(elf, va)
print(" callers of the stub: %d" % len(callers))
for c in callers:
print(" 0x%x" % c)
print(" NOTE: a stub-level patch covers all of these. A call-site-level")
print(" patch must cover every one of them, plus any caller reached")
print(" through a cached function pointer (not listed here).")
def cmd_diff(elf_a, args):
path_b = args.diff
elf_b = load(path_b)
a, b = elf_a.data, elf_b.data
print("== diff")
print(" A %s %d bytes sha256=%s" % (elf_a.path, len(a), hashlib.sha256(a).hexdigest()[:16]))
print(" B %s %d bytes sha256=%s" % (path_b, len(b), hashlib.sha256(b).hexdigest()[:16]))
if len(a) != len(b):
print(" SIZE DIFFERS: %d vs %d -- a length change is itself a finding"
% (len(a), len(b)))
n = min(len(a), len(b))
runs = []
i = 0
while i < n:
if a[i] != b[i]:
j = i
while j < n and a[j] != b[j]:
j += 1
runs.append((i, j - i))
i = j
else:
i += 1
total = sum(r[1] for r in runs)
print(" differing bytes=%d in %d run(s)" % (total, len(runs)))
regions = {}
if args.name_regions:
relocs_a = reloc_map(elf_a)
stubs_a = find_stubs(elf_a, relocs_a)
def describe(off):
for va, (sym, _got) in stubs_a.items():
size = 16 if elf_a.machine == EM_AARCH64 else 16
if va <= off < va + size:
return "PLT stub for %s" % sym
return ""
regions = {"describe": describe}
for off, ln in runs:
note = ""
if regions:
note = regions["describe"](off)
if note:
note = " <- " + note
print(" off=0x%06x len=%-4d A: %s" % (off, ln, a[off:off + ln].hex(" ")))
print(" %s B: %s%s" % (" " * 14, b[off:off + ln].hex(" "), note))
if args.name_regions and not runs:
print(" (identical)")
def main():
ap = argparse.ArgumentParser(
description="PLT stub -> symbol mapping and .so diffing for hardened libraries.",
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("so", help="path to the ELF (.so)")
ap.add_argument("--query", nargs="+", metavar="ADDR",
help="resolve specific stub addresses (hex ok)")
ap.add_argument("--symbol", metavar="NAME",
help="find the stub that resolves to this symbol")
ap.add_argument("--callers", action="store_true",
help="with --symbol: also list branch call sites")
ap.add_argument("--diff", metavar="OTHER_SO",
help="byte-compare against another library")
ap.add_argument("--name-regions", action="store_true",
help="with --diff: name the symbol each changed stub belongs to")
args = ap.parse_args()
try:
elf = load(args.so)
except Exception as e:
sys.exit("error: %s" % e)
if args.diff:
cmd_diff(elf, args)
elif args.query:
cmd_query(elf, args)
elif args.symbol:
cmd_symbol(elf, args)
else:
cmd_list(elf, args)
if __name__ == "__main__":
main()
+279
View File
@@ -0,0 +1,279 @@
#!/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):
l = lines[j]
mm = PID_RE.search(l)
if mm and "pid" not in block:
block["pid"], block["tid"], block["tname"], block["proc"] = mm.groups()
mm = PROC_RE.search(l)
if mm and "proc" not in block:
block["proc"] = mm.group(1)
mm = UPTIME_RE.search(l)
if mm:
block["uptime"] = mm.group(1)
if "Cause:" in l and "cause" not in block:
block["cause"] = l.split("Cause:", 1)[1].strip()
# walk down through the dump
j = i
while j < len(lines) and j < i + 120:
l = lines[j]
if j > i + 3 and SIGNAL_RE.search(l) and j > i + 3:
break
block["raw"].append(l.strip())
mm = PID_RE.search(l)
if mm and "pid" not in block:
block["pid"], block["tid"], block["tname"], block["proc"] = mm.groups()
mm = PROC_RE.search(l)
if mm and "proc" not in block:
block["proc"] = mm.group(1)
mm = UPTIME_RE.search(l)
if mm:
block["uptime"] = mm.group(1)
if "Cause:" in l and "cause" not in block:
block["cause"] = l.split("Cause:", 1)[1].strip()
mm = FRAME_RE.search(l)
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 = l.split(path, 1)[1].strip() if path in l 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",
l.replace("F DEBUG :", " ")):
block["regs"][k] = int(v, 16)
if "backtrace:" in l:
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()