Add native tamper/suicide guidance, toolchain map, and three scripts

references/native-tamper-and-suicide.md - how a hardened library terminates its own process: the mechanisms and their signatures, how to tell which one actually fires, arranged faults that call nothing (defeating every PLT-level fix), resolving a stub to its symbol, forged section headers, function boundaries from PT_GNU_EH_FRAME, scanner traps, and neutralising by returning rather than spinning.

references/toolchain.md - what to install, non-interactive invocation, GUI-only tools (ask for a human instead of silently substituting a weaker method), version-alignment traps, and working offline.

scripts/elf_plt.py - PLT stub to imported symbol on x86_64 and aarch64 from the relocation table; caller listing; library byte-diff naming each changed stub's symbol. scripts/apk_diff.py - entry-level diff by content hash, so same-size replacements are caught. scripts/native_crash.py - locate a native death from a log or tombstone and flag an arranged fault.

pitfalls P31-P36; SKILL.md gains a decision question, a hard constraint on neutralising terminate paths, and cross-references from packers/native-and-so.
This commit is contained in:
inliver233
2026-09-19 22:47:35 +08:00
parent e50c826d95
commit ae739bb0c1
10 changed files with 1584 additions and 1 deletions
+233
View File
@@ -794,3 +794,236 @@ Two close variants of the same error:
*change*, never *what* changed — and it never proves *absence*. Look at the images: a status-bar
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.