← Voltar ao Blog
· 45 min leitura ·

Quebrando 9 camadas de proteção DexProtector: Engenharia reversa real de um banking app Android

Análise avançada de engenharia reversa de um mobile banking protegido por DexProtector com 9 camadas: DEX criptografado, ~2000 strings enc!5016, JNI dispatch com 148 overloads, RegisterNatives dinâmico, stream cipher TEA-like, emulação ARM64 com Unicorn, dump de .so memory-only, bypass de ALICE anti-fraud — tudo documentado com código real e resultados.

#engenharia-reversa#frida#android#jni#dexprotector#unicorn#arm64#capstone#banking#segurança#cibersegurança#pentesting
Compartilhar

Este não é um tutorial introdutório de Frida. Aqui eu documento o processo real de análise de um mobile banking corporativo protegido por DexProtector — com DEX criptografado, ~1.978 strings ofuscadas, JNI dispatch com 148 overloads nativos, cipher stream customizado, telemetria anti-fraud e bibliotecas carregadas apenas em memória.

O alvo é um aplicativo de cartões empresariais de um grande banco brasileiro — um dos apps mais fortemente protegidos do país. Todos os nomes de pacotes, classes, URLs e endpoints foram anonimizados neste artigo, mas a estrutura técnica, os mecanismos de proteção e os resultados são reais. O objetivo é demonstrar as técnicas e ferramentas, não expor o app específico.

Tudo que descrevo aqui foi executado, testado e documentado durante pesquisa de segurança em ambiente controlado.

⚠️ Aviso Legal: Todo o conteúdo deste artigo é publicado exclusivamente para fins educacionais, acadêmicos e de pesquisa em segurança da informação. Nenhuma técnica aqui descrita foi utilizada para acessar dados reais de terceiros, comprometer sistemas em produção ou causar qualquer tipo de dano. A análise foi conduzida em ambiente controlado e isolado, sobre aplicação instalada em dispositivo próprio do autor, sem interação com servidores reais. Nomes de classes, bibliotecas e endpoints foram anonimizados. O autor não incentiva, apoia ou se responsabiliza pelo uso indevido destas informações. A engenharia reversa para fins de estudo e pesquisa de segurança é amparada por legislações como o DMCA §1201(j) (EUA), Diretiva 2009/24/CE Art. 6 (UE) e pelo princípio de responsible disclosure.


O alvo: modelo de proteção em 9 camadas

Antes de hookear qualquer coisa, é fundamental mapear a superfície de ataque. O app usa DexProtector (da empresa Licelus) como proteção principal — um dos sistemas de ofuscação e proteção de runtime mais avançados do mercado mobile.

Após decompilação com JADX (análise estática do bytecode Java/Smali) e disassembly ARM64 com Capstone (análise estática dos binários nativos .so), identifiquei 9 camadas de proteção sobrepostas:

CamadaProteçãoMecanismoO que faz
1Certificate Pinning (APK)SHA-256 do cert verificado em m() antes de qualquer initImpede análise de tráfego com proxy
2Native BootstrapSystem.loadLibrary("dpboot")nJa() → descriptografa classes.dex.datCódigo Java real fica oculto até runtime
3S-Box Key DerivationFtfFcvlCy() → dupla substituição S-Box → chave 32 bytes → zhacB()Chaves derivadas dificultam extração estática
4Native App LibrarySystem.loadLibrary("alice") → integrity check → RegisterNativesRegistra métodos nativos sem naming convention
5DEX Encryptionclasses.dex.dat (7.2 MB) — TODO o código real do app, criptografadoBytecode Java inacessível por decompilação normal
6String Encryption~1.978 strings enc!5016... descriptografadas via JNI s()URLs, tokens, chaves — tudo criptografado
7JNI Dispatch148 overloads de LibBankApplication.i(int, ...)Toda comunicação com server via dispatch nativo
8Certificate Pinning (Network)SHA-256 pins em network-security-config.xmlDupla camada de pin: APK + rede
9Process IsolationProcessos separados :p63ce7f... e :p72e6f... com fast-pathIsola processos críticos da memória principal

Por que 9 camadas? Proteção defense-in-depth: mesmo que o atacante quebre uma camada, as demais continuam ativas. É como uma cebola criptográfica — cada camada protege as interiores.


Pré-requisitos: Instalação do ferramental completo

Antes de qualquer análise, o ambiente precisa estar montado. Aqui está tudo que uso, com instruções de instalação:

Python 3.12 + pip (base de tudo)

# Ubuntu/Debian
sudo apt update && sudo apt install python3 python3-pip python3-venv -y

# Cria ambiente isolado para a análise
python3 -m venv ~/re-env
source ~/re-env/bin/activate

Capstone — Disassembler multi-arquitetura

Capstone é o disassembler que uso para análise estática de binários ARM64. Suporta x86, ARM, ARM64, MIPS, PowerPC, SPARC e mais. É a engine por trás de ferramentas como radare2, Ghidra plugins e Binary Ninja.

Por que Capstone e não objdump? Porque Capstone dá acesso programático aos operandos — posso resolver ADRP+ADD automaticamente, ler immediates, e cruzar referências de string no código Python. Com objdump, tudo seria parsing de texto.

# Instalação via pip (inclui bindings Python + engine C)
pip install capstone

# Verificar instalação
python3 -c "from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM; print('Capstone OK:', Cs(CS_ARCH_ARM64, CS_MODE_ARM))"

Teste rápido — disassemblar bytes ARM64 arbitrários:

from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM

# Inicializa disassembler para ARM64 (AArch64)
md = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
md.detail = True  # Habilita acesso detalhado aos operandos

# Bytes de exemplo: STP X29,X30,[SP,#-0x20]! (prólogo de função)
code = bytes([0xfd, 0x7b, 0xbe, 0xa9])
for insn in md.disasm(code, 0x1000):
    print(f"0x{insn.address:x}: {insn.mnemonic} {insn.op_str}")
    # Acessa operandos individuais
    for op in insn.operands:
        print(f"  operand type={op.type}, reg={op.reg}, imm={op.imm}")

Resultado:

0x1000: stp x29, x30, [sp, #-0x20]!
  operand type=1, reg=224, imm=0
  operand type=1, reg=225, imm=0
  operand type=4, reg=0, imm=0

Frida 16+ — Instrumentação dinâmica

# Server-side (no host)
pip install frida-tools frida

# Client-side (no dispositivo Android via ADB)
# Baixar frida-server para arm64:
# https://github.com/frida/frida/releases
adb push frida-server-16.x.x-android-arm64 /data/local/tmp/frida-server
adb shell "chmod 755 /data/local/tmp/frida-server"
adb shell "su -c /data/local/tmp/frida-server &"

# Verificar
frida-ps -U | head -5

Unicorn — Emulador de CPU

pip install unicorn
python3 -c "from unicorn import Uc, UC_ARCH_ARM64; print('Unicorn OK')"

JADX — Decompilador Java/Smali

# Download: https://github.com/skylot/jadx/releases
# Ou via package manager:
# Ubuntu: snap install jadx
# macOS: brew install jadx

jadx --version
# jadx-gui para interface gráfica

lief — Parser ELF/PE/MachO

pip install lief
python3 -c "import lief; print('lief', lief.__version__)"

Fase 1: Boot flow — o que acontece antes do onCreate()

A primeira coisa que fiz foi traçar o boot flow via análise estática do smali decompilado pelo JADX. Abri o APK no JADX-GUI e naveguei até a classe Application customizada. O que encontrei é que o app executa uma sequência de inicialização complexa antes mesmo do onCreate():

AppComponentFactory
  → ProtectedBankApplication.<clinit>()
      → new-array v0, 0x567 (1.383 bytes)
      → fill-array-data v0, :array_data_0
      → sput-object v0, Ej:[B          // array estático: material de chave

  → attachBaseContext(context):
      → System.loadLibrary("alice")     //  458 KB — JNI dispatch, crypto, MbedTLS
      → $f.a()                          //  integrity check do .apk
      → m()                             //  SHA-256 cert verification
      → System.loadLibrary("dpboot")    //  8.2 KB — DexProtector bootstrap
      → nJa()                           //  descriptografa classes.dex.dat (7.2 MB)

  → onCreate():
      → FtfFcvlCy()                    //  retorna 32 bytes raw key (nativo)
      → loop:                           //  derivação S-Box
          key[i] = SBOX_0[ SBOX_1[ raw[i] & 0xFF ] & 0xFF ]
      → zhacB(derived_key)              //  ativa proteção runtime

Por que isso importa? Porque todo o código Java real do app está criptografado no classes.dex.dat. O método nJa() descriptografa ele em memória — ou seja, se você simplesmente decompilar o APK, só vai encontrar stubs e wrappers. O código de negócio real não existe no arquivo APK.

O array Ej de 1.383 bytes (0x567) é inicializado em <clinit>() — o construtor estático da classe, executado pelo ClassLoader antes de qualquer método. Esse array é high-entropy (distribuição uniforme de bytes), que é a assinatura de material criptográfico. Extraí ele do fill-array-data no smali:

# analyze_smali_enc.py — extração do array Ej do bytecode smali
ej_bytes = []
with open("ProtectedBankApplication.smali") as f:
    in_array_data = False
    for line in f:
        if "fill-array-data" in line:
            in_array_data = True
        elif in_array_data and line.strip().startswith("0x"):
            ej_bytes.append(int(line.strip().rstrip("t"), 16))
        elif in_array_data and ".end" in line:
            in_array_data = False

print(f"Array Ej: {len(ej_bytes)} bytes")
print(f"Primeiros 16: {bytes(ej_bytes[:16]).hex()}")
print(f"Entropia: {len(set(ej_bytes))} valores únicos de 256 possíveis")

Resultado:

Array Ej: 1383 bytes
Primeiros 16: e84bba3e9d15c742f081637a2c8db1a6
Entropia: 248 valores únicos de 256 possíveis

248/256 valores únicos confirma — é material criptográfico, não dados estruturados.


Fase 2: Mapeando bibliotecas nativas com análise ELF

O app carrega 7 bibliotecas nativas .so. A análise de cada uma revela seu papel:

BibliotecaTamanhoFunção
libalice.so458 KBLib principal: JNI dispatch, crypto, device fingerprint, MbedTLS, Google Breakpad
libdexprotector.so356 KBRuntime DexProtector: DEX/strings decrypt (0 imports — totalmente self-contained)
libdpboot.so8.2 KBBootstrap DexProtector — descriptografa classes.dex.dat
libde17df.so52 KBMódulo auxiliar DexProtector
libiproov-*.so38 KBiProov biometric (face liveness detection)
libandroidx.graphics.path.so9.9 KBAndroidX graphics helper

O detalhe mais importante: libdexprotector.so tem zero imports dinâmicos. Ela não importa nenhuma função — nem malloc, nem memcpy, nem write. Tudo é implementado internamente: alocação de memória via syscalls diretas (mmap via SVC #0), crypto customizado, e I/O via prctl. Isso torna análise estática muito mais difícil porque não há symbols para referência cruzada.

Parseando o ELF64 da libdexprotector.so

Para entender a estrutura interna sem depender de tools como readelf (que podem omitir informações), escrevi um parser ELF64 manual:

# analyze_libdexprotector.py — parser ELF64 manual
import struct

def parse_elf64(path):
    with open(path, 'rb') as f:
        data = f.read()

    # Verifica magic bytes: \x7fELF
    assert data[:4] == b'\x7fELF', "Not an ELF file"
    assert data[4] == 2, "Not ELF64"  # EI_CLASS = ELFCLASS64

    # ELF header fields
    e_shoff = struct.unpack_from('<Q', data, 0x28)[0]      # Section header offset
    e_shentsize = struct.unpack_from('<H', data, 0x3A)[0]  # Entry size
    e_shnum = struct.unpack_from('<H', data, 0x3C)[0]      # Number of sections
    e_shstrndx = struct.unpack_from('<H', data, 0x3E)[0]   # String table index

    # Lê section string table
    shstr_sh = data[e_shoff + e_shstrndx * e_shentsize:]
    shstr_offset = struct.unpack_from('<Q', shstr_sh, 24)[0]
    shstr_size = struct.unpack_from('<Q', shstr_sh, 32)[0]
    shstrtab = data[shstr_offset:shstr_offset + shstr_size]

    print(f"ELF64: {e_shnum} sections, shstrtab at 0x{shstr_offset:x}")

    for i in range(e_shnum):
        off = e_shoff + i * e_shentsize
        sh_name = struct.unpack_from('<I', data, off)[0]
        sh_addr = struct.unpack_from('<Q', data, off + 0x10)[0]
        sh_offset = struct.unpack_from('<Q', data, off + 0x18)[0]
        sh_size = struct.unpack_from('<Q', data, off + 0x20)[0]

        name = shstrtab[sh_name:].split(b'\x00')[0].decode()
        if sh_size > 0:
            print(f"  {name:20s}: offset 0x{sh_offset:05X}, "
                  f"size {sh_size:6d} bytes, vaddr 0x{sh_addr:05X}")

    return data

data = parse_elf64("lib/arm64-v8a/libdexprotector.so")

Resultado real da execução:

ELF64: 8 sections, shstrtab at 0x36a0
  .text               : offset 0x003D4, size  10024 bytes, vaddr 0x003D4
  .rodata             : offset 0x002C0, size    276 bytes, vaddr 0x002C0
  .data               : offset 0x02C00, size   2672 bytes, vaddr 0x0AC00
  .init_array         : offset 0x02B00, size      8 bytes, vaddr 0x06B00
  .bss                : offset 0x03670, size   1088 bytes, vaddr 0x0B670

Análise do resultado:

  • .text com apenas ~10 KB — muito pouco código para uma lib de 356 KB. A maior parte do binário é outra coisa…
  • .init_array — entry point do construtor, executado pelo dynamic linker antes do JNI_OnLoad
  • ~340 KB desaparecidos — onde está o resto?

A resposta está num 5º segmento LOAD que não aparece nas sections padrão:

# Verifica segmentos LOAD no program header
e_phoff = struct.unpack_from('<Q', data, 0x20)[0]
e_phentsize = struct.unpack_from('<H', data, 0x36)[0]
e_phnum = struct.unpack_from('<H', data, 0x38)[0]

for i in range(e_phnum):
    off = e_phoff + i * e_phentsize
    p_type = struct.unpack_from('<I', data, off)[0]
    p_offset = struct.unpack_from('<Q', data, off + 0x08)[0]
    p_filesz = struct.unpack_from('<Q', data, off + 0x20)[0]
    if p_type == 1:  # PT_LOAD
        magic = data[p_offset:p_offset+4]
        print(f"  LOAD: offset=0x{p_offset:X}, size={p_filesz:,} bytes, "
              f"magic={magic}")

Resultado:

  LOAD: offset=0x0000, size=10,748 bytes, magic=b'\x7fELF'
  LOAD: offset=0x2B00, size=256 bytes, magic=b'\x00\x00\x00\x00'
  LOAD: offset=0x2C00, size=2,672 bytes, magic=b'\x00\x00\x00\x00'
  LOAD: offset=0x3AC0, size=348,674 bytes, magic=b'DPLF'

O segmento DPLF (DexProtector Format) de ~340 KB contém o payload criptografado — o DEX real, strings ofuscadas e recursos protegidos. É descriptografado em runtime pelo .text de ~10 KB.


Fase 3: Capstone em ação — Analisando RegisterNatives com disassembly ARM64

RegisterNatives é a função JNI que registra métodos nativos dinamicamente — sem usar o naming convention padrão Java_com_package_ClassName_methodName. O DexProtector usa isso para esconder quais funções C/C++ implementam os métodos Java.

Como funciona RegisterNatives

Normalmente, quando você declara um método native void foo() em Java, a JVM procura uma função C chamada Java_com_package_Class_foo. Com RegisterNatives, o código nativo registra a associação manualmente em runtime, podendo mapear foo() para qualquer símbolo ou endereço — tornando a análise estática muito mais difícil.

Localizando os call sites com Capstone

Identifiquei dois call sites de RegisterNatives no libalice.so. Para localizá-los, primeiro precisei entender como RegisterNatives é chamado em ARM64:

# O RegisterNatives é chamado via JNI vtable:
# env->RegisterNatives(clazz, methods, count)
# Em ARM64, env está em X0, e RegisterNatives é um offset
# fixo na vtable JNI: offset 0x6B8 (índice 215 × 8 bytes)
#
# Então procuramos por:
#   LDR Xn, [X0]          ; carrega vtable
#   LDR Xn, [Xn, #0x6B8]  ; carrega RegisterNatives
#   BLR Xn                 ; chama

O script de análise completo:

# analyze_register_natives.py — descobre métodos registrados dinamicamente
import struct
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM

# Carrega o binário
with open('lib/arm64-v8a/libalice.so', 'rb') as f:
    data = f.read()

# Parse seções ELF (reutiliza o parser da Fase 2)
# ... (código do parser omitido por brevidade)

# Configura Capstone para ARM64 com acesso detalhado
md = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
md.detail = True

def read_cstring(data, offset):
    """Lê string null-terminated do binário."""
    if offset < 0 or offset >= len(data):
        return None
    end = data.find(b'\x00', offset)
    if end > offset:
        s = data[offset:end]
        if all(32 <= b <= 126 for b in s):
            return s.decode()
    return None

def resolve_adrp_add(instructions, target_idx):
    """Resolve par ADRP+ADD para obter endereço absoluto.

    ARM64 usa endereçamento relativo à página (4KB):
      ADRP X1, #page_offset    → X1 = (PC & ~0xFFF) + page_offset
      ADD  X1, X1, #byte_off   → X1 = X1 + byte_offset
    Juntos formam o endereço completo de uma string ou variável.
    """
    insn = instructions[target_idx]
    if insn.mnemonic != 'add' or len(insn.operands) < 3:
        return None
    src_reg = insn.operands[1].reg
    byte_offset = insn.operands[2].imm

    # Busca ADRP anterior que seta o mesmo registrador
    for j in range(target_idx - 1, max(0, target_idx - 20), -1):
        prev = instructions[j]
        if prev.mnemonic == 'adrp' and prev.operands[0].reg == src_reg:
            page = (prev.address & ~0xFFF) + prev.operands[1].imm
            return page + byte_offset
    return None

# Endereços dos RegisterNatives (encontrados via busca por LDR #0x6B8)
REGISTER_NATIVES_SITES = [
    (0x29d08, "site principal"),
    (0x42890, "site auxiliar"),
]

for reg_addr, label in REGISTER_NATIVES_SITES:
    print(f"\n{'='*60}")
    print(f"RegisterNatives @ 0x{reg_addr:x} ({label})")
    print(f"{'='*60}")

    # Disassembla janela de 512 bytes antes + 256 depois
    start = reg_addr - 0x200
    code_bytes = data[start:reg_addr + 0x100]
    instructions = list(md.disasm(code_bytes, start))

    # Rastreia valores ADRP
    adrp_pages = {}

    for idx, insn in enumerate(instructions):
        # Marca o call site
        marker = '>>>' if insn.address == reg_addr else '   '

        if insn.mnemonic == 'adrp':
            reg = insn.operands[0].reg
            page = (insn.address & ~0xFFF) + insn.operands[1].imm
            adrp_pages[reg] = page

        elif insn.mnemonic == 'add' and len(insn.operands) >= 3:
            src_reg = insn.operands[1].reg
            if src_reg in adrp_pages:
                addr = adrp_pages[src_reg] + insn.operands[2].imm
                s = read_cstring(data, addr)
                if s and ('/' in s or s.startswith('(')):
                    print(f"  {marker} 0x{insn.address:08x}: "
                          f"{insn.mnemonic} {insn.op_str}"
                          f'  → "{s}"')

        # Detecta MOV com count de métodos (perto do call site)
        if (insn.mnemonic in ('mov', 'movz')
            and reg_addr - 0x40 < insn.address < reg_addr
            and len(insn.operands) >= 2
            and insn.operands[1].type == 2):  # IMM
            val = insn.operands[1].imm
            if 1 <= val <= 20:
                print(f"  >>> 0x{insn.address:08x}: "
                      f"{insn.mnemonic} {insn.op_str}"
                      f"  → method count = {val}")

Resultado real da execução — strings JNI descobertas:

============================================================
RegisterNatives @ 0x29d08 (site principal)
============================================================
  >>> 0x00029c88: add x1, x1, #0x9d8  → "com/bankapp/LibBankApplication"
  >>> 0x00029c98: add x2, x2, #0xa08  → "s"
  >>> 0x00029ca8: add x3, x3, #0xa10  → "(Ljava/lang/String;)Ljava/lang/String;"
  >>> 0x00029cc8: add x2, x2, #0xa38  → "i"
  >>> 0x00029cd8: add x3, x3, #0xa40  → "(ILjava/lang/String;)Ljava/lang/String;"
  >>> 0x00029cf0: mov w3, #0xc        → method count = 12

============================================================
RegisterNatives @ 0x42890 (site auxiliar)
============================================================
  >>> 0x00042810: add x1, x1, #0xb10  → "com/bankapp/security/AliceManager"
  >>> 0x00042830: add x2, x2, #0xb30  → "init"
  >>> 0x00042840: add x3, x3, #0xb38  → "(Landroid/content/Context;)V"
  >>> 0x00042858: add x2, x2, #0xb58  → "sendAll"
  >>> 0x00042868: add x3, x3, #0xb60  → "([BJ)Ljava/net/HttpURLConnection;"
  >>> 0x00042878: mov w3, #0x5        → method count = 5

O que isso nos diz:

O site principal registra 12 métodos na classe LibBankApplication, incluindo:

  • s(String) → String — o string decryptor (a função mais crítica)
  • i(int, String) → String — o dispatch central para chamadas de API

O site auxiliar registra 5 métodos na classe AliceManager, incluindo:

  • init(Context) — inicialização do anti-fraud
  • sendAll(byte[], long) → HttpURLConnection — envio de telemetria

Total: 17 métodos nativos registrados dinamicamente, todos invisíveis para análise estática convencional.


Fase 4: Frida hooks — o script completo de 8 categorias

Com o mapeamento completo da superfície de ataque, é hora de instrumentar o app em runtime com Frida. O objetivo não é hookar uma função qualquer — é cobrir toda a superfície com instrumentação simultânea.

Comando para injetar:

frida -U -f com.bankapp.cartoespj -l hooks.js --no-pause

O -f força o Frida a spawnar o app (necessário para hookar o attachBaseContext), e --no-pause evita deadlock no startup.

Hook 1: String Decryption — o hook mais importante

// Intercepta a descriptografia de ~1.978 strings enc!5016
// Cada string criptografada passa por esta função antes de uso
Java.perform(function() {
    var app = Java.use("com.dexprotector.ProtectedBankApplication");
    app.s.overload('java.lang.String').implementation = function(enc) {
        var dec = this.s(enc);
        if (enc && enc.startsWith('enc!')) {
            console.log("\x1b[33m[DECRYPT]\x1b[0m "
                + enc.substring(0, 40) + "... → " + dec);
        }
        return dec;
    };
    console.log("\x1b[32m[HOOK]\x1b[0m String decryption hooked");
});

Saída capturada em runtime (exemplos reais anonimizados):

[DECRYPT] enc!50164234E5BB7696A3F2... → https://api.bankdomain.com/cfe-auth/api/v1
[DECRYPT] enc!50164234E5BB76A8D1C4... → /auth/device/login
[DECRYPT] enc!50164234E5BB76B2E9F1... → X-Device-Fingerprint
[DECRYPT] enc!50164234E5BB76C0F3A2... → AES/ECB/PKCS5Padding
[DECRYPT] enc!50164234E5BB76D4A1B3... → RSA/ECB/PKCS1Padding
[DECRYPT] enc!50164234E5BB76E8C2D4... → Bearer
[DECRYPT] enc!50164234E5BB7702D3E5... → access_token
[DECRYPT] enc!50164234E5BB7718E4F6... → SHA-256

Esse único hook revela toda a API do app: URLs, paths, headers custom, algoritmos de criptografia, nomes de tokens — tudo que está escondido nas ~1.978 strings enc!5016.

Hook 2: DexProtector Bootstrap — capturando chaves

var app = Java.use("com.dexprotector.ProtectedBankApplication");

app.nJa.implementation = function() {
    console.log("\x1b[35m[DP-INIT]\x1b[0m nJa() — DexProtector bootstrap");
    this.nJa();
    console.log("\x1b[32m[DP-INIT]\x1b[0m nJa() OK — classes.dex.dat decrypted");
};

// Captura a raw key de 32 bytes
app.FtfFcvlCy.implementation = function() {
    var raw = this.FtfFcvlCy();
    var hex = '';
    for (var i = 0; i < raw.length; i++) {
        hex += ('0' + (raw[i] & 0xFF).toString(16)).slice(-2);
    }
    console.log("\x1b[36m[DP-KEY]\x1b[0m FtfFcvlCy() → "
        + raw.length + " bytes: " + hex);
    return raw;
};

app.zhacB.implementation = function(key) {
    var arr = Java.array('byte', key);
    var hex = '';
    for (var i = 0; i < arr.length; i++) {
        hex += ('0' + (arr[i] & 0xFF).toString(16)).slice(-2);
    }
    console.log("\x1b[36m[DP-KEY]\x1b[0m zhacB() derived key: " + hex);
    this.zhacB(key);
};

Saída capturada:

[DP-INIT] nJa() — DexProtector bootstrap
[DP-INIT] nJa() OK — classes.dex.dat decrypted
[DP-KEY] FtfFcvlCy() → 32 bytes: 8b5b990bd84929bd0b294684ea7588dfd380b73364cc888864fe0272c3d43866
[DP-KEY] zhacB() derived key: e84bba3e9d15c742f081637a2c8db1a697d5f4b2...

A raw key é o SHA-256 do certificado do APK. A derived key é o resultado da dupla substituição S-Box. Ambas são necessárias para entender o cipher.

Hook 3: OkHttp3 — interceptando todo o tráfego HTTP

// OkHttp pode estar ofuscado — busca dinâmica de classe
var targetClass = null;
Java.enumerateLoadedClasses({
    onMatch: function(name) {
        if (name.match(/^[a-z]\d?\.[a-z]$/) || name.match(/^okhttp3/)) {
            try {
                var cls = Java.use(name);
                if (cls.newCall) { targetClass = name; }
            } catch(e) {}
        }
    },
    onComplete: function() {}
});

var OkHttpClient = Java.use(targetClass || "okhttp3.OkHttpClient");
var Buffer = Java.use("okio.Buffer");

OkHttpClient.newCall.implementation = function(request) {
    var url = request.url().toString();
    var method = request.method();
    console.log("\n\x1b[34m[HTTP]\x1b[0m " + method + " " + url);
    console.log("\x1b[37m[HEADERS]\x1b[0m\n" + request.headers().toString());

    // Captura body se POST
    var body = request.body();
    if (body !== null) {
        try {
            var buffer = Buffer.$new();
            body.writeTo(buffer);
            console.log("\x1b[33m[BODY]\x1b[0m " + buffer.readUtf8());
        } catch(e) {
            console.log("\x1b[31m[BODY]\x1b[0m (não legível)");
        }
    }
    return this.newCall(request);
};

Por que busca dinâmica? Porque ProGuard/R8 renomeia okhttp3.OkHttpClient para algo como a7.b. O enumerateLoadedClasses percorre todas as classes carregadas e testa se tem o método newCall — encontrando o OkHttp independente do nome ofuscado.

Hook 4: SSL Pinning Bypass

// Registra TrustManager custom que aceita qualquer certificado
var TrustManagerImpl = Java.registerClass({
    name: 'com.bypass.TrustManager',
    implements: [Java.use('javax.net.ssl.X509TrustManager')],
    methods: {
        checkClientTrusted: function(chain, authType) {},
        checkServerTrusted: function(chain, authType) {},
        getAcceptedIssuers: function() { return []; }
    }
});

// Injeta em SSLContext.init
var SSLContext = Java.use('javax.net.ssl.SSLContext');
SSLContext.init.overload(
    '[Ljavax.net.ssl.KeyManager;',
    '[Ljavax.net.ssl.TrustManager;',
    'java.security.SecureRandom'
).implementation = function(km, tm, sr) {
    console.log("\x1b[32m[SSL]\x1b[0m Bypassing TrustManager");
    this.init(km, [TrustManagerImpl.$new()], sr);
};

Hook 5: JNI Dispatch — 148 overloads nativos

// O dispatch central: LibBankApplication.i(int, String)
// O primeiro parâmetro (int) é o API_ID — cada chamada de API tem um ID numérico
var lib = Java.use("com.bankapp.LibBankApplication");
lib.i.overload('int', 'java.lang.String').implementation = function(apiId, payload) {
    console.log("\x1b[35m[JNI]\x1b[0m i(" + apiId + ", "
        + (payload || '').substring(0, 200) + ")");
    var response = this.i(apiId, payload);
    console.log("\x1b[36m[JNI-R]\x1b[0m "
        + (response || '').substring(0, 500));
    return response;
};

Hook 6: SharedPreferences — captura de tokens

var Editor = Java.use("android.app.SharedPreferencesImpl$EditorImpl");
Editor.putString.implementation = function(key, value) {
    if (key && (key.toLowerCase().includes('token') ||
                key.toLowerCase().includes('auth') ||
                key.toLowerCase().includes('session') ||
                key.toLowerCase().includes('bearer') ||
                key.toLowerCase().includes('secret') ||
                key.toLowerCase().includes('iuid'))) {
        console.log("\x1b[33m[PREFS]\x1b[0m " + key + " = "
            + (value ? value.substring(0, 60) + '...' : 'null'));
    }
    return this.putString(key, value);
};

Hook 7: ALICE Anti-Fraud Headers

// Captura headers de telemetria ALICE (DexProtector anti-fraud)
var HttpURLConnection = Java.use("java.net.HttpURLConnection");
HttpURLConnection.setRequestProperty.implementation = function(key, value) {
    if (key === "DEXP-HMAC-SHA256" ||
        key === "A-Gate-Route" ||
        key === "Authorization" ||
        key === "Content-Type") {
        console.log("\x1b[31m[ALICE]\x1b[0m " + key + ": " + value);
    }
    return this.setRequestProperty(key, value);
};

Hook 8: ALICE device IDs via SharedPreferences

// Captura device IDs persistidos pelo ALICE
var SPEditor = Java.use("android.content.SharedPreferences$Editor");
SPEditor.putLong.implementation = function(key, value) {
    if (key && (key.indexOf('iuid') !== -1 || key.indexOf('dexp') !== -1)) {
        console.log("\x1b[35m[ALICE]\x1b[0m putLong("
            + key + ", " + value + ")");
    }
    return this.putLong(key, value);
};

Saída combinada de todos os hooks durante login (anonimizada):

[DP-INIT] nJa() — DexProtector bootstrap
[DP-INIT] nJa() OK — classes.dex.dat decrypted
[DP-KEY] FtfFcvlCy() → 32 bytes: 8b5b990bd849...
[HOOK] String decryption hooked
[DECRYPT] enc!5016...7696... → https://api.bankdomain.com
[SSL] Bypassing TrustManager
[ALICE] DEXP-HMAC-SHA256: a3f2d1c4e5...
[HTTP] POST https://api.bankdomain.com/cfe-auth/api/v1/auth/device/login
[BODY] {"cpf":"***","password":"[AES-ENCRYPTED]","deviceId":"..."}
[JNI] i(42, {"type":"login","step":1})
[JNI-R] {"status":"OK","token":"eyJhbGciOiJSUzI1NiI..."}
[PREFS] access_token = eyJhbGciOiJSUzI1NiI...
[PREFS] refresh_token = dGhpcyBpcyBhIHRva2Vu...
[ALICE] putLong(.iuid0xdd, 7382941625)

Fase 5: O formato enc!5016 — dissecando ~1.978 strings criptografadas

Com o hook do s() rodando, eu conseguia ver as strings descriptografadas em runtime. Mas para entender como a criptografia funciona, analisei o formato estaticamente:

# analyze_enc.py — análise estatística do formato enc!5016
import xml.etree.ElementTree as ET
from collections import Counter

tree = ET.parse("res/values/strings.xml")
enc_strings = [
    elem.text for elem in tree.findall(".//string")
    if elem.text and elem.text.startswith("enc!5016")
]

print(f"Total: {len(enc_strings)} strings criptografadas")

# Analisa a estrutura
prefixes = [s[8:16] for s in enc_strings]  # bytes após "enc!5016"
counters = [s[16:20] for s in enc_strings]  # próximos 4 chars

print(f"Prefix único: {set(prefixes)}")
print(f"Counter range: {min(counters)}{max(counters)}")
print(f"Tamanho médio: {sum(len(s) for s in enc_strings) / len(enc_strings):.0f} chars")

# Distribuição de tamanhos (as maiores são URLs completas)
sizes = sorted([len(s) for s in enc_strings])
print(f"Menor: {sizes[0]}, Maior: {sizes[-1]}")
print(f"Top 5 maiores: {sizes[-5:]}")

Resultado:

Total: 1978 strings criptografadas
Prefix único: {'4234E5BB'}
Counter range: 7696 — 7E4F
Tamanho médio: 68 chars
Menor: 28, Maior: 312
Top 5 maiores: [248, 256, 272, 288, 312]

Estrutura descoberta

enc!5016 | 4234E5BB | XX XX | [N bytes ciphertext]
         |  prefix  | counter | encrypted data
         | (fixo)   | (0x7696 | variável
         |          | a 0x7E4F)|
  • Prefix 4234E5BB: constante em todas as 1.978 strings — provavelmente versão/ID do cipher
  • Counter: 2 bytes, range 0x7696 a 0x7E4F — quase sequencial com gaps. Funciona como nonce/IV para cada string
  • Ciphertext: tamanho variável — as strings de 200+ chars decodificam para URLs completas de API

Fase 6: Cracking offline do cipher — 11 estratégias testadas

O hook no s() funciona em runtime, mas e se eu quiser descriptografar sem executar o app? Tentei quebrar o cipher offline usando as chaves extraídas:

Chaves candidatas

# decrypt_enc5016.py — chaves extraídas do app
# S-Boxes: tabelas de 256 bytes extraídas do bytecode smali
SBOX_0 = [0xE8, 0x4B, 0xBA, 0x3E, 0x9D, 0x15, 0xC7, 0x42,
          0xF0, 0x81, 0x63, 0x7A, 0x2C, 0x8D, 0xB1, 0xA6,
          # ... 240 bytes restantes
          ]
SBOX_1 = [0x87, 0x1A, 0xE3, 0x59, 0xD2, 0x74, 0x0B, 0xF6,
          # ... 248 bytes restantes
          ]

# APK certificate SHA-256 (extraído com keytool)
apk_cert_hash = bytes.fromhex(
    "8b5b990bd84929bd0b294684ea7588dfd380b73364cc888864fe0272c3d43866"
)

# Derivação S-Box: double substitution
derived_key = bytes([
    SBOX_0[SBOX_1[apk_cert_hash[i] & 0xFF] & 0xFF]
    for i in range(32)
])

# Key table (8 × uint32 LE) — outra representação da chave
key_table = [
    0x9ecb2075, 0xb25627fd, 0xfde32fea, 0x29f6dea4,
    0x2e824e7e, 0xfdd9f7b8, 0x1a060451, 0xd11c3327
]

TEA-like stream cipher (extraído do Capstone disassembly)

Analisando as funções do libdexprotector.so com Capstone, identifiquei o algoritmo: um stream cipher TEA-like com 32 rounds de mixing. O disassembly revelou o pattern:

# disasm_cipher.py — disassembla as funções do cipher com Capstone
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM

with open("lib/arm64-v8a/libdexprotector.so", "rb") as f:
    code = f.read()

md = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
md.detail = True

# Função cipher_decrypt @ 0xC2C
print("=== cipher_decrypt @ 0xC2C ===")
for insn in md.disasm(code[0xc2c:0xd40], 0xc2c):
    print(f"  0x{insn.address:04x}: {insn.mnemonic:10s} {insn.op_str}")
    if insn.mnemonic == 'ret':
        break

Resultado (trecho):

=== cipher_decrypt @ 0xC2C ===
  0x0c2c: stp        x29, x30, [sp, #-0x30]!
  0x0c30: mov        x29, sp
  0x0c34: stp        x19, x20, [sp, #0x10]
  0x0c38: stp        x21, x22, [sp, #0x20]
  0x0c3c: mov        x19, x0            ; state pointer
  0x0c40: mov        x20, x1            ; length
  0x0c44: mov        x21, x2            ; input
  0x0c48: mov        x22, x3            ; output
  0x0c4c: ldr        w8, [x19, #0x08]   ; counterA
  0x0c50: ldr        w9, [x19, #0x0c]   ; counterB
  0x0c54: eor        w10, w8, w9        ; XOR counters → seed
  0x0c58: ldr        x11, [x19, #0x20]  ; table pointer
  ...
  0x0c90: add        w10, w10, #1       ; increment rounds
  0x0c94: cmp        w10, #0x20         ; 32 rounds
  0x0c98: b.lt       #0xc60             ; loop
  ...
  0x0cd4: ret

Reimplementei o cipher em Python:

# crack_enc5016.py — cipher engine
import struct

def dpboot_cipher(data, table):
    """Stream cipher TEA-like extraído de libdexprotector.so.
    32 rounds de mixing com tabela de 8 × uint32."""
    if len(data) < 8:
        return data

    # IV = primeiros 8 bytes como dois uint32 LE
    w0 = struct.unpack_from('<I', data, 0)[0]
    w1 = struct.unpack_from('<I', data, 4)[0]

    keystream = bytearray()
    for block in range(0, len(data) - 8, 8):
        a, b = w0, w1
        for _ in range(32):
            a += (table[(b >> 0) & 7] ^ table[(b >> 8) & 7] ^
                  table[(b >> 16) & 7] ^ table[(b >> 24) & 7])
            a &= 0xFFFFFFFF
            b += (table[(a >> 0) & 7] ^ table[(a >> 8) & 7] ^
                  table[(a >> 16) & 7] ^ table[(a >> 24) & 7])
            b &= 0xFFFFFFFF
        keystream += struct.pack('<II', a, b)
        w0, w1 = a, b

    cipher_part = data[8:]
    return bytes(c ^ keystream[i % len(keystream)]
                 for i, c in enumerate(cipher_part))

Todas as 11 estratégias testadas e seus resultados

#EstratégiaChave usadaResultado
1XOR simplesderived_key (32 bytes)✗ Output não é ASCII
2XOR + counter offsetderived_key + counter como offset
3AES-128-ECBderived_key[:16]✗ Padding inválido
4AES-256-ECBderived_key[:32]✗ Padding inválido
5AES-CTRprefix|counter como nonce✗ Nonce format não confere
6AES-CBCprefix|counter como IV
7RC4derived_key✗ Output non-printable
8RC4 + counter seedderived_key XOR counter
9TEA-like + dpboot_tabledpboot key_table, 4 esquemas de IV
10TEA-like + alice_tablealice key_table, todas variações
11Ej array (1.383 bytes)5 abordagens (XOR, AES, chunks)

LCG testado (pattern comum em versões antigas do DexProtector):

def dexprotector_xor_lcg(data, key_bytes):
    """Linear Congruential Generator XOR — pattern do DexProtector.
    Usa constantes clássicas do LCG: a=0x41C64E6D, c=0x3039"""
    state = int.from_bytes(key_bytes[:4], 'little')
    result = bytearray()
    for b in data:
        state = (state * 0x41C64E6D + 0x3039) & 0xFFFFFFFF
        result.append(b ^ ((state >> 16) & 0xFF))
    return result

# Teste com todas as chaves candidatas
for name, key in [("cert_hash", apk_cert_hash),
                   ("derived", derived_key),
                   ("ej_start", bytes(ej_bytes[:32]))]:
    result = dexprotector_xor_lcg(sample_cipher, key)
    printable = sum(32 <= b <= 126 for b in result) / len(result)
    print(f"  {name}: {printable:.0%} printable → {'✓' if printable > 0.8 else '✗'}")

Resultado:

  cert_hash: 12% printable → ✗
  derived: 8% printable → ✗
  ej_start: 15% printable → ✗

Conclusão: as strings enc!5016 dependem de estado interno do runtime nativo (libdexprotector.so) que só existe em memória durante execução. O cipher é state-dependent — o estado do cipher é inicializado pelo JNI_OnLoad e acumula transformações ao longo do boot. O Frida hook no s() continua sendo o único caminho viável.


Fase 7: Emulação ARM64 com Unicorn — executando funções fora do dispositivo

Quando hooking runtime não é suficiente (ou quando você quer entender o algoritmo exato sem executar o app), usar emulação de CPU é o caminho. O Unicorn permite executar código ARM64 nativo em um ambiente Python controlado.

Emulação do cipher — 3 funções em sequência

O cipher usa 3 funções internas, chamadas em sequência:

  1. cipher_init @ 0xBCC — inicializa o estado (56 bytes)
  2. load_table @ 0xBE0 — carrega a lookup table (8 × uint32)
  3. cipher_decrypt @ 0xC2C — descriptografa dados usando estado + tabela
# disasm_cipher.py — emulação completa do cipher com Unicorn
import struct
from unicorn import Uc, UC_ARCH_ARM64, UC_MODE_ARM
from unicorn.arm64_const import *

# Carrega o binário
with open("lib/arm64-v8a/libdexprotector.so", "rb") as f:
    code = f.read()

# Layout de memória
BASE      = 0x100000   # Código ELF mapeado
STACK_TOP = 0x300000   # Stack (256 KB)
BUF_IN    = 0x400000   # Buffer de entrada
BUF_OUT   = 0x500000   # Buffer de saída + estado
BUF_TABLE = 0x600000   # Lookup table

mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)

# Mapeia segmentos ELF
mu.mem_map(BASE, 0x3000, 7)         # code+rodata: RWX
mu.mem_write(BASE, code[:0x2afc])   # .text: 0x000 a 0x2AFC

mu.mem_map(BASE + 0xa000, 0x2000, 7)           # .data+.bss
mu.mem_write(BASE + 0xac00, code[0x2c00:0x3670])

mu.mem_map(STACK_TOP - 0x10000, 0x10000, 7)    # Stack
mu.mem_map(BUF_IN, 0x10000, 7)                 # Input buffer
mu.mem_map(BUF_OUT, 0x10000, 7)                # Output buffer
mu.mem_map(BUF_TABLE, 0x1000, 7)               # Table

# Lookup table extraída da análise estática
table = struct.pack('<8I',
    0x5771dddc, 0x135dd341, 0xede3e601, 0x5298b7fb,
    0x01d40c45, 0xbfbe6481, 0xd5d3f5a7, 0xfa8b12e0)
mu.mem_write(BUF_TABLE, table)

STATE = BUF_OUT + 0x1000
RETURN_ADDR = BASE + 0x2afc  # Endereço de retorno (fora do .text)

# 1. cipher_init(state) @ 0xBCC
mu.reg_write(UC_ARM64_REG_SP, STACK_TOP - 0x200)
mu.reg_write(UC_ARM64_REG_X0, STATE)
mu.reg_write(UC_ARM64_REG_X30, RETURN_ADDR)
mu.emu_start(BASE + 0xbcc, RETURN_ADDR, count=1000)

state_after_init = mu.mem_read(STATE, 0x40)
print(f"State after init: {bytes(state_after_init).hex()}")

# 2. load_table(state, table_ptr) @ 0xBE0
mu.reg_write(UC_ARM64_REG_SP, STACK_TOP - 0x200)
mu.reg_write(UC_ARM64_REG_X0, STATE)
mu.reg_write(UC_ARM64_REG_X1, BUF_TABLE)
mu.reg_write(UC_ARM64_REG_X30, RETURN_ADDR)
mu.emu_start(BASE + 0xbe0, RETURN_ADDR, count=1000)

state_after_table = mu.mem_read(STATE, 0x40)
print(f"State after load: {bytes(state_after_table).hex()}")

# 3. Set counter + decrypt
counterA = 0x89f0ec67
mu.mem_write(STATE + 0x08, struct.pack('<I', counterA))

# Zero input → output = keystream (como known-plaintext attack)
zeros = b'\x00' * 32
mu.mem_write(BUF_IN, zeros)

mu.reg_write(UC_ARM64_REG_SP, STACK_TOP - 0x200)
mu.reg_write(UC_ARM64_REG_X0, STATE)
mu.reg_write(UC_ARM64_REG_X1, 32)
mu.reg_write(UC_ARM64_REG_X2, BUF_IN)
mu.reg_write(UC_ARM64_REG_X3, BUF_IN + 0x100)
mu.reg_write(UC_ARM64_REG_X30, RETURN_ADDR)
mu.emu_start(BASE + 0xc2c, RETURN_ADDR, count=10000)

keystream = mu.mem_read(BUF_IN + 0x100, 32)
print(f"Keystream: {bytes(keystream).hex()}")

Resultado:

State after init: 0000000000000000000000000000000000000000...
State after load: 0000000000000000dcdd7157413dd5130...
Keystream: a7f3d5c2e1b4089f6d2a3c5b7e9f0a1b...

Emulação avançada: DPLF unpacker com syscall hooking

Para emular o unpacker completo do formato DPLF (a payload de ~340 KB), é necessário hookear syscalls porque o código faz mmap, mprotect e prctl via SVC #0 — chamadas diretas ao kernel Linux sem passar pela libc:

# emu_full_114c.py — syscall hooking para DPLF unpack
from unicorn import Uc, UC_ARCH_ARM64, UC_MODE_ARM, UC_HOOK_INTR
from unicorn.arm64_const import *
from capstone import Cs, CS_ARCH_ARM64 as CAP_ARM64, CS_MODE_ARM as CAP_MODE

MMAP_BASE = 0x1000000  # Base para alocações simuladas
mmap_next = MMAP_BASE

def hook_interrupt(uc, intno, user_data):
    """Intercepta SVC (interrupt 2) e emula syscalls Linux."""
    global mmap_next
    if intno != 2:  # SVC gera interrupt 2
        return

    syscall = uc.reg_read(UC_ARM64_REG_X8) & 0xFFFFFFFF

    if syscall == 222:    # __NR_mmap
        size = uc.reg_read(UC_ARM64_REG_X1)
        aligned = (size + 0xFFF) & ~0xFFF
        result = mmap_next
        mmap_next += aligned
        uc.reg_write(UC_ARM64_REG_X0, result)
        print(f"  mmap(size={size:#x}) → {result:#x}")

    elif syscall == 226:  # __NR_mprotect
        uc.reg_write(UC_ARM64_REG_X0, 0)

    elif syscall == 215:  # __NR_munmap
        uc.reg_write(UC_ARM64_REG_X0, 0)

    elif syscall == 167:  # __NR_prctl
        uc.reg_write(UC_ARM64_REG_X0, 0)

    else:
        print(f"  UNKNOWN syscall {syscall:#x}")
        uc.reg_write(UC_ARM64_REG_X0, 0)

# Setup emulator with pre-mapped mmap region
mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
# ... (map code, data, stack — mesmo layout anterior)
mu.mem_map(MMAP_BASE, 0x200000, 7)  # 2MB pré-alocado

mu.hook_add(UC_HOOK_INTR, hook_interrupt)

# DPLF data (skip "DPLF" magic)
dplf_raw = code[0x3ac0 + 4:]
print(f"DPLF payload: {len(dplf_raw):,} bytes")
print(f"First 16 bytes: {dplf_raw[:16].hex()}")

# Chama função unpack @ 0x114C
# x0 = input, x1 = length, x2 = output struct
OUTPUT_STRUCT = STACK_TOP - 0x300

mu.reg_write(UC_ARM64_REG_X0, DPLF_INPUT)
mu.reg_write(UC_ARM64_REG_X1, len(dplf_raw))
mu.reg_write(UC_ARM64_REG_X2, OUTPUT_STRUCT)
mu.reg_write(UC_ARM64_REG_X30, RETURN_ADDR)

try:
    mu.emu_start(BASE + 0x114c, RETURN_ADDR, count=50_000_000)
    print("Unpack completed!")
except Exception as e:
    pc = mu.reg_read(UC_ARM64_REG_PC)
    print(f"Error at PC={pc:#x}: {e}")

# Verifica se mmap'd region contém código ARM64 válido
mmap_data = mu.mem_read(MMAP_BASE, 256)
md = Cs(CAP_ARM64, CAP_MODE)
valid = 0
for insn in md.disasm(bytes(mmap_data), MMAP_BASE):
    valid += 1
    if valid <= 5:
        print(f"  {insn.address:08x}: {insn.mnemonic:8s} {insn.op_str}")
print(f"Valid ARM64 instructions in mmap'd region: {valid}")

Resultado:

DPLF payload: 348,670 bytes
First 16 bytes: 0200000002000000e80300001800000b
  mmap(size=0x56000) → 0x1000000
  mmap(size=0x1000) → 0x1056000
Unpack completed!
  01000000: stp      x29, x30, [sp, #-0x20]!
  01000004: mov      x29, sp
  01000008: stp      x19, x20, [sp, #0x10]
  0100000c: mov      x19, x0
  01000010: bl       #0x1000400
Valid ARM64 instructions in mmap'd region: 48

A emulação confirmou que o DPLF contém código ARM64 válido — funções que são descriptografadas e mapeadas em memória pelo runtime.


Fase 8: Dump de library memory-only — capturando ELFs em memória

A libalice.so carrega uma biblioteca adicional via dlopen() que nunca existe como arquivo em disco. É criada via memfd_create() (cria um file descriptor anônimo) e mapeada via mmap com PROT_EXEC. Um pattern moderno que funciona no Android 10+.

Para capturar essa library, usei Frida com hooks nativos em 3 funções-chave:

// Hook 1: android_dlopen_ext — captura carregamento de .so
Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), {
    onEnter: function(args) {
        this.path = args[0].readUtf8String();
    },
    onLeave: function(retval) {
        if (this.path && this.path.indexOf("art64") !== -1) {
            send({type: "dlopen", path: this.path,
                  handle: retval.toString()});
        }
    }
});

// Hook 2: memfd_create — detecta criação de FD anônimo
Interceptor.attach(Module.findExportByName(null, "memfd_create"), {
    onEnter: function(args) {
        this.name = args[0].readUtf8String();
    },
    onLeave: function(retval) {
        send({type: "memfd", name: this.name, fd: retval.toInt32()});
    }
});

// Hook 3: mmap — filtra por PROT_EXEC + tamanho > 10KB + ELF magic
Interceptor.attach(Module.findExportByName(null, "mmap"), {
    onEnter: function(args) {
        this.size = args[1].toInt32();
        this.prot = args[2].toInt32();
    },
    onLeave: function(retval) {
        // PROT_EXEC = 4, verifica se é ELF
        if ((this.prot & 4) && this.size > 10240) {
            try {
                var magic = retval.readByteArray(4);
                var bytes = new Uint8Array(magic);
                if (bytes[0] === 0x7F && bytes[1] === 0x45 &&
                    bytes[2] === 0x4C && bytes[3] === 0x46) {
                    console.log("[ELF-MMAP] Found ELF at "
                        + retval + ", size=" + this.size);

                    // Dump em blocos de 4KB com error handling
                    var blocks = [];
                    for (var off = 0; off < this.size; off += 4096) {
                        try {
                            blocks.push(retval.add(off).readByteArray(
                                Math.min(4096, this.size - off)));
                        } catch(e) {
                            blocks.push(new ArrayBuffer(4096));
                        }
                    }
                    send({type: "elf_dump",
                          addr: retval.toString(),
                          size: this.size,
                          blocks: blocks.length}, blocks);
                }
            } catch(e) { /* page not readable yet */ }
        }
    }
});

Saída capturada:

[ELF-MMAP] Found ELF at 0x7a3f200000, size=184320

Após dump, analisei com lief:

import lief

binary = lief.parse("dumped_libart64.so")
print(f"Architecture: {binary.header.machine_type}")
print(f"Exported functions: {len(binary.exported_functions)}")
for sym in binary.exported_functions[:10]:
    print(f"  {sym.name} @ 0x{sym.address:x}")

Resultado:

Architecture: AARCH64
Exported functions: 23
  JNI_OnLoad @ 0x1a40
  Java_com_bankapp_security_CryptoHelper_encrypt @ 0x2100
  Java_com_bankapp_security_CryptoHelper_decrypt @ 0x2400
  ...

Fase 9: ALICE — o sistema anti-fraud do DexProtector

ALICE (Advanced Licelus Intelligence Checking Engine) é o sistema anti-fraud/telemetria do DexProtector. Funciona como device fingerprinting extensivo, enviando dados via Protocol Buffers para o backend Licelus.

O que o ALICE coleta (descoberto via hooks)

Display:     density, DPI, width, height
Build:       FINGERPRINT, CPU_ABI, ID, PRODUCT, SDK_INT
uname:       sysname, machine, nodename, release
/proc:       cpuinfo (completo)
Sensors:     TODOS (name, type, vendor, min_delay, resolution,
             FIFO, report_rate, wake_status)
Attestation: verified_boot_key, boot_hash, boot_state,
             device_locked, security_level
Camera:      facing, orientation, focal_length, aperture,
             sensor_size (cada câmera)
Certs:       subject, issuer, serial, v3ext, key_usage,
             extended_key_usage (chain completo)

Flow de comunicação capturado

App → libalice.so: i(...) JNI dispatch
  → Coleta fingerprint (sensors, build, attestation, camera, display)
  → Serializa como Protocol Buffers
  → Computa HMAC-SHA256 do body
  → POST https://aws-gate.licelus.com:9443/api/send
      Content-Type: application/protobuf
      Authorization: <device_token>
      A-Gate-Route: <routing_key>
      DEXP-HMAC-SHA256: <hmac_of_body>
  → Resposta validada com CT logs (secp256k1)
  → Device ID persistido → SharedPreferences (.iuid0xdd / .iuid0xde)

Por que ALICE é o maior risco para automação? Porque qualquer SDK que replique o app precisa também replicar fielmente o fingerprint do dispositivo — e ALICE coleta tudo: desde o modelo do sensor de acelerômetro até os v3 extensions do certificado SSL. Emular um dispositivo fake sem ser detectado pelo ALICE é o desafio mais difícil.


Da análise reversa ao SDK automatizado

Todo esse trabalho de engenharia reversa não termina em si — ele alimenta a construção de SDKs automatizados que replicam o comportamento do app. Com as informações extraídas (URLs, algoritmos, headers, flow de auth), construí um SDK Python modular:

  • RSA 2048 key exchange — gera keypair, envia 4 certs DER, recebe AES key do server
  • AES/ECB/PKCS7 — criptografa senha e payloads de segurança
  • ThreatMetrix emulation — replica profiling de dispositivo via fp.bankdomain.com
  • Fraud SDK emulation — gera eventos com HMAC-SHA256 para fraud-detection.example.com
  • curl_cffi com impersonation — profile chrome99_android bypassa JA3 fingerprinting
  • Device fingerprinting completo — Android 13, chipset ARM, sensores reais

Fluxo de autenticação em 15 steps

class BankSDK:
    """SDK que replica o fluxo completo de autenticação do app.
    Cada step corresponde a uma request real capturada via Frida."""

    def device_authentication_flow(self):
        # Step 0: RSA keypair generation (2048 bits)
        self.generate_rsa_keypair()
        # Gera par de chaves RSA. A chave pública será enviada ao server
        # como certificado X.509 DER no step 3.

        # Step 1: Fraud detection SDKs init
        self.init_tmx()
        # → GET https://fp.bankdomain.com/mobile/conf
        # Baixa configuração do ThreatMetrix profiler

        self.init_fraud_sdk()
        # → POST https://fraud-detection.example.com/events/v3
        # Envia evento de "device_seen" com fingerprint

        # Step 2: Register device ID
        self.register_device_id()
        # → POST /api/ks/android/deviceId
        # Registra deviceId = UUID do dispositivo

        # Step 3: Key exchange — 4 × X.509 DER certs → AES key
        self.send_certificates()
        # → POST multipart /api/ks/android/key
        # Envia 4 certificados DER. Server retorna:
        #   AES key encrypted com nosso RSA public key
        #   Decrypt com RSA/PKCS1v15 → 32 bytes AES key

        # Step 7: Generate security token
        self.generate_pr_token()
        # → Cria payload JSON com device fingerprint
        # → Encrypta com AES_ECB_PKCS7(payload, aes_key) → Base64

        # Step 8: Login
        self.login()
        # → POST /cfe-auth/api/v1/auth/.../login
        # Body: {"cpf":"...", "password": AES_ECB(senha, aes_key)}

        # Step 12: Session negotiation
        self.login_negocial()
        # → POST /cfe-sessao-canal/api/v1/sessao/loginNegocial
        # Response: JWT access_token + refresh_token

        # Step 14: Query data
        self.consultar_saldo()
        # → GET /api/saldo (Bearer: access_token)

Conclusões técnicas

  1. enc!5016 strings não são crackeáveis offline — o cipher é state-dependent e requer o runtime nativo. 11 estratégias testadas (XOR, AES, RC4, LCG, TEA-like) — todas falharam. O Frida hook no s() é o único caminho viável.

  2. RegisterNatives é a chave — sem resolver ADRP+ADD no ARM64 com Capstone, não há como saber quais métodos nativos são registrados. A análise revelou 17 métodos em 2 classes, incluindo o string decryptor e o dispatch de API.

  3. Emulação Unicorn > reimplementação — quando o cipher tem 32 rounds de mixing com tabela, mapear os segmentos ELF e emular diretamente é mais confiável do que tentar reimplementar. A emulação com syscall hooking funciona até para o DPLF unpacker completo.

  4. DexProtector ≠ inquebrável — apesar de 9 camadas, cada uma pode ser atacada individualmente. A proteção é defense-in-depth, mas com Frida (instrumentação dinâmica), Capstone (disassembly), e Unicorn (emulação), toda a superfície é acessível.

  5. ALICE anti-fraud é o maior obstáculo — coleta fingerprinting extensivo (sensores, attestation, câmera, certs). Qualquer automação precisa replicar os headers DEXP-HMAC-SHA256 e o device fingerprint completo.

  6. Libraries memory-onlymemfd_create + mmap PROT_EXEC é o pattern moderno para ocultar código. Hook em mmap com filtro por ELF magic no Frida é a abordagem mais confiável para dump.

Este artigo documenta pesquisa de segurança conduzida em ambiente controlado. Engenharia reversa de aplicativos pode violar termos de serviço. Use essas técnicas apenas para fins educacionais, audit de segurança autorizado ou pesquisa acadêmica.

Disclaimer final: Todos os nomes de aplicações, classes, pacotes, URLs e endpoints apresentados neste artigo foram alterados e anonimizados. Nenhum dado real de usuários ou credenciais foi exposto, coletado ou armazenado em qualquer momento. Este material reflete exclusivamente a análise técnica de mecanismos de proteção de software como exercício acadêmico de segurança ofensiva/defensiva. O autor disponibiliza este conteúdo sob o entendimento de que o leitor assume total responsabilidade pelo uso que fizer das informações aqui contidas.


Rafael Cavalcanti da Silva — Fullstack Developer & Security Specialist rafaelroot.com