Two real investigations against the same family of app produced long, chronological logs of what I tried, in what order, including the dead ends. Those logs stay private. This document is the other half: a walkthrough you can actually follow against your own target, built from the same techniques, with runnable commands and real example output at every step — not just the principles behind them.
Nothing here is tied to any specific app, vendor, or investigation by
design. Wherever a command needs a real name, I use a generic
placeholder (target.apk, libtarget.so, com.example.app,
Target_decode) — swap in your own app's names and the rest should
transfer directly. Work through the sections in order the first
time; each one produces an artifact the next one consumes (the
extracted .so → its symbol table → a Ghidra offset → a Frida hook
address), the same way the two source investigations actually unfolded.
What you'll need, before starting:
# Java/Kotlin decompilation
brew install apktool
pip install jadx # or: brew install jadx
# Native binary tools (usually already on macOS/Linux; llvm variants work too)
which nm objdump readelf strings
# Ghidra (GUI + headless analyzeHeadless script)
brew install --cask ghidra
# Unicorn emulation harness
pip install unicorn lief cryptography
# Frida — client + on-device server (match versions to each other)
pip install frida-tools
# or, if your Python's frida-tools is broken (see §7):
npm install frida frida-compile frida-java-bridge
# A rooted Android emulator to run Frida against
brew install --cask android-studio # for the AVD manager + adb
If you're about to reverse-engineer an Android app with a native
(.so) crypto/parsing core and no source, no test vectors, and no
vendor cooperation — this is the guide.
1Philosophy, before any tooling
Two rules carried both source investigations from "impossible" to "done," and every technique in this guide is just an implementation of one of these two. Internalize them before you touch a single tool.
Treat every layer as an independent black box, and don't move past a layer until it has a deterministic test vector. Don't try to understand the whole pipeline at once. Give each stage — the Java/Kotlin wrapper, the native entry point, each crypto round, each output field — its own success criterion: does my reimplementation produce byte-for-byte the same output the real app produces, for a real input? If yes, stop touching that stage and move to the next one. If no, don't guess — go back to live capture (§7). This discipline is what makes a reverse-engineering project converge instead of sprawling into an unfalsifiable pile of "I think it does X."
Reach for live, dynamic capture on the real app before static analysis, not after. This is the single biggest process lesson from both source investigations, and it's worth stating bluntly: every time the choice was "stare at more Ghidra pseudocode" versus "just hook it live and look," the live hook won — often in minutes, after the pseudocode route had already burned hours. Static analysis is for finding where to look (an address, a symbol, a candidate function). Dynamic capture is for finding what actually happens there — real argument values, real control flow, real intermediate results. You'll feel the pull toward "just read the disassembly one more time" when you're stuck — that pull is usually wrong. Go hook it instead.
2The toolkit, and when each tool earns its place
| Tool | What it's for | When to reach for it |
|---|---|---|
apktool / jadx |
Decompile the Java/Kotlin shell to (readable, if obfuscated) source | Always, first — this is where you find the native entry point and its exact signature |
nm -D / objdump -T on the .so |
Enumerate exported symbols | Before writing a single Frida hook — hook by symbol name, never by raw offset, whenever the symbol is exported (§5, §7) |
| Ghidra (GUI or headless) | Decompile specific native functions to pseudocode | To find candidate offsets/functions and form a hypothesis — not to answer "what value does X actually have at runtime" |
| Unicorn (ARM64 emulation) | Run the native code standalone, outside the JVM, with every memory access and external call intercepted | When you need to derive an algorithm (so you can reimplement it with zero dependency on the original binary) rather than just observe one run of it |
| Frida on a rooted device/emulator | Hook a live, actually-running instance of the real app | The default choice for "what does this function actually do/return/receive" — see §1 |
| Cross-sample diffing (a script you write yourself) | Separate fixed/structural bytes from genuinely-variable data in an opaque binary format | Any time you're staring at a blob of bytes asking "is this a constant, or does it vary per input?" — don't guess, get more samples and diff |
| An independent standard-conformant validator | Sanity-check your own parsing against someone else's implementation of the same spec | Once you think you've solved a standard (not proprietary) sub-format, before declaring it done |
The rest of this guide walks these seven rows in the order you'll actually use them.
3APK reconnaissance
Start here, every time, before touching the native binary.
Walkthrough:
# Unpack resources/manifest, and get readable-ish Java/Kotlin source.
# Obfuscators rename symbols, not control flow — jadx still gives you
# something you can follow.
apktool d target.apk -o target_unpacked
jadx -d target_jadx target.apk
# Find the JNI bridge: which class declares native methods, and what's
# the loaded library's name?
grep -rn "native " target_jadx/sources --include=*.java | head -20
grep -rn "loadLibrary" target_jadx/sources --include=*.java
Typical output worth paying attention to:
// somewhere in target_jadx/sources/.../Bridge.java
static { System.loadLibrary("target"); } // → libtarget.so is your binary
private final native Bitmap gImg(String data);
private final native String gTxt(String data);
private final native String decode(Activity act, byte[] data); // ← usually "the heart"
From here:
- Read every small helper around the main entry point, not just the
main one. Getters, size assertions, and pre-processing helpers
often encode wire-format details in plain Java — header stripping,
size constraints, buffer concatenation order — that you'd otherwise
have to rediscover by trial and error against the native code. If
you see something like
System.arraycopy(bArr, 2, bArr3, ...)right before the native call, that's a documented "strip the first 2 bytes" you don't have to reverse-engineer later. - Note the native method's exact signature, not just its name.
If you're looking at a second version of an app you've seen before
and the signature changed (e.g.
decode(Activity, byte[])becamedecode(Activity, List<byte[]>, boolean)), that's a strong signal the calling convention changed at the JNI boundary too — confirm the new argument-marshaling with Ghidra (§5) before assuming an old harness still applies. - Grep Java-side constants against the output you're chasing. A
hit like
JSON_TAG_EMAILorFIELD_NAME_Xtells you the output schema for free — you don't have to guess the field set, only how each field gets derived.
By the end of this section you should have: the entry point's exact
name + signature, the pre/post-processing Java does around it, and
libtarget.so pulled out and ready to inventory next.
4Native binary triage
Before disassembling anything, build an inventory. This is cheap and it changes what you look for in every later step.
Walkthrough:
file libtarget.so
# ELF 64-bit LSB shared object, ARM aarch64, ...
nm -D libtarget.so | wc -l # exported symbol count
nm -D libtarget.so | grep -iE "crypt|rsa|aes|ecc|ecdsa|hash|cipher"
Example output that's worth stopping and reading carefully:
0000000000257c1c T _ZN13VendorSdkCert11FingerprintEPKcS1_
0000000000252dc0 T EVP_DecryptUpdate
A hit like this is the single most valuable thing a first pass gives
you: a symbol from a named, well-known third-party crypto library —
a mangled C++ one from a commercial SDK, a plain C one like OpenSSL's
EVP_DecryptUpdate, or the BoringSSL/libsodium equivalents. You now
know the shape of the crypto — which primitives, whose API — before
you know a single concrete key or byte value. c++filt demangles a
mangled C++ symbol if you hit one:
echo '_ZN13VendorSdkCert11FingerprintEPKcS1_' | c++filt
# VendorSdkCert::Fingerprint(char const*, char const*)
Two more things worth checking in this same pass:
-
Hardcoded ciphertext-looking blobs in
.rodata. Long hex-looking byte runs at fixed offsets, especially in matched-up pairs, are worth flagging before you know what they decrypt to:objdump -h libtarget.so | grep rodata # find the .rodata section's file offset+size, then eyeball long # high-entropy runs within it — a repeating (key, blob) pair pattern # is a strong signal of a layered key-unwrap chain before you've # traced a single round of it. -
Anti-tampering / licensing guard function names —
isSafeEnvironment,checkLicense, anything that sounds like an integrity check. You'll likely need to patch or stub these out later for emulation (§6); it's cheaper to flag them now than to discover them via a mysterious earlyabort()three sections from now.
5Static analysis with Ghidra — and its real limit
Ghidra is for two things: finding candidate functions/offsets, and getting a first-pass pseudocode hypothesis for what a function does. It is not reliable for answering "what value flows through here on a real run" — that always needs §7.
Two sharp edges, worth knowing before you hit them:
Ghidra's own labels carry an image-base bias. A label like
FUN_002c19a0 already includes Ghidra's assumed image base — commonly
0x100000 for Android .so files loaded this way. The raw
so-file-relative offset you need for a Unicorn harness or a Frida
module.base.add() call is the label's number minus that bias:
Ghidra label: FUN_002c19a0
Image base: 0x100000
Raw so-file offset: 0x002c19a0 - 0x00100000 = 0x1c19a0 ← use THIS
Get this wrong and you get confusing "unmapped memory access" or "hit the wrong function" errors that look like a logic bug but are actually a units bug. If a Frida hook or Unicorn breakpoint lands somewhere nonsensical, check this arithmetic before anything else.
Automate the headless decompiler once, reuse it constantly. Opening
the GUI, navigating to a function, and waiting for it to decompile —
repeated by hand across dozens of candidate functions — is the single
most avoidable time sink in this whole process. A small GhidraScript
post-script fixes it in one sitting:
// DecompileTargets.java — a Ghidra headless post-script
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.decompiler.DecompileResults;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import java.io.PrintWriter;
public class DecompileTargets extends GhidraScript {
@Override
public void run() throws Exception {
long[] offsets = { 0x1a203cL, 0x1a5f10L, 0x1b0044L }; // raw so-offsets
String[] labels = { "entry_point", "crypto_driver", "field_dispatch" };
DecompInterface ifc = new DecompInterface();
ifc.openProgram(currentProgram);
Address base = currentProgram.getImageBase();
try (PrintWriter out = new PrintWriter("/tmp/decompiled.txt")) {
for (int i = 0; i < offsets.length; i++) {
Address addr = base.add(offsets[i]);
Function f = getFunctionAt(addr);
if (f == null) { out.println("no function at " + addr); continue; }
DecompileResults res = ifc.decompileFunction(f, 60, monitor);
out.println("=== " + labels[i] + " @ " + addr + " ===");
out.println(res.getDecompiledFunction().getC());
}
}
}
}
Run it non-interactively:
analyzeHeadless /path/to/project ProjectName \
-process libtarget.so \
-scriptPath . -postScript DecompileTargets.java
That turns "GUI dance × 40 functions" into one command you can re-run every time you have a new list of addresses worth reading.
6Building a Unicorn emulation harness
Reach for this when you need to derive an algorithm well enough to
reimplement it with zero runtime dependency on the original binary or
device — not just observe one execution of it. The payoff that makes it
worth building: once an algorithm is fully derived this way, your
production reimplementation runs in milliseconds with no .so, no
Unicorn, no device at all. That portability is the actual goal — the
emulator itself is a means to it, not the deliverable.
A minimal skeleton to adapt, showing the architecture every larger harness in this style is built from:
#!/usr/bin/env python3
import lief
from unicorn import *
from unicorn.arm64_const import *
BASE, STACK, STACK_SZ, HEAP, HEAP_SZ = 0x10000000, 0x80000000, 0x400000, 0x90000000, 0x4000000
STUB_AREA, RETURN_ADDR = 0xC0000000, 0xE0000000
binary = lief.parse("libtarget.so")
uc = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
# 1. Map every PT_LOAD segment at BASE.
for seg in binary.segments:
if seg.type == lief.ELF.SEGMENT_TYPES.LOAD:
addr = BASE + (seg.virtual_address & ~0xFFF)
size = ((seg.virtual_size + 0xFFF) & ~0xFFF) or 0x1000
uc.mem_map(addr, size, UC_PROT_ALL)
uc.mem_write(BASE + seg.virtual_address, bytes(seg.content))
# 2. Dedicated regions for everything the emulated code will touch.
uc.mem_map(STACK, STACK_SZ, UC_PROT_ALL)
uc.mem_map(HEAP, HEAP_SZ, UC_PROT_ALL)
uc.mem_map(STUB_AREA, 0x200000, UC_PROT_ALL)
uc.mem_map(RETURN_ADDR, 0x1000, UC_PROT_ALL)
uc.mem_write(RETURN_ADDR, b"\x00\x00\x20\xd4") # BRK #0 — the "you've returned" trap
# 3+4. Every external GOT entry becomes a BRK trap dispatched in Python.
stub_hooks = {} # addr -> (name, python_handler)
next_stub = STUB_AREA
def register_stub(name, handler):
global next_stub
addr = next_stub
uc.mem_write(addr, b"\x00\x00\x20\xd4" + b"\xc0\x03\x5f\xd6") # BRK #0; RET
stub_hooks[addr] = (name, handler)
next_stub += 8
return addr
def h_malloc(uc, name):
size = uc.reg_read(UC_ARM64_REG_X0)
# ... bump-allocate from HEAP, write result to X0, jump to LR ...
print(f" >> malloc({size})")
register_stub("malloc", h_malloc)
# ... walk binary.pltgot_relocations, rewrite each external entry to a
# stub of this shape, one register_stub() call per symbol name ...
def on_interrupt(uc, intno, data):
pc = uc.reg_read(UC_ARM64_REG_PC)
if pc in stub_hooks:
name, handler = stub_hooks[pc]
handler(uc, name)
else:
print(f"unhandled interrupt at 0x{pc:x}")
uc.emu_stop()
uc.hook_add(UC_HOOK_INTR, on_interrupt)
# 5. Call the real entry point.
entry = BASE + 0x1a203c # from Ghidra, bias-corrected (§5)
uc.reg_write(UC_ARM64_REG_SP, STACK + STACK_SZ - 0x1000)
uc.reg_write(UC_ARM64_REG_LR, RETURN_ADDR)
uc.emu_start(entry, RETURN_ADDR + 4, timeout=30_000_000)
From there, layer in what your specific target needs:
- A fake JNI environment, if the signature you're calling passes
anything richer than primitives (a
List, aString, anActivityobject): a hand-built vtable in emulated memory, with handlers for whichever calls (GetMethodID,CallObjectMethod,GetArrayLength,GetByteArrayRegion, ...) the target code actually invokes. Find the exact vtable offsets this specific binary uses by decompiling the JNI entry point in Ghidra (§5) — they're not always the canonicaljni.hnumbering, especially across NDK/compiler versions. - Synchronous
pthread_create. A single-threaded emulator that just returns0without ever running the thread body will hang any code that polls for a flag the "thread" was supposed to set. Fix: run the thread's start routine to completion, synchronously, right there inside yourpthread_createhandler, on a small dedicated scratch stack. - License/anti-tampering bypasses (flagged in §4): a NOP patch at load time, a BSS flag forced to the "passed" value, or — for a check that reads a real signed license/config blob — supplying the real file, extracted once from the app package and cached locally. Don't try to forge a signature; just give the check the real asset it expects.
A framing worth keeping in mind the whole time you're building this:
it's a research tool, not necessarily a production path. If your target
reimplementation doesn't need to run the original .so at runtime
(because you've fully derived its algorithm — the entire point of this
section), it's fine for the emulator to stay a debugging aid you reach
for occasionally, never ported to your production language, and never
required to reach a clean "no errors" exit before you call the
underlying algorithm work done. Getting real crypto/parsing calls to
fire and observing their real inputs/outputs is the valuable outcome;
the emulated program running to completion is a nice bonus, not the
bar.
7Frida on a rooted device/emulator — the workhorse
If you only build fluency with one dynamic technique, make it this one.
Set up once:
# Boot (or re-launch) a rooted AVD — cheap to tear down and recreate.
emulator -avd my_rooted_avd &
adb wait-for-device
adb root
# Push and run frida-server, version-matched to your client.
adb push frida-server-<ver>-android-arm64 /data/local/tmp/frida-server
adb shell chmod 755 /data/local/tmp/frida-server
adb shell "nohup /data/local/tmp/frida-server &"
# Sanity check:
frida-ps -U | head
If frida-ps (Python) errors out with something like
ImportError: cannot import name 'NotRequired' from 'typing', that's a
Python-version/frida package-version mismatch, not a device problem —
switch to the Node.js frida package as your driver instead (same
wire protocol, different host language):
npm install frida frida-compile frida-java-bridge
// run_frida.js — a minimal Node driver
const frida = require('frida');
const fs = require('fs');
(async () => {
const source = fs.readFileSync(process.argv[2], 'utf8');
const device = await frida.getUsbDevice();
const pid = await device.spawn(['com.example.app']);
const session = await device.attach(pid);
const script = await session.createScript(source);
script.message.connect((m) => console.log(m.payload ?? m));
await script.load();
await device.resume(pid);
await new Promise(r => setTimeout(r, 30000));
})();
Write the smallest hook that answers your question, bundle it, and run it:
// hook_example.js — hooks by EXPORTED symbol name, not raw offset
import Java from "frida-java-bridge";
globalThis.Java = Java;
Java.perform(() => {
const mod = Process.getModuleByName("libtarget.so");
const addr = mod.findExportByName("_ZN8CkCrypt212HashBytesENCER10CkByteDataR8CkString");
Interceptor.attach(addr, {
onEnter(args) { console.log("HashBytesENC called"); },
onLeave(retval) { console.log("-> returned"); },
});
});
frida-compile hook_example.js -o hook_bundled.js # resolves frida-java-bridge
node run_frida.js hook_bundled.js
A handful of hard-won rules that make the difference between "this works" and "why won't it attach/find anything":
- Hook by exported symbol name whenever the symbol is exported
(check with
nm -D, §4) —Module.findExportByName, not a raw offset. Symbol-name hooking survives ASLR and different builds; raw offsets (with the Ghidra bias correction from §5, when you truly need one for a non-exported internal function) are fragile and should be a last resort. - Drive the app's own real entry point, not a synthetic
substitute. If the function you're hooking expects data built up
through several prior method calls on a live object, reproduce that
exact sequence —
Java.choose()to find a liveActivityinstance, reflection to set private fields, then call the real method — rather than hand-assembling arguments and calling the native function directly. The real sequence exercises validation you don't know exists yet; a shortcut risks silently skipping it. -
Use
Thread.backtrace()when you need to know who calls a function, not just what it does. If a Stalker-based call trace mysteriously produces zero events despite ordinaryInterceptor.attachhooks firing fine on the same thread, don't debug the tracing engine — fall back to a plain backtrace captured right at the point of interest instead (e.g. inside amemcpyhook whose source buffer matches a byte pattern you're tracking):Interceptor.attach(Module.getExportByName("libc.so", "memcpy"), { onEnter(args) { // ... check args[1]/args[2] for your target pattern, then: console.log(Thread.backtrace(this.context, Backtracer.ACCURATE) .map(a => DebugSymbol.fromAddress(a).toString()).join("\n")); } }); -
One un-filtered trace of "what gets looked up, and how often" is a powerful general-purpose diagnostic. If the target stores parsed fields in some kind of key-value structure (a map, a "get-or-create slot by name" helper), hook that one generic accessor and log every key it's called with, across a real end-to-end run, no filtering:
const seen = {}; Interceptor.attach(genericLookupAddr, { onEnter(args) { const key = args[1].readCString(); seen[key] = (seen[key] || 0) + 1; console.log(`lookup: "${key}" (${seen[key]}x so far)`); } });The resulting call-count-per-key table tells you, for free, which fields are write-once-dead (present in the parsed data, but never read back by the app's own logic — not used for anything the user sees or that affects a normal operation; count of 1) versus write-then-read (genuinely consumed downstream; count of 2+). This one hook is how two completely different questions — "is this signature ever actually verified?" and "is this other field ever touched again after being parsed?" — each got a definitive, evidence-backed answer instead of a guess.
8Reverse-engineering an opaque binary/TLV format: diff, don't guess
This is the single most effective technique in either source investigation for cracking a format with no public spec.
Step 1 — get many real samples, not one. A single sample can't distinguish "this byte is always this value" from "this byte happens to be this value in the one case I have." Aim for a dozen-plus independent real inputs before you trust any conclusion about structure.
Step 2 — align and diff, don't eyeball. A short script does this far more reliably than staring at hex dumps side by side:
import sys
samples = [open(p, "rb").read() for p in sys.argv[1:]]
length = min(len(s) for s in samples)
for pos in range(length):
values = {s[pos] for s in samples}
if len(values) > 1:
print(f"offset {pos:3d}: VARIES {[hex(v) for v in sorted(values)]}")
# constant positions: no output, or print with a `--verbose` flag
Run it: python3 diff_samples.py sample1.bin sample2.bin sample3.bin ...
Positions that print nothing (constant across every sample) are structural — magic numbers, version tags, fixed padding, vendor stamps. Positions that vary are real per-input data, worth investigating further. From here:
- Look for recognizable substrings, not just statistics. Decode a constant run as ASCII before writing it off as noise — a vendor name or an unrelated format's magic number appearing inside your target format is a free, enormous clue. Four printable bytes in a sea of binary are worth a second look every time.
- A field constant across every sample, sitting where a spec says measured data should be, is a strong signal the container is being repurposed — e.g. a fixed-shape placeholder record (constant coordinates, constant flags) with only one or two of its byte positions genuinely carrying real per-input data, smuggled through a slot a generic parser already knows how to skip past without complaint.
-
Check a suspected length-prefix byte against reality, don't just theorize about it:
for i, s in enumerate(samples): declared = s[LENGTH_OFFSET] actual_remaining = len(s) - (LENGTH_OFFSET + 1) assert declared == actual_remaining, f"sample {i}: {declared} != {actual_remaining}"If this holds with zero exceptions across every sample, you've found a real, working field — even if it's in a different position or framing than whatever standard you expected would define it. - Measure entropy on anything you still can't characterize:
import math, collections def entropy(data): counts = collections.Counter(data) n = len(data) return -sum((c/n) * math.log2(c/n) for c in counts.values())A trailing blob near the theoretical maximum (8 bits/byte) reads as a hash, MAC, or densely-packed/encrypted payload; a blob with visible clustering or repetition reads as structured data you haven't decoded yet. This doesn't tell you what the data is, but it tells you what kind of thing to keep looking for — cheap to compute before spending more time guessing at structure that probably isn't there. - Don't stop at "where does the data end" without checking whether a surrounding container already answers that more reliably than the data's own internal fields do. A field's self-reported length can be stale or simply wrong; if that data lives inside a generic container format your project already parses for other reasons (TLV, length-prefixed records, ...), that outer framing's own declared length is very often the real, authoritative boundary — check it before trusting an inner field claiming the same thing.
9Validate against an independent tool, not just yourself
Once you believe you've solved a piece of a standard (not proprietary) sub-format, find a reference implementation you didn't write and run your own extracted data through it.
Before installing anything, look at what you're about to install:
pip download some-promising-package --no-deps -d /tmp/check
python3 -m zipfile -l /tmp/check/*.whl # what's actually inside?
unzip -p /tmp/check/*.whl some_promising_package/__init__.py | head -40
A plausible-sounding package name is not evidence of relevance — a name that matches your format's acronym can just as easily belong to an unrelated library that happens to share the abbreviation. This check costs a download and a minute of reading; skipping it costs an install, a test run, and the time to realize the tool was never applicable.
From there:
- Prefer an official/authoritative reference implementation — a standards body's own public-domain reference tooling is worth more than a same-named third-party package you found by search.
- When the reference tool partially fails on your data, that failure point is information, not a dead end. It tells you exactly where your data stops looking like the standard and starts being vendor-specific — often exactly the boundary you were already trying to find.
- A reference tool agreeing with you on the parts it can parse is real, independent confirmation — proof your own parsing isn't just self-consistent by construction (matching your own possibly-wrong assumptions), worth doing even for parts you're already confident about.
10Bugs worth knowing about in advance
Real ones, each costing real debugging time in one of the source investigations — recognize them faster than I did.
Ghidra image-base bias (§5, restated because it bites twice): a
FUN_0031a6d8-style label is not the raw file offset — subtract the
image base (commonly 0x100000) before using it in Unicorn or Frida.
libc++'s short-string-optimization (SSO) encoding. Reading a
std::string's bytes directly (instead of through its accessor method)
requires knowing the SSO layout — a flag bit distinguishes an
inline-short representation from a heap-allocated-long one. Get this
wrong and you'll read garbage that looks plausible enough to waste
real time debugging it as a logic error somewhere else entirely.
Working-directory-relative symlink commands, run from the wrong directory, silently destroy the file they meant to link to:
# If your cwd is ALREADY the target directory, this creates a
# self-referential broken symlink that overwrites the real file:
cd vendor/
ln -sf ../vendor/real_file.c vendor/real_file.c # BUG: cwd is already vendor/
If a build starts failing with "file not found" for a file you're sure exists, check whether it quietly became a broken symlink pointing at itself.
A literal */ inside a text comment about C-style comments closes
the enclosing block comment early:
/* This function does CkEcc::Sign*/Verify* calls of some kind. */
// ^^ this closes the comment HERE,
// and everything after becomes live code
Trivial to avoid once you're watching for it; a painful, confusing cascade of unrelated-looking parse errors if you're not.
Freeing a buffer before every pointer into it (not a copy of it) has been read is a classic use-after-free — and it can stay completely silent for a long time, right up until some unrelated later change happens to allocate something of similar size in the gap between the free and the last read. At that point it starts corrupting real output with no obvious connection to whatever change "caused" it. When adding a new allocation near existing free/read code, re-check every earlier pointer's lifetime, not just the new one's.
A wire-format tweak that "seems to work" can be coincidentally right, not actually right. A byte-level transform that produces a plausible (even correct-looking) result for one sample can simply have gotten lucky on limited data. Re-derive and confirm the mechanism live (§7) rather than trusting a transform just because its output looked reasonable.
11A generic checklist to run against a fresh target
- Decompile the APK; locate the native entry point and confirm its exact signature and any pre/post-processing Java does around it (§3).
- Inventory the native binary: architecture, exported symbols, known third-party crypto library fingerprints, hardcoded constant/ ciphertext blobs, anti-tampering guards (§4).
- Use Ghidra to form hypotheses about candidate functions and offsets (§5) — plan to confirm every hypothesis dynamically, never stop at the pseudocode.
- Per question, decide: "derive the algorithm so it runs with zero dependency on the original binary" (→ Unicorn, §6) or "observe what the real running app actually does" (→ Frida, §7) — default to the latter when in doubt (§1).
- For any opaque binary/TLV sub-format: get many real samples and diff them byte-by-byte before guessing at structure (§8).
- Once a sub-format looks like a known standard, validate it against an independent reference implementation of that standard (§9).
- Reimplement each verified piece in your target production language(s), checked byte-for-byte against the real app's own output on real inputs — not against your own emulator's output, which only proves internal consistency, not correctness against the real thing.
- Batch-test against as many real samples as you can gather before calling a piece "done" — a single passing test hides both format edge cases and diffing false positives (§8) that only show up at scale.
- Document as you go, in the order things actually happened — including the dead ends. A log that only records the final correct answer throws away exactly the information (what didn't work, and why) that saves the next session, or the next person, from repeating the same mistakes.