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.
265 lines
14 KiB
Markdown
265 lines
14 KiB
Markdown
# 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.
|