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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`. |
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user