← Back to Blog
· 45 min read ·

Breaking 9 layers of DexProtector: Real-world reverse engineering of an Android banking app

Advanced reverse engineering analysis of a mobile banking app protected by DexProtector with 9 layers: encrypted DEX, ~2000 enc!5016 strings, JNI dispatch with 148 overloads, dynamic RegisterNatives, TEA-like stream cipher, ARM64 emulation with Unicorn, memory-only .so dump, ALICE anti-fraud bypass — all documented with real code and results.

#reverse-engineering#frida#android#jni#dexprotector#unicorn#arm64#capstone#banking#security#cyber-security#pentesting
Share

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:

LayerProtectionMechanismPurpose
1Certificate Pinning (APK)SHA-256 cert check in m() before any initPrevents traffic analysis via proxy
2Native BootstrapSystem.loadLibrary("dpboot")nJa() → decrypts classes.dex.datReal Java code hidden until runtime
3S-Box Key DerivationFtfFcvlCy() → double S-Box substitution → 32-byte key → zhacB()Derived keys resist static extraction
4Native App LibrarySystem.loadLibrary("alice") → integrity check → RegisterNativesRegisters native methods without naming convention
5DEX Encryptionclasses.dex.dat (7.2 MB) — ALL real app code, encryptedJava bytecode inaccessible via normal decompilation
6String Encryption~1,978 enc!5016... strings decrypted via JNI s()URLs, tokens, keys — everything encrypted
7JNI Dispatch148 overloads of LibBankApplication.i(int, ...)All server communication via native dispatch
8Certificate Pinning (Network)SHA-256 pins in network-security-config.xmlDouble pin layer: APK + network
9Process IsolationSeparate processes :p63ce7f... and :p72e6f... with fast-pathIsolates 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:

LibrarySizeFunction
libalice.so458 KBMain lib: JNI dispatch, crypto, device fingerprint, MbedTLS, Google Breakpad
libdexprotector.so356 KBDexProtector runtime: DEX/strings decrypt (0 imports — fully self-contained)
libdpboot.so8.2 KBDexProtector bootstrap — decrypts classes.dex.dat
libde17df.so52 KBAuxiliary DexProtector module
libiproov-*.so38 KBiProov biometric (face liveness detection)
libandroidx.graphics.path.so9.9 KBAndroidX 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:

  • .text with 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 initialization
  • sendAll(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

#StrategyKey usedResult
1Simple XORderived_key (32 bytes)✗ Output not ASCII
2XOR + counter offsetderived_key + counter as offset
3AES-128-ECBderived_key[:16]✗ Invalid padding
4AES-256-ECBderived_key[:32]✗ Invalid padding
5AES-CTRprefix|counter as nonce
6AES-CBCprefix|counter as IV
7RC4derived_key✗ Non-printable output
8RC4 + counter seedderived_key XOR counter
9TEA-like + dpboot_tabledpboot key_table, 4 IV schemes
10TEA-like + alice_tablealice key_table, all variations
11Ej 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

  1. 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.

  2. RegisterNatives is the key — without resolving ADRP+ADD in ARM64 with Capstone, there’s no way to know which native methods are registered. The analysis revealed 17 methods across 2 classes.

  3. 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.

  4. 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.

  5. ALICE anti-fraud is the biggest obstacle — collects extensive fingerprinting (sensors, attestation, camera, certs). Any automation must replicate DEXP-HMAC-SHA256 headers and the complete device fingerprint.

  6. Memory-only librariesmemfd_create + mmap PROT_EXEC is the modern pattern for hiding code. Hooking mmap with 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