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:
@@ -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
|
||||
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:
|
||||
|
||||
- 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
|
||||
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
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user