Files
apk-reverse/references/native-tamper-and-suicide.md
T
inliver233 ae739bb0c1 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.
2026-09-19 22:47:35 +08:00

14 KiB

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.

; 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 movs 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.