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:
| Camada | Proteção | Mecanismo | O que faz |
|---|---|---|---|
| 1 | Certificate Pinning (APK) | SHA-256 do cert verificado em m() antes de qualquer init | Impede análise de tráfego com proxy |
| 2 | Native Bootstrap | System.loadLibrary("dpboot") → nJa() → descriptografa classes.dex.dat | Código Java real fica oculto até runtime |
| 3 | S-Box Key Derivation | FtfFcvlCy() → dupla substituição S-Box → chave 32 bytes → zhacB() | Chaves derivadas dificultam extração estática |
| 4 | Native App Library | System.loadLibrary("alice") → integrity check → RegisterNatives | Registra métodos nativos sem naming convention |
| 5 | DEX Encryption | classes.dex.dat (7.2 MB) — TODO o código real do app, criptografado | Bytecode Java inacessível por decompilação normal |
| 6 | String Encryption | ~1.978 strings enc!5016... descriptografadas via JNI s() | URLs, tokens, chaves — tudo criptografado |
| 7 | JNI Dispatch | 148 overloads de LibBankApplication.i(int, ...) | Toda comunicação com server via dispatch nativo |
| 8 | Certificate Pinning (Network) | SHA-256 pins em network-security-config.xml | Dupla camada de pin: APK + rede |
| 9 | Process Isolation | Processos separados :p63ce7f... e :p72e6f... com fast-path | Isola 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:
| Biblioteca | Tamanho | Função |
|---|---|---|
libalice.so | 458 KB | Lib principal: JNI dispatch, crypto, device fingerprint, MbedTLS, Google Breakpad |
libdexprotector.so | 356 KB | Runtime DexProtector: DEX/strings decrypt (0 imports — totalmente self-contained) |
libdpboot.so | 8.2 KB | Bootstrap DexProtector — descriptografa classes.dex.dat |
libde17df.so | 52 KB | Módulo auxiliar DexProtector |
libiproov-*.so | 38 KB | iProov biometric (face liveness detection) |
libandroidx.graphics.path.so | 9.9 KB | AndroidX 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:
.textcom 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 doJNI_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-fraudsendAll(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
0x7696a0x7E4F— 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égia | Chave usada | Resultado |
|---|---|---|---|
| 1 | XOR simples | derived_key (32 bytes) | ✗ Output não é ASCII |
| 2 | XOR + counter offset | derived_key + counter como offset | ✗ |
| 3 | AES-128-ECB | derived_key[:16] | ✗ Padding inválido |
| 4 | AES-256-ECB | derived_key[:32] | ✗ Padding inválido |
| 5 | AES-CTR | prefix|counter como nonce | ✗ Nonce format não confere |
| 6 | AES-CBC | prefix|counter como IV | ✗ |
| 7 | RC4 | derived_key | ✗ Output non-printable |
| 8 | RC4 + counter seed | derived_key XOR counter | ✗ |
| 9 | TEA-like + dpboot_table | dpboot key_table, 4 esquemas de IV | ✗ |
| 10 | TEA-like + alice_table | alice key_table, todas variações | ✗ |
| 11 | Ej 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:
cipher_init@ 0xBCC — inicializa o estado (56 bytes)load_table@ 0xBE0 — carrega a lookup table (8 × uint32)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_androidbypassa 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
-
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. -
RegisterNatives é a chave — sem resolver
ADRP+ADDno 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. -
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.
-
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.
-
ALICE anti-fraud é o maior obstáculo — coleta fingerprinting extensivo (sensores, attestation, câmera, certs). Qualquer automação precisa replicar os headers
DEXP-HMAC-SHA256e o device fingerprint completo. -
Libraries memory-only —
memfd_create+mmap PROT_EXECé o pattern moderno para ocultar código. Hook emmmapcom 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