Systems · Ingeniería inversa

Ingeniería inversa de un pipeline
nativo de criptografía en Android

Una guía práctica — las herramientas, en el orden en que de verdad las usas, con comandos ejecutables y salida de ejemplo real en cada paso. Escrita a partir de dos investigaciones reales contra un binario ARM64 cerrado, sin documentación, sin vectores de prueba y sin cooperación del proveedor.

11 secciones ~26 min de lectura jadx Ghidra Unicorn Frida ARM64

Dos investigaciones reales contra la misma familia de app produjeron bitácoras largas y cronológicas de lo que intenté, en qué orden, incluidos los callejones sin salida. Esas bitácoras siguen siendo privadas. Este documento es la otra mitad: un recorrido que sí puedes seguir contra tu propio objetivo, construido con las mismas técnicas, con comandos ejecutables y salida de ejemplo real en cada paso — no nada más los principios detrás de ellos.

Nada de lo que hay aquí está atado a una app, un proveedor o una investigación en particular; así fue diseñado. Cada vez que un comando necesita un nombre real, uso un marcador genérico (target.apk, libtarget.so, com.example.app, Target_decode) — cambia esos por los nombres de tu app y lo demás debería transferirse directo. La primera vez, trabaja las secciones en orden: cada una produce un artefacto que la siguiente consume (el .so extraído → su tabla de símbolos → un offset de Ghidra → una dirección de hook para Frida), igual que se fueron dando las dos investigaciones originales.

Lo que vas a necesitar, antes de empezar:

# Decompilación de Java/Kotlin
brew install apktool
pip install jadx   # o bien: brew install jadx

# Herramientas para binarios nativos (normalmente ya vienen en macOS/Linux;
# las variantes de llvm también funcionan)
which nm objdump readelf strings

# Ghidra (GUI + el script headless analyzeHeadless)
brew install --cask ghidra

# Arnés de emulación con Unicorn
pip install unicorn lief cryptography

# Frida — cliente + servidor en el dispositivo (empata las versiones entre sí)
pip install frida-tools
# o, si tu frida-tools de Python está roto (ver §7):
npm install frida frida-compile frida-java-bridge

# Un emulador de Android rooteado donde correr Frida
brew install --cask android-studio   # para el AVD manager y adb

Si estás a punto de hacerle ingeniería inversa a una app de Android con un núcleo nativo (.so) de cripto/parseo, sin código fuente, sin vectores de prueba y sin cooperación del proveedor — esta es la guía.


1Filosofía, antes de cualquier herramienta

Dos reglas llevaron ambas investigaciones de "imposible" a "resuelto", y cada técnica de esta guía no es más que una implementación de una de las dos. Interiorízalas antes de tocar una sola herramienta.

Trata cada capa como una caja negra independiente, y no pases de una capa hasta que tenga un vector de prueba determinista. No intentas entender todo el pipeline de un jalón. Dale a cada etapa — el wrapper de Java/Kotlin, el punto de entrada nativo, cada ronda de cripto, cada campo de salida — su propio criterio de éxito: ¿mi reimplementación produce byte por byte la misma salida que produce la app real, para una entrada real? Si sí, deja de tocar esa etapa y pásate a la siguiente. Si no, no adivines — regresa a la captura en vivo (§7). Esta disciplina es lo que hace que un proyecto de ingeniería inversa converja en lugar de desparramarse en un montón infalsable de "yo creo que hace X".

Échate mano de la captura dinámica en vivo sobre la app real antes del análisis estático, no después. Esta es la lección de proceso más grande de ambas investigaciones, y vale la pena decirla sin rodeos: cada vez que la decisión fue "seguir viendo pseudocódigo de Ghidra" contra "mejor hookearlo en vivo y ver", ganó el hook en vivo — muchas veces en minutos, después de que la ruta del pseudocódigo ya se había comido horas. El análisis estático sirve para encontrar dónde buscar (una dirección, un símbolo, una función candidata). La captura dinámica sirve para encontrar qué pasa realmente ahí — valores reales de argumentos, flujo de control real, resultados intermedios reales. Vas a sentir el jalón de "nada más leo el desensamblado una vez más" cuando estés atorado — ese jalón casi siempre está equivocado. Mejor ve y hookéalo.


2El toolkit, y cuándo se gana su lugar cada herramienta

Herramienta Para qué sirve Cuándo echarle mano
apktool / jadx Decompilar la carcasa de Java/Kotlin a código (legible, aunque esté ofuscado) Siempre, primero — aquí es donde encuentras el punto de entrada nativo y su firma exacta
nm -D / objdump -T sobre el .so Enumerar los símbolos exportados Antes de escribir un solo hook de Frida — hookea por nombre de símbolo, nunca por offset crudo, siempre que el símbolo esté exportado (§5, §7)
Ghidra (GUI o headless) Decompilar funciones nativas específicas a pseudocódigo Para encontrar offsets/funciones candidatas y formular una hipótesis — no para responder "qué valor tiene X en tiempo de ejecución"
Unicorn (emulación ARM64) Correr el código nativo por su cuenta, fuera de la JVM, interceptando cada acceso a memoria y cada llamada externa Cuando necesitas derivar un algoritmo (para reimplementarlo sin ninguna dependencia del binario original) y no nada más observar una corrida de él
Frida sobre un dispositivo/emulador rooteado Hookear una instancia viva, realmente corriendo, de la app real La opción por defecto para "qué hace/devuelve/recibe esta función en realidad" — ver §1
Diffing entre muestras (un script que escribes tú) Separar los bytes fijos/estructurales de los datos genuinamente variables en un formato binario opaco Cada vez que estés viendo un blob de bytes preguntándote "¿esto es constante o cambia por entrada?" — no adivines, consigue más muestras y saca el diff
Un validador independiente conforme al estándar Contrastar tu propio parseo contra la implementación de alguien más del mismo spec Cuando creas que ya resolviste un sub-formato estándar (no propietario), antes de darlo por terminado

El resto de la guía recorre estos siete renglones en el orden en que de verdad los vas a usar.


3Reconocimiento del APK

Empieza aquí, siempre, antes de tocar el binario nativo.

Recorrido:

# Desempaqueta recursos/manifest, y obtén código Java/Kotlin más o menos legible.
# Los ofuscadores renombran símbolos, no el flujo de control — jadx te sigue
# dando algo que puedes seguir.
apktool d target.apk -o target_unpacked
jadx -d target_jadx target.apk

# Encuentra el puente JNI: qué clase declara métodos nativos, y cuál es
# el nombre de la librería que se carga.
grep -rn "native " target_jadx/sources --include=*.java | head -20
grep -rn "loadLibrary" target_jadx/sources --include=*.java

Salida típica a la que vale la pena ponerle atención:

// en algún lugar de target_jadx/sources/.../Bridge.java
static { System.loadLibrary("target"); }        // → libtarget.so es tu binario

private final native Bitmap  gImg(String data);
private final native String  gTxt(String data);
private final native String  decode(Activity act, byte[] data);   // ← normalmente "el corazón"

De aquí en adelante:

  1. Lee todos los helpers chiquitos alrededor del punto de entrada principal, no nada más el principal. Los getters, las aserciones de tamaño y los helpers de preprocesamiento seguido codifican detalles del formato de cable en puro Java — recorte de encabezados, restricciones de tamaño, orden de concatenación de buffers — que si no tendrías que redescubrir a prueba y error contra el código nativo. Si ves algo como System.arraycopy(bArr, 2, bArr3, ...) justo antes de la llamada nativa, eso es un "quítale los primeros 2 bytes" documentado que ya no tienes que sacar por ingeniería inversa.
  2. Anota la firma exacta del método nativo, no nada más su nombre. Si estás viendo una segunda versión de una app que ya conocías y la firma cambió (por ejemplo, decode(Activity, byte[]) se volvió decode(Activity, List<byte[]>, boolean)), esa es una señal fuerte de que también cambió la convención de llamada en la frontera JNI — confirma el nuevo marshalling de argumentos con Ghidra (§5) antes de asumir que un arnés viejo sigue sirviendo.
  3. Busca con grep las constantes del lado de Java contra la salida que andas persiguiendo. Un hit como JSON_TAG_EMAIL o FIELD_NAME_X te regala el esquema de salida — ya no tienes que adivinar el conjunto de campos, nada más cómo se deriva cada uno.

Al terminar esta sección deberías tener: el nombre y la firma exactos del punto de entrada, el pre/post-procesamiento que Java hace a su alrededor, y libtarget.so ya extraído y listo para inventariar.


4Triage del binario nativo

Antes de desensamblar nada, arma un inventario. Es barato y cambia lo que vas a buscar en todos los pasos siguientes.

Recorrido:

file libtarget.so
# ELF 64-bit LSB shared object, ARM aarch64, ...

nm -D libtarget.so | wc -l        # cuántos símbolos exportados hay
nm -D libtarget.so | grep -iE "crypt|rsa|aes|ecc|ecdsa|hash|cipher"

Salida de ejemplo que vale la pena parar a leer con calma:

0000000000257c1c T _ZN13VendorSdkCert11FingerprintEPKcS1_
0000000000252dc0 T EVP_DecryptUpdate

Un hit así es lo más valioso que te puede dar una primera pasada: un símbolo de una librería de criptografía de terceros, conocida y con nombre — uno de C++ manglado de un SDK comercial, uno de C plano como el EVP_DecryptUpdate de OpenSSL, o los equivalentes de BoringSSL/libsodium. Ya sabes la forma de la cripto — qué primitivas, la API de quién — antes de conocer una sola llave o un solo byte concreto. c++filt desmanglea un símbolo de C++ si te topas con uno:

echo '_ZN13VendorSdkCert11FingerprintEPKcS1_' | c++filt
# VendorSdkCert::Fingerprint(char const*, char const*)

Dos cosas más que conviene revisar en esta misma pasada:

  • Blobs en .rodata con pinta de ciphertext hardcodeado. Corridas largas de bytes con pinta de hex en offsets fijos, sobre todo en pares emparejados, vale la pena marcarlas antes de saber qué descifran:

    objdump -h libtarget.so | grep rodata
    # localiza el offset y el tamaño de la sección .rodata en el archivo, y luego
    # revisa a ojo las corridas largas de alta entropía dentro de ella — un patrón
    # repetido de pares (llave, blob) es una señal fuerte de una cadena de
    # desenvolvimiento de llaves por capas, antes de que hayas trazado una
    # sola ronda.
    
  • Nombres de funciones de anti-tampering / licenciamientoisSafeEnvironment, checkLicense, cualquier cosa que suene a verificación de integridad. Lo más probable es que después tengas que parchearlas o stubearlas para emular (§6); sale más barato marcarlas ahora que descubrirlas por un abort() misterioso y prematuro tres secciones más adelante.


5Análisis estático con Ghidra — y su límite real

Ghidra sirve para dos cosas: encontrar funciones/offsets candidatos, y sacar una primera hipótesis en pseudocódigo de lo que hace una función. No es confiable para responder "qué valor pasa por aquí en una corrida real" — para eso siempre necesitas la §7.

Dos filos que conviene conocer antes de cortarte:

Las etiquetas de Ghidra traen un sesgo de image base. Una etiqueta como FUN_002c19a0 ya incluye la image base que Ghidra asumió — que para archivos .so de Android cargados así suele ser 0x100000. El offset crudo relativo al archivo .so, que es el que necesitas para un arnés de Unicorn o para una llamada module.base.add() de Frida, es el número de la etiqueta menos ese sesgo:

Etiqueta de Ghidra:        FUN_002c19a0
Image base:                 0x100000
Offset crudo en el .so:     0x002c19a0 - 0x00100000 = 0x1c19a0   ← usa ESTE

Si te equivocas aquí, te salen errores confusos de "unmapped memory access" o de "le pegué a la función equivocada" que parecen bug de lógica pero en realidad son un bug de unidades. Si un hook de Frida o un breakpoint de Unicorn cae en un lugar sin sentido, revisa esta aritmética antes que cualquier otra cosa.

Automatiza el decompilador headless una vez y reúsalo sin parar. Abrir la GUI, navegar a una función y esperar a que decompile — repetido a mano por docenas de funciones candidatas — es el desperdicio de tiempo más evitable de todo el proceso. Un GhidraScript chiquito de post-script lo arregla en una sola sentada:

// DecompileTargets.java — un post-script headless de Ghidra
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 };  // offsets crudos del .so
        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 hay función en " + addr); continue; }
                DecompileResults res = ifc.decompileFunction(f, 60, monitor);
                out.println("=== " + labels[i] + " @ " + addr + " ===");
                out.println(res.getDecompiledFunction().getC());
            }
        }
    }
}

Córrelo sin interacción:

analyzeHeadless /ruta/al/proyecto NombreProyecto \
  -process libtarget.so \
  -scriptPath . -postScript DecompileTargets.java

Eso convierte "el baile de la GUI × 40 funciones" en un solo comando que puedes volver a correr cada vez que tengas una lista nueva de direcciones que valga la pena leer.


6Armar un arnés de emulación con Unicorn

Échale mano a esto cuando necesites derivar un algoritmo lo suficientemente bien como para reimplementarlo sin ninguna dependencia en tiempo de ejecución del binario original ni del dispositivo — no nada más observar una ejecución. La recompensa que lo hace valer la pena: una vez que derivaste por completo un algoritmo de esta forma, tu reimplementación de producción corre en milisegundos, sin .so, sin Unicorn y sin dispositivo. Esa portabilidad es la meta de verdad — el emulador es un medio para llegar ahí, no el entregable.

Un esqueleto mínimo para adaptar, que muestra la arquitectura de la que se construye todo arnés más grande de este estilo:

#!/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. Mapea cada segmento PT_LOAD en 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. Regiones dedicadas para todo lo que el código emulado va a tocar.
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 — la trampa de "ya regresaste"

# 3+4. Cada entrada externa de la GOT se vuelve una trampa BRK despachada en Python.
stub_hooks = {}  # addr -> (nombre, handler de python)
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)
    # ... reserva por bump desde HEAP, escribe el resultado en X0, salta a LR ...
    print(f"  >> malloc({size})")

register_stub("malloc", h_malloc)
# ... recorre binary.pltgot_relocations y reescribe cada entrada externa a un
#     stub de esta forma, una llamada a register_stub() por nombre de símbolo ...

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"interrupción sin manejar en 0x{pc:x}")
        uc.emu_stop()

uc.hook_add(UC_HOOK_INTR, on_interrupt)

# 5. Llama al punto de entrada real.
entry = BASE + 0x1a203c  # de Ghidra, ya corregido el sesgo (§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)

De ahí, agrégale por capas lo que tu objetivo en particular necesite:

  • Un entorno JNI falso, si la firma que estás llamando recibe algo más rico que primitivos (un List, un String, un objeto Activity): una vtable hecha a mano en memoria emulada, con handlers para las llamadas (GetMethodID, CallObjectMethod, GetArrayLength, GetByteArrayRegion, ...) que el código objetivo realmente invoque. Encuentra los offsets exactos de vtable que usa este binario en particular decompilando el punto de entrada JNI en Ghidra (§5) — no siempre son la numeración canónica de jni.h, sobre todo entre versiones distintas del NDK o del compilador.
  • pthread_create síncrono. Un emulador de un solo hilo que nada más devuelve 0 sin correr nunca el cuerpo del hilo va a colgar cualquier código que esté esperando una bandera que ese "hilo" debía poner. Solución: corre la rutina de arranque del hilo hasta el final, de forma síncrona, ahí mismo dentro de tu handler de pthread_create, sobre un stack de trabajo chiquito y dedicado.
  • Bypasses de licencia / anti-tampering (los que marcaste en §4): un parche NOP al momento de cargar, una bandera en BSS forzada al valor de "pasó", o — para una verificación que lee un blob real de licencia o configuración firmado — darle el archivo real, extraído una vez del paquete de la app y cacheado localmente. No intentes falsificar una firma; nada más dale a la verificación el asset real que espera.

Un encuadre que conviene tener en mente todo el tiempo mientras armas esto: es una herramienta de investigación, no necesariamente un camino a producción. Si tu reimplementación objetivo no necesita correr el .so original en tiempo de ejecución (porque ya derivaste por completo su algoritmo — que es justo el punto de esta sección), está perfecto que el emulador se quede como una ayuda de depuración a la que recurres de vez en cuando, que nunca portas a tu lenguaje de producción y que nunca necesita llegar a una salida limpia "sin errores" para que des por terminado el trabajo sobre el algoritmo. Lograr que las llamadas reales de cripto/parseo se disparen y observar sus entradas y salidas reales es el resultado valioso; que el programa emulado corra hasta el final es un extra, no la vara.


7Frida sobre un dispositivo/emulador rooteado — el caballito de batalla

Si nada más vas a agarrar soltura con una técnica dinámica, que sea esta.

Configúralo una vez:

# Arranca (o vuelve a lanzar) un AVD rooteado — es barato tirarlo y recrearlo.
emulator -avd my_rooted_avd &
adb wait-for-device
adb root

# Sube y corre frida-server, con la versión empatada a la de tu cliente.
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 &"

# Prueba de humo:
frida-ps -U | head

Si frida-ps (el de Python) truena con algo como ImportError: cannot import name 'NotRequired' from 'typing', eso es un desajuste entre tu versión de Python y la del paquete frida, no un problema del dispositivo — cámbiate al paquete frida de Node.js como driver (mismo protocolo de cable, distinto lenguaje anfitrión):

npm install frida frida-compile frida-java-bridge
// run_frida.js — un driver mínimo de Node
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));
})();

Escribe el hook más chiquito que responda tu pregunta, empaquétalo y córrelo:

// hook_example.js — hookea por nombre de símbolo EXPORTADO, no por offset crudo
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("se llamó a HashBytesENC"); },
    onLeave(retval) { console.log("-> regresó"); },
  });
});
frida-compile hook_example.js -o hook_bundled.js   # resuelve frida-java-bridge
node run_frida.js hook_bundled.js

Un puñado de reglas ganadas a pulso que hacen la diferencia entre "esto funciona" y "por qué no se engancha / no encuentra nada":

  1. Hookea por nombre de símbolo exportado siempre que el símbolo esté exportado (verifícalo con nm -D, §4) — Module.findExportByName, no un offset crudo. Hookear por nombre de símbolo sobrevive a ASLR y a builds distintos; los offsets crudos (con la corrección de sesgo de Ghidra de la §5, cuando de verdad necesitas uno para una función interna no exportada) son frágiles y deberían ser el último recurso.
  2. Maneja el punto de entrada real de la app, no un sustituto sintético. Si la función que estás hookeando espera datos que se fueron armando con varias llamadas previas a métodos de un objeto vivo, reproduce esa secuencia exactaJava.choose() para encontrar una instancia viva de Activity, reflexión para setear campos privados, y luego llamar al método real — en lugar de armar los argumentos a mano y llamar directo a la función nativa. La secuencia real ejercita validaciones que todavía no sabes que existen; un atajo se arriesga a saltárselas en silencio.
  3. Usa Thread.backtrace() cuando necesites saber quién llama a una función, no nada más qué hace. Si un trace de llamadas basado en Stalker misteriosamente produce cero eventos aunque los hooks normales de Interceptor.attach sí se disparan en el mismo hilo, no te pongas a depurar el motor de tracing — mejor cáete a un backtrace simple capturado justo en el punto de interés (por ejemplo, dentro de un hook de memcpy cuyo buffer de origen empata con un patrón de bytes que estás siguiendo):

    Interceptor.attach(Module.getExportByName("libc.so", "memcpy"), {
      onEnter(args) {
        // ... revisa args[1]/args[2] contra tu patrón objetivo, y entonces:
        console.log(Thread.backtrace(this.context, Backtracer.ACCURATE)
          .map(a => DebugSymbol.fromAddress(a).toString()).join("\n"));
      }
    });
    
  4. Un solo trace sin filtrar de "qué se consulta, y con qué frecuencia" es un diagnóstico de propósito general poderosísimo. Si el objetivo guarda los campos parseados en algún tipo de estructura llave-valor (un mapa, un helper de "dame o crea el slot por nombre"), hookea ese único accesor genérico y loguea cada llave con la que lo llaman, a lo largo de una corrida real de punta a punta, sin filtrar nada:

    const seen = {};
    Interceptor.attach(genericLookupAddr, {
      onEnter(args) {
        const key = args[1].readCString();
        seen[key] = (seen[key] || 0) + 1;
        console.log(`consulta: "${key}" (${seen[key]}x hasta ahora)`);
      }
    });
    

    La tabla resultante de conteo de llamadas por llave te dice, gratis, qué campos son escritos-y-nunca-leídos (están presentes en los datos parseados, pero la propia lógica de la app nunca los vuelve a leer — no se usan para nada que el usuario vea ni que afecte una operación normal; conteo de 1) contra escritos-y-luego-leídos (sí se consumen aguas abajo; conteo de 2 o más). Este único hook fue como dos preguntas completamente distintas — "¿esta firma alguna vez se verifica de verdad?" y "¿este otro campo se vuelve a tocar después de parsearlo?" — obtuvieron cada una una respuesta definitiva y respaldada por evidencia, en lugar de una corazonada.


8Ingeniería inversa de un formato binario/TLV opaco: saca el diff, no adivines

Esta es la técnica más efectiva de cualquiera de las dos investigaciones para romper un formato sin spec público.

Paso 1 — consigue muchas muestras reales, no una. Una sola muestra no puede distinguir entre "este byte siempre vale esto" y "este byte resulta que vale esto en el único caso que tengo". Apunta a una docena o más de entradas reales independientes antes de confiar en cualquier conclusión sobre la estructura.

Paso 2 — alinea y saca el diff, no lo veas a ojo. Un script corto lo hace mucho más confiablemente que estar comparando volcados hexadecimales lado a lado:

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}: VARÍA  {[hex(v) for v in sorted(values)]}")
    # posiciones constantes: sin salida, o imprímelas con una bandera `--verbose`

Córrelo: python3 diff_samples.py sample1.bin sample2.bin sample3.bin ...

Las posiciones que no imprimen nada (constantes en todas las muestras) son estructurales — números mágicos, etiquetas de versión, padding fijo, sellos del proveedor. Las que varían son datos reales por entrada, que vale la pena investigar. De aquí en adelante:

  • Busca subcadenas reconocibles, no nada más estadísticas. Decodifica como ASCII una corrida constante antes de descartarla como ruido — el nombre de un proveedor o el número mágico de un formato no relacionado apareciendo dentro de tu formato objetivo es una pista gratis y enorme. Cuatro bytes imprimibles en medio de un mar de binario siempre merecen una segunda mirada.
  • Un campo constante en todas las muestras, sentado justo donde el spec dice que van datos medidos, es una señal fuerte de que el contenedor se está reutilizando para otra cosa — por ejemplo, un registro de relleno con forma fija (coordenadas constantes, banderas constantes) donde nada más una o dos de sus posiciones de byte realmente llevan datos por entrada, contrabandeados por una ranura que un parser genérico ya sabe saltarse sin quejarse.
  • Verifica contra la realidad un byte que sospechas que es prefijo de longitud; no nada más teorices al respecto:

    for i, s in enumerate(samples):
        declared = s[LENGTH_OFFSET]
        actual_remaining = len(s) - (LENGTH_OFFSET + 1)
        assert declared == actual_remaining, f"muestra {i}: {declared} != {actual_remaining}"
    

    Si esto se cumple sin una sola excepción en todas las muestras, encontraste un campo real que funciona — aunque esté en otra posición u otro encuadre que el que definía el estándar que esperabas. - Mide la entropía de todo lo que todavía no puedas caracterizar:

    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())
    

    Un blob al final cerca del máximo teórico (8 bits por byte) se lee como un hash, un MAC o un payload cifrado/empaquetado densamente; un blob con agrupamientos o repeticiones visibles se lee como datos estructurados que todavía no decodificas. Esto no te dice qué son los datos, pero sí te dice qué tipo de cosa seguir buscando — barato de calcular antes de gastar más tiempo adivinando una estructura que probablemente no está ahí. - No te detengas en "dónde terminan los datos" sin revisar si un contenedor que los envuelve ya responde eso de forma más confiable que los propios campos internos de los datos. La longitud que un campo reporta de sí mismo puede estar desactualizada o simplemente mal; si esos datos viven dentro de un formato contenedor genérico que tu proyecto ya parsea por otras razones (TLV, registros con prefijo de longitud, ...), la longitud declarada de ese encuadre exterior es muy seguido el límite real y autoritativo — revísala antes de confiar en un campo interno que dice lo mismo.


9Valida contra una herramienta independiente, no nada más contra ti mismo

Cuando creas que ya resolviste una pieza de un sub-formato estándar (no propietario), busca una implementación de referencia que no hayas escrito tú y pásale tus propios datos extraídos.

Antes de instalar nada, mira qué estás a punto de instalar:

pip download some-promising-package --no-deps -d /tmp/check
python3 -m zipfile -l /tmp/check/*.whl        # ¿qué trae adentro en realidad?
unzip -p /tmp/check/*.whl some_promising_package/__init__.py | head -40

Que el nombre de un paquete suene plausible no es evidencia de que sea relevante — un nombre que empata con el acrónimo de tu formato puede perfectamente pertenecer a una librería sin ninguna relación que nada más comparte la abreviatura. Esta revisión cuesta una descarga y un minuto de lectura; saltártela cuesta una instalación, una corrida de pruebas y el tiempo de darte cuenta de que la herramienta nunca aplicaba.

De ahí en adelante:

  • Prefiere una implementación de referencia oficial o autoritativa — el tooling de referencia de dominio público del propio organismo de estandarización vale más que un paquete de terceros con el mismo nombre que encontraste buscando.
  • Cuando la herramienta de referencia falla parcialmente con tus datos, ese punto de falla es información, no un callejón sin salida. Te dice exactamente dónde tus datos dejan de parecerse al estándar y empiezan a ser específicos del proveedor — que muchas veces es justo la frontera que andabas buscando.
  • Que una herramienta de referencia coincida contigo en las partes que puede parsear es confirmación real e independiente — prueba de que tu parseo no es nada más autoconsistente por construcción (es decir, que embona con tus propias suposiciones posiblemente equivocadas), y vale la pena hacerlo incluso para partes de las que ya estás seguro.

10Bugs que conviene conocer de antemano

Bugs reales, cada uno cobró tiempo real de depuración en alguna de las investigaciones — reconócelos más rápido de lo que yo lo hice.

El sesgo de image base de Ghidra (§5, repetido porque muerde dos veces): una etiqueta tipo FUN_0031a6d8 no es el offset crudo del archivo — réstale la image base (típicamente 0x100000) antes de usarla en Unicorn o en Frida.

La codificación SSO (short string optimization) de libc++. Leer los bytes de un std::string directamente (en lugar de a través de su método accesor) requiere conocer el layout de SSO — un bit de bandera distingue la representación corta en línea de la larga alojada en el heap. Si le atinas mal, vas a leer basura que parece lo bastante plausible como para que pierdas tiempo real depurándola como si fuera un error de lógica en otro lado completamente distinto.

Los comandos de symlink relativos al directorio de trabajo, corridos desde el directorio equivocado, destruyen en silencio el archivo que querían enlazar:

# Si tu cwd YA es el directorio objetivo, esto crea un symlink roto
# autorreferencial que sobrescribe el archivo real:
cd vendor/
ln -sf ../vendor/real_file.c vendor/real_file.c   # BUG: el cwd ya es vendor/

Si un build empieza a fallar con "file not found" por un archivo que estás seguro de que existe, revisa si calladito se convirtió en un symlink roto que apunta a sí mismo.

Un */ literal dentro de un comentario de texto que habla de comentarios estilo C cierra el bloque de comentario antes de tiempo:

/* Esta función hace llamadas del tipo CkEcc::Sign*/Verify* o algo así. */
                          //    ^^ esto cierra el comentario AQUÍ,
                          //       y todo lo que sigue se vuelve código vivo

Trivial de evitar en cuanto lo traes en el radar; una cascada dolorosa y confusa de errores de parseo que no parecen relacionados si no.

Liberar un buffer antes de que se hayan leído todos los punteros hacia dentro de él (no copias de él) es un use-after-free clásico — y puede quedarse completamente en silencio durante mucho tiempo, hasta que algún cambio posterior sin relación resulta que reserva algo de tamaño parecido en el hueco entre el free y la última lectura. En ese momento empieza a corromper salida real sin conexión obvia con el cambio que "lo causó". Cuando agregues una reserva nueva cerca de código existente de free/lectura, revisa de nuevo el tiempo de vida de cada puntero anterior, no nada más el del nuevo.

Un ajuste al formato de cable que "parece funcionar" puede estar bien por coincidencia, no bien de verdad. Una transformación a nivel de bytes que produce un resultado plausible (incluso que se ve correcto) para una muestra puede simplemente haber tenido suerte con datos limitados. Vuelve a derivar y confirma el mecanismo en vivo (§7) en lugar de confiar en una transformación nada más porque su salida se veía razonable.


11Un checklist genérico para correr contra un objetivo nuevo

  1. Decompila el APK; localiza el punto de entrada nativo y confirma su firma exacta y cualquier pre/post-procesamiento que Java haga a su alrededor (§3).
  2. Inventaría el binario nativo: arquitectura, símbolos exportados, huellas de librerías de cripto de terceros conocidas, blobs constantes o de ciphertext hardcodeados, guardias de anti-tampering (§4).
  3. Usa Ghidra para formular hipótesis sobre funciones y offsets candidatos (§5) — planea confirmar cada hipótesis dinámicamente, no te quedes nunca en el pseudocódigo.
  4. Por cada pregunta, decide: "derivar el algoritmo para que corra sin ninguna dependencia del binario original" (→ Unicorn, §6) u "observar qué hace realmente la app corriendo" (→ Frida, §7) — ante la duda, quédate con lo segundo (§1).
  5. Para cualquier sub-formato binario/TLV opaco: consigue muchas muestras reales y sácales el diff byte por byte antes de adivinar la estructura (§8).
  6. En cuanto un sub-formato se parezca a un estándar conocido, valídalo contra una implementación de referencia independiente de ese estándar (§9).
  7. Reimplementa cada pieza verificada en tu(s) lenguaje(s) de producción, contrastada byte por byte contra la salida de la app real con entradas reales — no contra la salida de tu propio emulador, que nada más prueba consistencia interna, no corrección contra la cosa real.
  8. Prueba en lote contra tantas muestras reales como puedas juntar antes de dar una pieza por "terminada" — una sola prueba que pasa esconde tanto casos borde del formato como falsos positivos del diffing (§8) que solo aparecen a escala.
  9. Documenta sobre la marcha, en el orden en que de verdad pasaron las cosas — incluidos los callejones sin salida. Una bitácora que solo registra la respuesta final correcta tira justo la información (qué no funcionó, y por qué) que le ahorra a la siguiente sesión, o a la siguiente persona, repetir los mismos errores.