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
+4
View File
@@ -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),
+264
View File
@@ -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.
+7
View File
@@ -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
+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.
+136
View File
@@ -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.