Systems · Reverse Engineering

Reverse-Engineering an
Android Native Crypto Pipeline

A practical guide — the tools, in the order you actually reach for them, with runnable commands and real example output at every step. Written from two real investigations against a closed ARM64 binary with no documentation, no test vectors, and no vendor cooperation.

11 sections ~24 min read jadx Ghidra Unicorn Frida ARM64

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:

  1. 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.
  2. 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[]) became decode(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.
  3. Grep Java-side constants against the output you're chasing. A hit like JSON_TAG_EMAIL or FIELD_NAME_X tells 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 namesisSafeEnvironment, 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 early abort() 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, a String, an Activity object): 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 canonical jni.h numbering, especially across NDK/compiler versions.
  • Synchronous pthread_create. A single-threaded emulator that just returns 0 without 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 your pthread_create handler, 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":

  1. 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.
  2. 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 live Activity instance, 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.
  3. 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 ordinary Interceptor.attach hooks 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 a memcpy hook 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"));
      }
    });
    
  4. 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

  1. Decompile the APK; locate the native entry point and confirm its exact signature and any pre/post-processing Java does around it (§3).
  2. Inventory the native binary: architecture, exported symbols, known third-party crypto library fingerprints, hardcoded constant/ ciphertext blobs, anti-tampering guards (§4).
  3. Use Ghidra to form hypotheses about candidate functions and offsets (§5) — plan to confirm every hypothesis dynamically, never stop at the pseudocode.
  4. 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).
  5. For any opaque binary/TLV sub-format: get many real samples and diff them byte-by-byte before guessing at structure (§8).
  6. Once a sub-format looks like a known standard, validate it against an independent reference implementation of that standard (§9).
  7. 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.
  8. 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.
  9. 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.