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
+457
View File
@@ -0,0 +1,457 @@
#!/usr/bin/env python3
"""PLT stub -> imported symbol mapping, and byte-level .so diffing.
Built for hardened/rebuilt native libraries where the usual paths lie:
* section headers are often forged, so tools that walk sections (readelf -x,
objdump -d on named sections, pyelftools' section API) return confidently
wrong or empty results;
* a linear disassembler can stop silently on a buffer that does not start at
an instruction boundary, so "no matches" from it is not evidence.
Therefore this tool walks PT_LOAD / PT_DYNAMIC by hand and decodes the two
stub shapes with its own decoders (no capstone dependency, no silent stops).
Why you need the stub -> symbol map: when a hardening library terminates the
process, it does so through a PLT stub. To suppress it safely you must know
*which* symbol a given stub resolves to -- inferring from position or from a
comment is how you end up freezing `snprintf` or `pthread_exit` and reporting
it as "the patch did not work".
Usage
-----
# full stub table
python elf_plt.py libfoo.so
# what do these stubs call?
python elf_plt.py libfoo.so --query 0x9920 0x9950
# find the stub for one symbol (and every caller of it)
python elf_plt.py libfoo.so --symbol kill --callers
# what changed between the original and a rebuilt library?
python elf_plt.py original.so --diff rebuilt.so
# same, but also name the symbol each changed stub belongs to
python elf_plt.py original.so --diff rebuilt.so --name-regions
"""
import argparse
import hashlib
import os
import struct
import sys
PT_LOAD = 1
PT_DYNAMIC = 2
DT_PLTRELSZ = 2
DT_PLTGOT = 3
DT_STRTAB = 5
DT_SYMTAB = 6
DT_RELA = 7
DT_RELASZ = 8
DT_STRSZ = 10
DT_SYMENT = 11
DT_PLTREL = 20
DT_JMPREL = 23
EM_X86_64 = 62
EM_AARCH64 = 183
ARCH_NAME = {EM_X86_64: "x86_64", EM_AARCH64: "aarch64"}
# --------------------------------------------------------------------------
# ELF parsing (program headers only -- never trust the section table here)
# --------------------------------------------------------------------------
class Elf:
def __init__(self, data, path=""):
self.data = data
self.path = path
if data[:4] != b"\x7fELF":
raise ValueError("not an ELF file")
if data[4] != 2:
raise ValueError("only 64-bit ELF is supported")
self.machine = struct.unpack_from("<H", data, 18)[0]
self.phoff = struct.unpack_from("<Q", data, 32)[0]
self.phentsize = struct.unpack_from("<H", data, 54)[0]
self.phnum = struct.unpack_from("<H", data, 56)[0]
self.loads = [] # (vaddr, offset, filesz, memsz, flags)
self.dynamic = None # (offset, size)
self.gnu_eh_frame = None
for i in range(self.phnum):
off = self.phoff + i * self.phentsize
p_type, p_flags = struct.unpack_from("<II", data, off)
p_offset, p_vaddr, _pa, p_filesz, p_memsz, _al = struct.unpack_from(
"<QQQQQQ", data, off + 8)
if p_type == PT_LOAD:
self.loads.append((p_vaddr, p_offset, p_filesz, p_memsz, p_flags))
elif p_type == PT_DYNAMIC:
self.dynamic = (p_offset, p_filesz)
elif p_type == 0x6474e550:
self.gnu_eh_frame = (p_vaddr, p_filesz)
@property
def arch(self):
return ARCH_NAME.get(self.machine, "machine=%d" % self.machine)
def exec_segment(self):
for seg in self.loads:
if seg[4] & 1:
return seg
raise ValueError("no executable PT_LOAD segment")
def vaddr_to_off(self, vaddr):
for va, fo, _fs, ms, _fl in self.loads:
if va <= vaddr < va + ms:
return fo + (vaddr - va)
return None
def dynamic_tags(self):
if self.dynamic is None:
raise ValueError("no PT_DYNAMIC segment")
tags = {}
off, size = self.dynamic
for j in range(size // 16):
t, v = struct.unpack_from("<qQ", self.data, off + j * 16)
if t == 0:
break
tags[t] = v
return tags
def reloc_map(elf):
"""{GOT vaddr: imported symbol name} from DT_JMPREL (+ DT_RELA when present)."""
tags = elf.dynamic_tags()
if DT_STRTAB not in tags or DT_SYMTAB not in tags:
return {}
strtab_o = elf.vaddr_to_off(tags[DT_STRTAB])
symtab_o = elf.vaddr_to_off(tags[DT_SYMTAB])
strsz = tags.get(DT_STRSZ, 0)
if strtab_o is None or symtab_o is None:
return {}
def symname(idx):
so = symtab_o + idx * 24
if so + 4 > len(elf.data):
return "?"
(st_name,) = struct.unpack_from("<I", elf.data, so)
if st_name >= strsz:
return "?"
start = strtab_o + st_name
end = elf.data.find(b"\x00", start)
if end < 0:
return "?"
return elf.data[start:end].decode("utf-8", "replace")
out = {}
for tag_off, tag_sz in ((DT_JMPREL, DT_PLTRELSZ), (DT_RELA, DT_RELASZ)):
if tag_off not in tags:
continue
base = elf.vaddr_to_off(tags[tag_off])
if base is None:
continue
n = tags.get(tag_sz, 0) // 24
for k in range(n):
if base + k * 24 + 16 > len(elf.data):
break
r_offset, r_info = struct.unpack_from("<QQ", elf.data, base + k * 24)
out[r_offset] = symname(r_info >> 32)
return out
# --------------------------------------------------------------------------
# Stub decoders -- written out rather than delegated, so nothing stops early
# --------------------------------------------------------------------------
def find_stubs(elf, relocs):
"""{stub_vaddr: (symbol, got_vaddr)} for the library's stub table."""
base, foff, fsz, _ms, _fl = elf.exec_segment()
if elf.machine == EM_X86_64:
return _stubs_x86(elf.data, base, foff, fsz, relocs)
if elf.machine == EM_AARCH64:
return _stubs_arm64(elf.data, base, foff, fsz, relocs)
return {}
def _stubs_x86(data, base, foff, fsz, relocs):
"""x86_64 PLT stub: `ff 25 <disp32>` == jmp qword ptr [rip+disp32]."""
out = {}
i = 0
while i < fsz - 6:
if data[foff + i] == 0xFF and data[foff + i + 1] == 0x25:
disp = struct.unpack_from("<i", data, foff + i + 2)[0]
va = base + i
target = va + 6 + disp
if target in relocs:
out[va] = (relocs[target], target)
i += 1
return out
def _stubs_arm64(data, base, foff, fsz, relocs):
"""aarch64 PLT stub: adrp x16 / ldr x17,[x16,#off] / add x16,x16,#off / br x17.
Note the stub is FOUR instructions (16 bytes), not four bytes. Assuming the
shorter form leads to "borrowing" the next slot, which belongs to a
different symbol.
"""
out = {}
off = 0
while off < fsz - 16:
w0, w1, w2, w3 = struct.unpack_from("<IIII", data, foff + off)
dec = _decode_adrp(w0, base + off)
if dec is not None:
page, rd0 = dec
ldr = _decode_ldr_unsigned(w1)
add = _decode_add_imm(w2)
br = _decode_br(w3)
if (ldr is not None and add is not None and br is not None
and rd0 == 16 and ldr[0] == 16 and ldr[1] == 17
and add[0] == 16 and add[1] == 16
and ldr[2] == add[2] and br == 17):
got = page + add[2]
if got in relocs:
out[base + off] = (relocs[got], got)
off += 4
return out
def _sign_extend(value, bits):
if value & (1 << (bits - 1)):
value -= (1 << bits)
return value
def _decode_adrp(word, pc):
"""adrp Rd, #imm -> (target_page, Rd). None if not an adrp."""
if (word >> 31) & 1 != 1:
return None
if ((word >> 24) & 0x1F) != 0x10:
return None
immlo = (word >> 29) & 0x3
immhi = (word >> 5) & 0x7FFFF
imm = (immhi << 2) | immlo
imm = _sign_extend(imm, 21)
return (pc & ~0xFFF) + (imm << 12), (word & 0x1F)
def _decode_ldr_unsigned(word):
"""ldr Rt, [Rn, #imm] (64-bit unsigned offset) -> (Rn, Rt, byte_offset)."""
if (word >> 30) != 0b11: # size == 64-bit
return None
if ((word >> 27) & 0x7) != 0b111:
return None
if ((word >> 24) & 0x3) != 0b01:
return None
imm12 = (word >> 10) & 0xFFF
rn = (word >> 5) & 0x1F
rt = word & 0x1F
return rn, rt, imm12 * 8
def _decode_add_imm(word):
"""add Rd, Rn, #imm -> (Rn, Rd, imm).
Encoding: sf op S 100010 sh imm12 Rn Rd
bits[31]=sf bits[30]=op bits[29]=S bits[28:23]=100010 (6 bits, not 9)
"""
if (word >> 31) & 1 != 1: # sf must be 1 (64-bit form)
return None
if ((word >> 23) & 0x3F) != 0b100010:
return None
if (word >> 22) & 1: # shifted form -- not what stubs use
return None
imm12 = (word >> 10) & 0xFFF
rn = (word >> 5) & 0x1F
rd = word & 0x1F
return rn, rd, imm12
def _decode_br(word):
"""br Rn -> Rn, or None."""
if (word & 0xFFFFFC1F) == 0xD61F0000:
return (word >> 5) & 0x1F
return None
def find_branch_callers(elf, target_vaddr, limit=500):
"""Addresses of BL/B instructions that branch to target_vaddr."""
base, foff, fsz, _ms, _fl = elf.exec_segment()
hits = []
if elf.machine == EM_AARCH64:
off = 0
while off < fsz - 4:
(w,) = struct.unpack_from("<I", data_slice(elf.data, foff + off, 4))
if (w & 0xFC000000) in (0x94000000, 0x14000000): # BL / B
imm = _sign_extend(w & 0x03FFFFFF, 26) << 2
if base + off + imm == target_vaddr:
hits.append(base + off)
if len(hits) >= limit:
break
off += 4
elif elf.machine == EM_X86_64:
i = 0
while i < fsz - 5:
if elf.data[foff + i] == 0xE8:
disp = struct.unpack_from("<i", elf.data, foff + i + 1)[0]
if base + i + 5 + disp == target_vaddr:
hits.append(base + i)
if len(hits) >= limit:
break
i += 1
return hits
def data_slice(data, off, n):
return data[off:off + n]
# --------------------------------------------------------------------------
# Commands
# --------------------------------------------------------------------------
def load(path):
with open(path, "rb") as fh:
return Elf(fh.read(), path)
def cmd_list(elf, args):
relocs = reloc_map(elf)
stubs = find_stubs(elf, relocs)
base, foff, fsz, _ms, _fl = elf.exec_segment()
print("== %s" % elf.path)
print(" arch=%s exec seg: vaddr=0x%x file=0x%x size=0x%x" % (elf.arch, base, foff, fsz))
print(" imported relocations=%d stubs resolved=%d" % (len(relocs), len(stubs)))
if not stubs:
print(" NOTE: zero stubs resolved. Either the library has no PLT, or the")
print(" hardening removed/forged the relocation tables. Do not read")
print(" this as 'the library imports nothing'.")
for va in sorted(stubs):
sym, got = stubs[va]
print(" 0x%08x -> %-34s (GOT 0x%x)" % (va, sym, got))
return stubs
def cmd_query(elf, args):
relocs = reloc_map(elf)
stubs = find_stubs(elf, relocs)
for a in args.query:
va = int(a, 0)
hit = stubs.get(va)
if hit:
print("0x%x -> %s (GOT 0x%x)" % (va, hit[0], hit[1]))
else:
print("0x%x -> not a resolved stub" % va)
def cmd_symbol(elf, args):
relocs = reloc_map(elf)
stubs = find_stubs(elf, relocs)
wanted = args.symbol
found = [(va, sym, got) for va, (sym, got) in stubs.items() if sym == wanted]
if not found:
near = sorted({s for _v, (s, _g) in stubs.items()
if wanted in s or s in wanted})[:10]
print("no stub resolves to %r" % wanted)
if near:
print(" similar: %s" % ", ".join(near))
return
for va, sym, got in sorted(found):
print("stub 0x%x -> %s (GOT 0x%x)" % (va, sym, got))
if args.callers:
callers = find_branch_callers(elf, va)
print(" callers of the stub: %d" % len(callers))
for c in callers:
print(" 0x%x" % c)
print(" NOTE: a stub-level patch covers all of these. A call-site-level")
print(" patch must cover every one of them, plus any caller reached")
print(" through a cached function pointer (not listed here).")
def cmd_diff(elf_a, args):
path_b = args.diff
elf_b = load(path_b)
a, b = elf_a.data, elf_b.data
print("== diff")
print(" A %s %d bytes sha256=%s" % (elf_a.path, len(a), hashlib.sha256(a).hexdigest()[:16]))
print(" B %s %d bytes sha256=%s" % (path_b, len(b), hashlib.sha256(b).hexdigest()[:16]))
if len(a) != len(b):
print(" SIZE DIFFERS: %d vs %d -- a length change is itself a finding"
% (len(a), len(b)))
n = min(len(a), len(b))
runs = []
i = 0
while i < n:
if a[i] != b[i]:
j = i
while j < n and a[j] != b[j]:
j += 1
runs.append((i, j - i))
i = j
else:
i += 1
total = sum(r[1] for r in runs)
print(" differing bytes=%d in %d run(s)" % (total, len(runs)))
regions = {}
if args.name_regions:
relocs_a = reloc_map(elf_a)
stubs_a = find_stubs(elf_a, relocs_a)
def describe(off):
for va, (sym, _got) in stubs_a.items():
size = 16 if elf_a.machine == EM_AARCH64 else 16
if va <= off < va + size:
return "PLT stub for %s" % sym
return ""
regions = {"describe": describe}
for off, ln in runs:
note = ""
if regions:
note = regions["describe"](off)
if note:
note = " <- " + note
print(" off=0x%06x len=%-4d A: %s" % (off, ln, a[off:off + ln].hex(" ")))
print(" %s B: %s%s" % (" " * 14, b[off:off + ln].hex(" "), note))
if args.name_regions and not runs:
print(" (identical)")
def main():
ap = argparse.ArgumentParser(
description="PLT stub -> symbol mapping and .so diffing for hardened libraries.",
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("so", help="path to the ELF (.so)")
ap.add_argument("--query", nargs="+", metavar="ADDR",
help="resolve specific stub addresses (hex ok)")
ap.add_argument("--symbol", metavar="NAME",
help="find the stub that resolves to this symbol")
ap.add_argument("--callers", action="store_true",
help="with --symbol: also list branch call sites")
ap.add_argument("--diff", metavar="OTHER_SO",
help="byte-compare against another library")
ap.add_argument("--name-regions", action="store_true",
help="with --diff: name the symbol each changed stub belongs to")
args = ap.parse_args()
try:
elf = load(args.so)
except Exception as e:
sys.exit("error: %s" % e)
if args.diff:
cmd_diff(elf, args)
elif args.query:
cmd_query(elf, args)
elif args.symbol:
cmd_symbol(elf, args)
else:
cmd_list(elf, args)
if __name__ == "__main__":
main()