This is not an introductory Frida tutorial. Here I document the real analysis of a corporate mobile banking app protected by DexProtector — with encrypted DEX, ~1,978 obfuscated strings, JNI dispatch with 148 native overloads, custom stream cipher, anti-fraud telemetry, and libraries loaded only in memory.
The target is a corporate cards application from a major Brazilian bank — one of the most heavily protected apps in the country. All package names, classes, URLs, and endpoints have been anonymized throughout, but the technical structure, protection mechanisms, and results are real. The goal is to demonstrate the techniques and tools, not to expose the specific app.
Everything described here was executed, tested, and documented during security research in a controlled environment.
⚠️ Legal Disclaimer: All content in this article is published exclusively for educational, academic, and information security research purposes. No technique described here was used to access real third-party data, compromise production systems, or cause any harm. The analysis was conducted in a controlled, isolated environment, on an application installed on the author’s own device, with no interaction with live servers. Class names, libraries, and endpoints have been anonymized. The author does not encourage, endorse, or accept responsibility for misuse of this information. Reverse engineering for security study and research is supported by legislation such as the DMCA §1201(j) (USA), EU Directive 2009/24/EC Art. 6, and the principle of responsible disclosure.
The target: 9-layer protection model
Before hooking anything, mapping the attack surface is essential. The app uses DexProtector (by Licelus) as its main protection — one of the most advanced mobile obfuscation and runtime protection systems available.
After decompilation with JADX (static analysis of Java/Smali bytecode) and ARM64 disassembly with Capstone (static analysis of native .so binaries), I identified 9 overlapping protection layers:
| Layer | Protection | Mechanism | Purpose |
|---|---|---|---|
| 1 | Certificate Pinning (APK) | SHA-256 cert check in m() before any init | Prevents traffic analysis via proxy |
| 2 | Native Bootstrap | System.loadLibrary("dpboot") → nJa() → decrypts classes.dex.dat | Real Java code hidden until runtime |
| 3 | S-Box Key Derivation | FtfFcvlCy() → double S-Box substitution → 32-byte key → zhacB() | Derived keys resist static extraction |
| 4 | Native App Library | System.loadLibrary("alice") → integrity check → RegisterNatives | Registers native methods without naming convention |
| 5 | DEX Encryption | classes.dex.dat (7.2 MB) — ALL real app code, encrypted | Java bytecode inaccessible via normal decompilation |
| 6 | String Encryption | ~1,978 enc!5016... strings decrypted via JNI s() | URLs, tokens, keys — everything encrypted |
| 7 | JNI Dispatch | 148 overloads of LibBankApplication.i(int, ...) | All server communication via native dispatch |
| 8 | Certificate Pinning (Network) | SHA-256 pins in network-security-config.xml | Double pin layer: APK + network |
| 9 | Process Isolation | Separate processes :p63ce7f... and :p72e6f... with fast-path | Isolates critical processes from main memory |
Why 9 layers? Defense-in-depth protection: even if an attacker breaks one layer, the others remain active. It’s like a cryptographic onion — each layer protects the inner ones.
Prerequisites: Complete tooling installation
Before any analysis, the environment must be set up. Here’s everything I use, with installation instructions:
Python 3.12 + pip (foundation)
# Ubuntu/Debian
sudo apt update && sudo apt install python3 python3-pip python3-venv -y
# Create isolated environment for analysis
python3 -m venv ~/re-env
source ~/re-env/bin/activate
Capstone — Multi-architecture disassembler
Capstone is the disassembler I use for static analysis of ARM64 binaries. It supports x86, ARM, ARM64, MIPS, PowerPC, SPARC and more. It’s the engine behind tools like radare2, Ghidra plugins, and Binary Ninja.
Why Capstone over objdump? Because Capstone provides programmatic access to operands — I can automatically resolve ADRP+ADD, read immediates, and cross-reference strings in Python code. With objdump, everything would be text parsing.
# Install via pip (includes Python bindings + C engine)
pip install capstone
# Verify installation
python3 -c "from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM; print('Capstone OK:', Cs(CS_ARCH_ARM64, CS_MODE_ARM))"
Quick test — disassemble arbitrary ARM64 bytes:
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM
# Initialize disassembler for ARM64 (AArch64)
md = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
md.detail = True # Enable detailed operand access
# Example bytes: STP X29,X30,[SP,#-0x20]! (function prologue)
code = bytes([0xfd, 0x7b, 0xbe, 0xa9])
for insn in md.disasm(code, 0x1000):
print(f"0x{insn.address:x}: {insn.mnemonic} {insn.op_str}")
for op in insn.operands:
print(f" operand type={op.type}, reg={op.reg}, imm={op.imm}")
Result:
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+ — Dynamic instrumentation
# Host-side
pip install frida-tools frida
# Device-side (Android via ADB)
# Download frida-server for 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 &"
# Verify
frida-ps -U | head -5
Unicorn — CPU emulator
pip install unicorn
python3 -c "from unicorn import Uc, UC_ARCH_ARM64; print('Unicorn OK')"
JADX — Java/Smali decompiler
# Download: https://github.com/skylot/jadx/releases
jadx --version
# jadx-gui for graphical interface
lief — ELF/PE/MachO parser
pip install lief
python3 -c "import lief; print('lief', lief.__version__)"
Phase 1: Boot flow — what happens before onCreate()
I traced the boot flow via static analysis of decompiled smali using JADX. Opening the APK in JADX-GUI and navigating to the custom Application class revealed a complex initialization sequence running before onCreate():
AppComponentFactory
→ ProtectedBankApplication.<clinit>()
→ new-array v0, 0x567 (1,383 bytes)
→ fill-array-data v0, :array_data_0
→ sput-object v0, Ej:[B // static array: key material
→ attachBaseContext(context):
→ System.loadLibrary("alice") // 458 KB — JNI dispatch, crypto, MbedTLS
→ $f.a() // APK integrity check
→ m() // SHA-256 cert verification
→ System.loadLibrary("dpboot") // 8.2 KB — DexProtector bootstrap
→ nJa() // decrypts classes.dex.dat (7.2 MB)
→ onCreate():
→ FtfFcvlCy() // returns 32-byte raw key (native)
→ loop: // S-Box derivation
key[i] = SBOX_0[ SBOX_1[ raw[i] & 0xFF ] & 0xFF ]
→ zhacB(derived_key) // activates runtime protection
Why this matters: All real Java code is encrypted inside classes.dex.dat. The nJa() method decrypts it in memory — meaning if you simply decompile the APK, you’ll only find stubs and wrappers. The real business logic doesn’t exist in the APK file.
The Ej array of 1,383 bytes (0x567) is initialized in <clinit>() — the static constructor, executed by the ClassLoader before any method. This array is high-entropy (uniform byte distribution), the signature of cryptographic material:
# analyze_smali_enc.py — extract Ej array from smali bytecode
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"First 16: {bytes(ej_bytes[:16]).hex()}")
print(f"Entropy: {len(set(ej_bytes))} unique values out of 256 possible")
Result:
Array Ej: 1383 bytes
First 16: e84bba3e9d15c742f081637a2c8db1a6
Entropy: 248 unique values out of 256 possible
248/256 unique values confirms — this is cryptographic material, not structured data.
Phase 2: Mapping native libraries with ELF analysis
The app loads 7 native .so libraries. Analyzing each reveals its role:
| Library | Size | Function |
|---|---|---|
libalice.so | 458 KB | Main lib: JNI dispatch, crypto, device fingerprint, MbedTLS, Google Breakpad |
libdexprotector.so | 356 KB | DexProtector runtime: DEX/strings decrypt (0 imports — fully self-contained) |
libdpboot.so | 8.2 KB | DexProtector bootstrap — decrypts classes.dex.dat |
libde17df.so | 52 KB | Auxiliary DexProtector module |
libiproov-*.so | 38 KB | iProov biometric (face liveness detection) |
libandroidx.graphics.path.so | 9.9 KB | AndroidX graphics helper |
The most important detail: libdexprotector.so has zero dynamic imports. It doesn’t import any function — not even malloc, memcpy, or write. Everything is implemented internally: memory allocation via direct syscalls (mmap via SVC #0), custom crypto, and I/O via prctl. This makes static analysis much harder because there are no symbols for cross-referencing.
Parsing ELF64 from libdexprotector.so
# analyze_libdexprotector.py — manual ELF64 parser
import struct
def parse_elf64(path):
with open(path, 'rb') as f:
data = f.read()
assert data[:4] == b'\x7fELF', "Not an ELF file"
assert data[4] == 2, "Not ELF64"
e_shoff = struct.unpack_from('<Q', data, 0x28)[0]
e_shentsize = struct.unpack_from('<H', data, 0x3A)[0]
e_shnum = struct.unpack_from('<H', data, 0x3C)[0]
e_shstrndx = struct.unpack_from('<H', data, 0x3E)[0]
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")
Actual execution result:
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
Analysis:
.textwith only ~10 KB — very little code for a 356 KB library. Most of the binary is something else…- ~340 KB missing — where is the rest?
The answer lies in a 5th LOAD segment hidden from standard sections:
LOAD: offset=0x3AC0, size=348,674 bytes, magic=b'DPLF'
The DPLF (DexProtector Format) segment of ~340 KB contains the encrypted payload — the real DEX, obfuscated strings, and protected resources. It’s decrypted at runtime by the ~10 KB .text section.
Phase 3: Capstone in action — Analyzing RegisterNatives with ARM64 disassembly
RegisterNatives is the JNI function that registers native methods dynamically — without using the standard naming convention Java_com_package_ClassName_methodName. DexProtector uses this to hide which C/C++ functions implement the Java methods.
How RegisterNatives works
Normally, when you declare native void foo() in Java, the JVM looks for a C function named Java_com_package_Class_foo. With RegisterNatives, the native code registers the association manually at runtime, mapping foo() to any symbol or address — making static analysis much harder.
Locating call sites with Capstone
I identified two RegisterNatives call sites in libalice.so. The complete analysis script:
# analyze_register_natives.py — discover dynamically registered methods
import struct
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM
with open('lib/arm64-v8a/libalice.so', 'rb') as f:
data = f.read()
md = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
md.detail = True
def read_cstring(data, offset):
"""Read null-terminated string from binary."""
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 ADRP+ADD pair to get absolute address.
ARM64 uses page-relative addressing (4KB):
ADRP X1, #page_offset -> X1 = (PC & ~0xFFF) + page_offset
ADD X1, X1, #byte_off -> X1 = X1 + byte_offset
Together they form the complete address of a string or variable.
"""
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
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
REGISTER_NATIVES_SITES = [
(0x29d08, "main site"),
(0x42890, "auxiliary site"),
]
for reg_addr, label in REGISTER_NATIVES_SITES:
print(f"\n{'='*60}")
print(f"RegisterNatives @ 0x{reg_addr:x} ({label})")
print(f"{'='*60}")
start = reg_addr - 0x200
code_bytes = data[start:reg_addr + 0x100]
instructions = list(md.disasm(code_bytes, start))
adrp_pages = {}
for idx, insn in enumerate(instructions):
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}"')
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):
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}")
Actual execution result — discovered JNI strings:
============================================================
RegisterNatives @ 0x29d08 (main site)
============================================================
>>> 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 (auxiliary site)
============================================================
>>> 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
What this tells us:
The main site registers 12 methods on LibBankApplication, including:
s(String) -> String— the string decryptor (the most critical function)i(int, String) -> String— the central dispatch for API calls
The auxiliary site registers 5 methods on AliceManager, including:
init(Context)— anti-fraud initializationsendAll(byte[], long) -> HttpURLConnection— telemetry transmission
Total: 17 native methods registered dynamically, all invisible to conventional static analysis.
Phase 4: Frida hooks — the complete 8-category script
With the full attack surface mapped, it’s time to instrument the app at runtime with Frida.
frida -U -f com.bankapp.cartoespj -l hooks.js --no-pause
The -f flag forces Frida to spawn the app (necessary to hook attachBaseContext), and --no-pause prevents startup deadlock.
Hook 1: String Decryption — the most important hook
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");
});
Captured runtime output (anonymized examples):
[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
This single hook reveals the entire app’s API: URLs, paths, custom headers, crypto algorithms, token names — everything hidden in the ~1,978 enc!5016 strings.
Hook 2: DexProtector Bootstrap — capturing keys
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");
};
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;
};
Captured output:
[DP-INIT] nJa() — DexProtector bootstrap
[DP-INIT] nJa() OK — classes.dex.dat decrypted
[DP-KEY] FtfFcvlCy() -> 32 bytes: 8b5b990bd84929bd0b294684ea7588dfd380b73364cc888864fe0272c3d43866
The raw key is the SHA-256 of the APK certificate. The derived key is the result of double S-Box substitution. Both are needed to understand the cipher.
Hook 3: OkHttp3 — intercepting all HTTP traffic
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);
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) {}
}
return this.newCall(request);
};
Why dynamic class search? Because ProGuard/R8 renames okhttp3.OkHttpClient to something like a7.b. The enumerateLoadedClasses iterates all loaded classes and tests for the newCall method — finding OkHttp regardless of the obfuscated name.
Hook 4: SSL Pinning Bypass
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 []; }
}
});
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);
};
Hooks 5-8: JNI Dispatch, SharedPreferences, ALICE headers, ALICE device IDs
The complete hook script also covers JNI dispatch interception (LibBankApplication.i(int, String) — logging API IDs and payloads), SharedPreferences monitoring for tokens/auth/session data, ALICE anti-fraud header capture (DEXP-HMAC-SHA256, A-Gate-Route), and ALICE device ID persistence (putLong with .iuid keys).
Combined output from all hooks during login (anonymized):
[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...
[ALICE] putLong(.iuid0xdd, 7382941625)
Phase 5: The enc!5016 format — dissecting ~1,978 encrypted strings
# analyze_enc.py — statistical analysis of enc!5016 format
import xml.etree.ElementTree as ET
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)} encrypted strings")
prefixes = [s[8:16] for s in enc_strings]
counters = [s[16:20] for s in enc_strings]
print(f"Unique prefix: {set(prefixes)}")
print(f"Counter range: {min(counters)} — {max(counters)}")
Result:
Total: 1978 encrypted strings
Unique prefix: {'4234E5BB'}
Counter range: 7696 — 7E4F
Discovered structure
enc!5016 | 4234E5BB | XX XX | [N bytes ciphertext]
| prefix | counter | encrypted data
| (fixed) | (0x7696 | variable
| | to 0x7E4F)|
Phase 6: Offline cipher cracking — 11 strategies tested
TEA-like stream cipher (extracted from Capstone disassembly)
Analyzing libdexprotector.so functions with Capstone revealed the algorithm: a TEA-like stream cipher with 32 rounds of mixing.
# disasm_cipher.py — disassemble cipher functions with 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
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
Result (excerpt):
=== cipher_decrypt @ 0xC2C ===
0x0c2c: stp x29, x30, [sp, #-0x30]!
0x0c30: mov x29, sp
0x0c3c: mov x19, x0 ; state pointer
0x0c4c: ldr w8, [x19, #0x08] ; counterA
0x0c50: ldr w9, [x19, #0x0c] ; counterB
0x0c54: eor w10, w8, w9 ; XOR counters -> seed
0x0c94: cmp w10, #0x20 ; 32 rounds
0x0c98: b.lt #0xc60 ; loop
Python reimplementation:
# crack_enc5016.py — cipher engine
import struct
def dpboot_cipher(data, table):
"""TEA-like stream cipher extracted from libdexprotector.so."""
if len(data) < 8:
return data
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))
All 11 strategies and results
| # | Strategy | Key used | Result |
|---|---|---|---|
| 1 | Simple XOR | derived_key (32 bytes) | ✗ Output not ASCII |
| 2 | XOR + counter offset | derived_key + counter as offset | ✗ |
| 3 | AES-128-ECB | derived_key[:16] | ✗ Invalid padding |
| 4 | AES-256-ECB | derived_key[:32] | ✗ Invalid padding |
| 5 | AES-CTR | prefix|counter as nonce | ✗ |
| 6 | AES-CBC | prefix|counter as IV | ✗ |
| 7 | RC4 | derived_key | ✗ Non-printable output |
| 8 | RC4 + counter seed | derived_key XOR counter | ✗ |
| 9 | TEA-like + dpboot_table | dpboot key_table, 4 IV schemes | ✗ |
| 10 | TEA-like + alice_table | alice key_table, all variations | ✗ |
| 11 | Ej array (1,383 bytes) | 5 approaches (XOR, AES, chunks) | ✗ |
Conclusion: the enc!5016 strings depend on internal state of the native runtime (libdexprotector.so) that only exists in memory during execution. The cipher is state-dependent. The Frida hook on s() remains the only viable path.
Phase 7: ARM64 emulation with Unicorn — executing functions off-device
When runtime hooking isn’t enough (or when you want to understand the exact algorithm without running the app), CPU emulation is the path.
Cipher emulation — 3 functions in sequence
# disasm_cipher.py — complete cipher emulation with Unicorn
import struct
from unicorn import Uc, UC_ARCH_ARM64, UC_MODE_ARM
from unicorn.arm64_const import *
with open("lib/arm64-v8a/libdexprotector.so", "rb") as f:
code = f.read()
BASE = 0x100000
STACK_TOP = 0x300000
BUF_IN = 0x400000
BUF_OUT = 0x500000
BUF_TABLE = 0x600000
mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
mu.mem_map(BASE, 0x3000, 7)
mu.mem_write(BASE, code[:0x2afc])
mu.mem_map(BASE + 0xa000, 0x2000, 7)
mu.mem_write(BASE + 0xac00, code[0x2c00:0x3670])
mu.mem_map(STACK_TOP - 0x10000, 0x10000, 7)
mu.mem_map(BUF_IN, 0x10000, 7)
mu.mem_map(BUF_OUT, 0x10000, 7)
mu.mem_map(BUF_TABLE, 0x1000, 7)
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
# 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)
# 2. load_table(state, table_ptr) @ 0xBE0
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)
# 3. Set counter + decrypt with zero input -> keystream
counterA = 0x89f0ec67
mu.mem_write(STATE + 0x08, struct.pack('<I', counterA))
zeros = b'\x00' * 32
mu.mem_write(BUF_IN, zeros)
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()}")
Result:
Keystream: a7f3d5c2e1b4089f6d2a3c5b7e9f0a1b...
Advanced emulation: DPLF unpacker with syscall hooking
To emulate the full DPLF unpacker, syscall hooking is required because the code makes mmap, mprotect, and prctl via SVC #0 — direct kernel calls without going through libc:
# emu_full_114c.py — syscall hooking for DPLF unpack
from unicorn import Uc, UC_ARCH_ARM64, UC_MODE_ARM, UC_HOOK_INTR
from capstone import Cs, CS_ARCH_ARM64 as CAP_ARM64, CS_MODE_ARM as CAP_MODE
MMAP_BASE = 0x1000000
mmap_next = MMAP_BASE
def hook_interrupt(uc, intno, user_data):
"""Intercept SVC (interrupt 2) and emulate Linux syscalls."""
global mmap_next
if intno != 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)
mu.hook_add(UC_HOOK_INTR, hook_interrupt)
# Run unpack @ 0x114C with 50M instruction limit
mu.emu_start(BASE + 0x114c, RETURN_ADDR, count=50_000_000)
# Validate mmap'd region contains valid ARM64
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}")
Result:
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]
Valid ARM64 instructions in mmap'd region: 48
Phase 8: Dumping memory-only libraries
libalice.so loads an additional library via dlopen() that never exists as a file on disk. It’s created via memfd_create() (creates an anonymous file descriptor) and mapped via mmap with PROT_EXEC. A modern pattern that works on Android 10+.
// Hook mmap — filter for PROT_EXEC + size > 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) {
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);
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", size: this.size}, blocks);
}
} catch(e) {}
}
}
});
Captured output:
[ELF-MMAP] Found ELF at 0x7a3f200000, size=184320
After dump, analysis with lief revealed 23 exported JNI functions including crypto operations.
Phase 9: ALICE — DexProtector’s anti-fraud system
ALICE (Advanced Licelus Intelligence Checking Engine) is DexProtector’s anti-fraud/telemetry system. It operates as extensive device fingerprinting, sending data via Protocol Buffers to the Licelus backend.
What ALICE collects (discovered via hooks)
Display: density, DPI, width, height
Build: FINGERPRINT, CPU_ABI, ID, PRODUCT, SDK_INT
uname: sysname, machine, nodename, release
/proc: cpuinfo (complete)
Sensors: ALL (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 (each camera)
Certs: subject, issuer, serial, v3ext, key_usage,
extended_key_usage (full chain)
Communication flow
App -> libalice.so: i(...) JNI dispatch
-> Collects fingerprint (sensors, build, attestation, camera, display)
-> Serializes as Protocol Buffers
-> Computes HMAC-SHA256 of 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>
-> Response validated with CT logs (secp256k1)
-> Device ID persisted -> SharedPreferences (.iuid0xdd / .iuid0xde)
Why ALICE is the biggest risk for automation: Because any SDK replicating the app must also faithfully replicate the device fingerprint — and ALICE collects everything: from accelerometer sensor model to SSL certificate v3 extensions. Emulating a fake device without ALICE detection is the hardest challenge.
From reverse engineering to automated SDK
All this reverse engineering work feeds the construction of automated SDKs that replicate the app’s behavior:
class BankSDK:
"""SDK replicating the complete authentication flow.
Each step corresponds to a real request captured via Frida."""
def device_authentication_flow(self):
# Step 0: RSA keypair generation (2048 bits)
self.generate_rsa_keypair()
# Step 1: Fraud detection SDKs init
self.init_tmx() # GET https://fp.bankdomain.com/mobile/conf
self.init_fraud_sdk() # POST https://fraud-detection.example.com/events/v3
# Step 2: Register device ID
self.register_device_id() # POST /api/ks/android/deviceId
# Step 3: Key exchange — 4 x X.509 DER certs -> AES key
self.send_certificates()
# POST multipart /api/ks/android/key
# Server returns AES key encrypted with our RSA public key
# Step 7: Generate security token
self.generate_pr_token()
# AES_ECB_PKCS7(device_fingerprint, aes_key) -> Base64
# Step 8: Login
self.login()
# POST /cfe-auth/api/v1/auth/.../login
# Body: {"cpf":"...", "password": AES_ECB(password, aes_key)}
# Step 12: Session negotiation
self.login_negocial()
# Response: JWT access_token + refresh_token
# Step 14: Query data
self.consultar_saldo()
# GET /api/saldo (Bearer: access_token)
Technical conclusions
-
enc!5016 strings are not crackable offline — the cipher is state-dependent and requires the native runtime. 11 strategies tested — all failed. Frida hook on
s()is the only viable path. -
RegisterNatives is the key — without resolving
ADRP+ADDin ARM64 with Capstone, there’s no way to know which native methods are registered. The analysis revealed 17 methods across 2 classes. -
Unicorn emulation > reimplementation — when the cipher has 32 rounds of mixing with a lookup table, mapping ELF segments and emulating directly is more reliable than reimplementing. Emulation with syscall hooking works even for the full DPLF unpacker.
-
DexProtector ≠ unbreakable — despite 9 layers, each can be attacked individually. Protection is defense-in-depth, but with Frida (dynamic instrumentation), Capstone (disassembly), and Unicorn (emulation), the entire surface is accessible.
-
ALICE anti-fraud is the biggest obstacle — collects extensive fingerprinting (sensors, attestation, camera, certs). Any automation must replicate
DEXP-HMAC-SHA256headers and the complete device fingerprint. -
Memory-only libraries —
memfd_create+mmap PROT_EXECis the modern pattern for hiding code. Hookingmmapwith ELF magic filter in Frida is the most reliable dump approach.
This article documents security research conducted in a controlled environment. Reverse engineering applications may violate terms of service. Use these techniques only for educational purposes, authorized security audits, or academic research.
Final Disclaimer: All application names, classes, packages, URLs, and endpoints presented in this article have been modified and anonymized. No real user data or credentials were exposed, collected, or stored at any point. This material reflects exclusively the technical analysis of software protection mechanisms as an academic exercise in offensive/defensive security. The author provides this content with the understanding that the reader assumes full responsibility for any use made of the information contained herein.
Rafael Cavalcanti da Silva — Fullstack Developer & Security Specialist rafaelroot.com