#!/usr/bin/env python3 """Scan an ELF or a raw memory capture for inline `svc` instructions and name the syscall. This is the tool behind one question: **is a libc-level hook even capable of seeing this call?** Code that issues `svc #0` directly -- or through its own inline `exit`/`kill`/`mprotect` table -- never enters libc, so `Interceptor.replace` on `exit_group`/`kill`/`mprotect` cannot observe it and a userspace "block the exit" strategy silently does nothing. That is the discriminator in `references/detection-and-anti-analysis.md` between "the exit came from libc" and "the exit bypassed libc": if the library that dies carries its own svc sites for `exit`/`kill`, a libc hook is the wrong instrument. It is also the sanity check for the *other* direction: a library with **no** svc sites for the termination syscalls is evidence that a libc-level hook can work, so a missing event is a finding about the hook rather than about the target. How the syscall number is recovered, and why it is a heuristic: - arm64 loads the number in `x8`, almost always as `movz x8, #imm` (or `mov` to x8, which assembles to the same), immediately before the `svc`. The scan looks back a short BOUNDED window for the nearest register-writing instruction that targets x8, and resolves the common immediate forms (`movz`, `mov` alias, `orr x8, xzr, #imm`). A number computed at runtime, or loaded from a table, will not resolve -- those sites are reported as `nr=?` with the raw instruction that wrote x8, because "I cannot read this one" is a different statement from "this is not a syscall". - arm (32-bit Thumb/A32) uses `r7` and a `svc` with an 8-bit immediate; the same bounded lookback applies. This script **finds and names**; it does not decide intent. A library full of `svc` sites that are all `futex`/`read` is ordinary libc-free code, not evasion. Read the histogram, not the total. Usage: python svc_scan.py libfoo.so python svc_scan.py /data/local/tmp/libjiagu_a64.so --json python svc_scan.py linker64 --arch arm64 --follow-exec python svc_scan.py region.bin --raw --arch arm64 --base 0x7a12340000 Exit codes: 0 = scan completed (hits or not), 1 = the input could not be read or the architecture could not be determined. A scan that finds zero svc sites exits 0: that is a result, not an error. """ import argparse import json import os import struct import sys try: from capstone import Cs, CS_ARCH_ARM64, CS_ARCH_ARM, CS_MODE_ARM, CS_MODE_THUMB from capstone.arm64 import ARM64_OP_IMM, ARM64_OP_REG from capstone.arm import ARM_OP_IMM, ARM_OP_REG except ImportError: # pragma: no cover - dependency message is the point sys.stderr.write( "capstone is required: python -m pip install capstone\n" "(this repository's other native scripts use it as well)\n") raise SystemExit(1) # arm64 syscall numbers (linux, asm-generic). Only the ones worth naming: a scan # report that names `futex` is more useful than one that prints 98. ARM64_SYSCALLS = { 56: "openat", 57: "close", 61: "getdents64", 62: "lseek", 63: "read", 64: "write", 66: "writev", 78: "readlinkat", 79: "newfstatat", 80: "fstat", 93: "exit", 94: "exit_group", 98: "futex", 99: "set_robust_list", 101: "nanosleep", 113: "clock_gettime", 117: "ptrace", 129: "kill", 130: "tkill", 131: "tgkill", 134: "rt_sigaction", 135: "rt_sigprocmask", 160: "uname", 167: "prctl", 169: "gettimeofday", 172: "getpid", 173: "getppid", 174: "getuid", 175: "geteuid", 178: "gettid", 214: "brk", 215: "munmap", 216: "mremap", 220: "clone", 221: "execve", 222: "mmap", 226: "mprotect", 260: "wait4", 273: "set_robust_list", 276: "renameat", 278: "getrandom", 280: "utimensat", 291: "statx", } # The three groups that decide whether a userspace strategy is even applicable. TERMINATION = {93, 94, 129, 130, 131} # exit / exit_group / kill / tkill / tgkill EVASION = {117, 167, 226, 222, 220} # ptrace / prctl / mprotect / mmap / clone DISCOVERY = {56, 57, 61, 62, 63, 64, 78, 79} # openat / close / getdents64 / read... # arm (32-bit) numbers, for the armeabi-v7a half of a dual-ABI APK. ARM_SYSCALLS = { 1: "exit", 3: "read", 4: "write", 5: "open", 6: "close", 20: "getpid", 37: "kill", 38: "rename", 45: "brk", 54: "ioctl", 91: "munmap", 120: "clone", 122: "uname", 125: "mprotect", 192: "mmap2", 199: "getuid", 224: "gettid", 238: "tkill", 240: "futex", 248: "exit_group", 268: "statfs64", } # How far back to look for the instruction that loads the syscall number. Real # compilers put it 1-3 instructions before the svc (sometimes with an intermediate # `orr`/`and`); 8 words is generous without letting an unrelated x8 write leak in. LOOKBACK = 8 def parse_elf_segments(data): """Return [(file_offset, vaddr, filesz, exec_flag)] for PT_LOAD, or None if not ELF. Hand-walked rather than delegated to pyelftools on purpose: this kit's other native scripts do the same, because a target's section header table is exactly the thing a hardened binary forges (see `references/native-tamper-and-suicide.md`). Note the pf_x handling: it is *reported*, not used as a filter. Measured on a real device, the ROM's own `libc.so` and `linker64` both carry `pf_x == 0` on every PT_LOAD — so filtering to executable segments silently drops every hit on this platform. The flag is printed per segment instead, so a run that mixes code and data is visible rather than wrong. """ if len(data) < 64 or data[:4] != b"\x7fELF": return None is64 = data[4] == 2 little = data[5] == 1 if not little: return None # no big-endian Android target in this kit's scope if is64: e_phoff, = struct.unpack_from(" len(data): break # Elf64_Phdr: p_type u32, p_flags u32, p_offset u64 ... p_type, p_flags = struct.unpack_from(" len(data): break p_type = struct.unpack_from("= 20 and data[:4] == b"\x7fELF": machine = struct.unpack_from(" " if insn.address == off else " ", insn.address, insn.mnemonic, insn.op_str)) return out def looks_like_data(md, data, arch, off, before=6): """True when the bytes just before the site are not a plausible instruction stream. Heuristic, and deliberately conservative: it only fires on the shapes that were actually observed around byte-scan hits inside data (`udf`, `scvtf` with a wide shift, NEON `mul v..s[..]`, an `orr w.., w.., #0xfffff...` mask). It never removes a site -- it labels it, because "this is a real svc I do not understand" and "this is not an instruction" need different follow-ups. """ if off < 16: return False window = data[max(0, off - before * 4):off] for insn in md.disasm(window, max(0, off - before * 4)): m = insn.mnemonic if m == "udf": return True if m in ("scvtf", "ucvtf") and "#0x1" in insn.op_str: return True if m in ("mul", "mla") and "s[" in insn.op_str: return True if m == "orr" and "#0xfffff" in insn.op_str: return True return False def scan_blob(data, arch, base, thumb=False, segs=None, code_only=False, context=0): """Return (sites, per_segment_counts). `segs` limits the scan to PT_LOAD regions.""" md = make_disassembler(arch, thumb) reg = "x8" if arch == "arm64" else "r7" table = ARM64_SYSCALLS if arch == "arm64" else ARM_SYSCALLS ranges = [] if segs: for idx, (p_offset, p_vaddr, p_filesz, p_flags) in enumerate(segs): if code_only and not (p_flags & 1): continue end = min(len(data), p_offset + p_filesz) if end > p_offset: ranges.append((p_offset, end, p_vaddr - p_offset, idx, p_flags)) else: ranges.append((0, len(data), base, 0, None)) sites = [] per_segment = {} for lo, hi, vaddr_bias, idx, p_flags in ranges: # Word-aligned walk only: an unaligned resync over the whole file produces # garbage sites. Real svc sites sit on an instruction boundary, and the walk # is done per 4 bytes so a straddling pattern cannot hide one. n_here = 0 off = lo + ((4 - lo % 4) % 4) while off + 4 <= hi: word = struct.unpack_from(" to scan it as a capture\n" % args.path) return 1 detected = guess_arch(data) if args.arch == "auto": if detected is None: sys.stderr.write("cannot determine the architecture from the ELF header; " "pass --arch explicitly\n") return 1 arch = detected segs = parse_elf_segments(data) elif args.arch == "auto": sys.stderr.write("--raw needs an explicit --arch (there is no header to read)\n") return 1 base = int(args.base, 0) sites, per_segment = scan_blob(data, arch, base, thumb=args.thumb, segs=segs, code_only=args.code_only, context=args.context) truncated = False if args.max_sites and len(sites) > args.max_sites: sites = sites[:args.max_sites] truncated = True by_group = {} by_name = {} n_data = 0 n_noload = 0 for s in sites: g = group_of(s, arch) by_group[g] = by_group.get(g, 0) + 1 key = s["name"] or "unresolved" by_name[key] = by_name.get(key, 0) + 1 if s.get("likely_data"): n_data += 1 if s["writer"] is None: n_noload += 1 if args.json: print(json.dumps({ "path": os.path.abspath(args.path), "arch": arch, "code_only": args.code_only, "segments": [ {"index": i, "exec": None if fl is None else bool(fl & 1), "file_range": [lo, hi], "svc_sites": c} for i, (c, fl, lo, hi) in sorted(per_segment.items()) ], "sites": sites, "by_group": by_group, "by_name": by_name, "likely_data_sites": n_data, "no_load_sites": n_noload, "truncated": truncated, }, indent=2)) return 0 print("== svc scan: %s (%s) ==" % (os.path.basename(args.path), arch)) if per_segment and segs: print("segments (pf_x is reported from the program header, not used as a filter):") for i, (c, fl, lo, hi) in sorted(per_segment.items()): print(" seg%-2d off=0x%-8x size=0x%-8x pf_x=%d svc=%d" % (i, lo, hi - lo, (fl & 1) if fl is not None else -1, c)) skipped = len(segs) - len(per_segment) if skipped: print(" (%d PT_LOAD segment(s) skipped by --code-only)" % skipped) print("sites: %d%s" % (len(sites), " (truncated by --max-sites)" if truncated else "")) if not sites: print("no inline `svc` instruction found in the scanned ranges.") print("reading: this file does not issue syscalls directly in the scanned bytes -- " "for the termination group that means a libc-level hook is not structurally " "excluded (see references/detection-and-anti-analysis.md).") return 0 fmt = " %-10s %-10s %-6s %-12s %-5s %s" print(fmt % ("offset", "vaddr", "svc#", "syscall", "nr", "note")) for s in sites: note = [] if s.get("likely_data"): note.append("neighbours are udf/NEON soup") if s["writer"] is None: note.append("no syscall-number load nearby") print(fmt % ("0x%x" % s["offset"], "0x%x" % s["vaddr"], s["svc_imm"], s["name"] or "?", s["nr"] if s["nr"] is not None else "?", "; ".join(note))) for line in s.get("context", ()): print(" %s" % line) print("\nhistogram by group: %s" % ", ".join( "%s=%d" % (k, by_group[k]) for k in sorted(by_group))) print("histogram by syscall: %s" % ", ".join( "%s=%d" % (k, by_name[k]) for k in sorted(by_name, key=lambda x: -by_name[x])[:12])) term = by_group.get("termination", 0) unresolved = by_group.get("unresolved", 0) print("\nreading:") if n_data or n_noload: print(" %d site(s) carry a data-artefact flag (%d udf/NEON neighbours, %d with no " "syscall-number load nearby). Both mean `likely not an instruction`: verify " "with --context before treating any of them as a call, because a byte scan " "for `svc` matches inside data." % (max(n_data, n_noload), n_data, n_noload)) if term: print(" %d termination site(s) (exit/exit_group/kill/tkill/tgkill): a libc-level " "hook CANNOT observe these. Prefer a static NOP at the site over an " "Interceptor.replace on libc, and check the site's own writer instruction " "before editing it." % term) else: print(" no termination site: your libc-level hook is not structurally excluded, so " "a missing exit event is a finding about the hook, not about the target.") if unresolved: print(" %d site(s) whose number is not a literal: either computed at runtime " "(`mov x8, x0`, `sxtw x8, w1`) or hidden behind a table. Report them as " "unknown -- do not assume a syscall number. A module whose svc sites are " "*all* unresolved is consistent with a deliberate indirection layer, but " "this scan cannot prove that; say `unresolved` in the record." % unresolved) return 0 if __name__ == "__main__": sys.exit(main())