banner
毅种循环

毅种循环

请将那贻笑罪过以逐字吟咏 如对冰川投以游丝般倾诉

A Failed Reverse Engineering Attempt: Android 16+ Digital Reinforcement + Flutter APP Pitfalls

This article is a comprehensive review of my recent analysis of the "Certain Financial" APP (version 5.x).

A prior declaration: I have never engaged in Android reverse engineering; I am merely a beginner who can use tools. Many contents and ideas were completed with the assistance of AI, and errors are inevitable. I am very grateful.

The background of this reverse engineering is quite special: the target runs on Android 16, uses Frida 17.5.1, and the APP not only has 360 strong shell protection but also has its core business logic developed based on Flutter.

This led to an interesting phenomenon: all the "Java layer unwrapping techniques" I had prepared became ineffective, forcing me to switch to the Native layer, and ultimately needing to resolve permission and environment issues on high-version Android. To prevent myself (or future readers) from forgetting these basic operations, I recorded the most fundamental environment setup steps in the first part.


Analysis target: Android APP

Operating environment: Google Pixel (Android 16), Magisk Root

Toolchain: Frida 17.5.1, Blutter, SoFixer, JADX, Kali


Phase One: Conventional Start and Basic Unwrapping Tutorial#

After obtaining the APK, I confirmed the shell as 360 reinforcement. As per convention, when facing a first or second-generation shell, the fastest way is to use frida-dexdump to violently search and export the DEX file from memory.

image

1.1 Basic Tool: frida-dexdump User Guide#

Although I stumbled on this APP, this process is very effective against most ordinary protections, so here’s a memo.

Principle:

Although 360 reinforcement encrypts local DEX files, it must decrypt and load the DEX into memory for the virtual machine to execute when the APP runs. Frida-dexdump utilizes Frida scripts to search for the DEX file's signature header (dex\n035) in memory and then "extracts" it.

This is very effective against first-generation shells (overall reinforcement) and can also dump the overall structure for some second-generation shells (function extraction) (though the method bodies may be empty).

Here are the detailed usage steps:

1. Environment Preparation (Crucial)#

Before using frida-dexdump, you must ensure that your Frida environment is operational.

  • PC Side:
    • Install Frida toolkit: pip3 install frida-tools
    • Install DexDump: pip3 install frida-dexdump
  • Mobile/Emulator Side:
    • The phone must be Root.
    • frida-server must be running on the phone, and the version should ideally match the PC's Frida version.
    • Ensure ADB connection is normal (adb devices should see the device).

2. Basic Usage Mode#

frida-dexdump supports two main modes: Spawn (Launch Mode) and Attach (Attachment Mode).

Suitable for applications that are already running, or if you need to manually bypass some startup checks before dumping.

  1. Manually open the target App on the phone and let it stay on the main screen (at this point, the shell code has usually finished running, and the DEX has been decrypted and loaded into memory).
  2. Run the command in the computer terminal:
    Bash
# -U indicates connecting to a USB device
# -F indicates automatically attaching to the currently front-most displayed App
frida-dexdump -U -F

Or specify the package name/process name: Bash

frida-dexdump -U -n <App package name or process name>

Mode B: Spawn Mode#

Suitable for Apps that start very quickly, or if you need to intervene the moment the App starts. However, for strong shells, the Spawn mode is easily detected by anti-debugging.

Bash

# -f followed by the package name will automatically restart the App and inject
frida-dexdump -U -f com.example.targetapp

3. Common Parameters Explained#

  • -o <path> (Output): Specify the folder where the exported DEX file will be saved. If not specified, it will default to generating a folder named after the package in the current directory.
  • -d (Deep Search): Deep search mode. If the normal mode dump is incomplete or cannot be found, add this parameter. It will scan more memory segments, which will be slower but more comprehensive.
  • --sleep <seconds>: How many seconds to wait after starting the App before beginning the dump. Some shells decrypt slowly, so you can set a delay to wait for decryption to complete.

4. Result Analysis#

After successful execution, the terminal will display logs like [INFO] DexSize=xxxx, SavePath=....

  1. Open the generated folder.
  2. You will see multiple .dex files (e.g., classes.dex, classes2.dex, ...).
  3. How to identify which is the original DEX?
    • Check the size: Usually, the largest one, or one a few MB in size, is where the business logic resides.
    • Use tools to view: Open these DEX files with Jadx.
      • If you see the package name com.qihoo.util or similar, that is the shell code.
      • If you see the real package name and business code of the target App, then the unwrapping was successful.

5. frida-dexdump Precautions#

Using frida-dexdump may encounter the following situations:

  1. Can dump the structure, but methods are empty (nop): This is because some encrypted shells use function extraction technology. The DEX file structure is complete in memory, but the specific method instructions (Code Item) are empty before execution; they are temporarily decrypted and filled in when the method is executed, and erased after execution.
    • Manifestation: When opening the dumped DEX with Jadx, you can see class names and method names, but when you enter the method to view the code, it’s all empty or only has return.
    • Countermeasure: At this point, frida-dexdump is insufficient, and usually, a more advanced tool based on the ART runtime (like FART, Youpk, etc.) is needed to forcefully trigger decryption by traversing and calling all functions.
  2. Crash due to anti-debugging: If the App crashes when running frida-dexdump, it indicates detection.
    • Countermeasure: You need to first use a Frida script (like the bypass included with frida-il2cpp-bridge or a dedicated Anti-Anti-Frida script) to bypass anti-debugging, or use a modified version of frida-server (hluda).

Summary#

frida-dexdump is the first tool for unwrapping. Regardless of the shell, run it first (it’s recommended to add the -d parameter). If you’re lucky, you can directly get the code; if you’re unlucky (encountering extraction shells), you can still obtain the complete class structure, laying the groundwork for subsequent analysis.

Environment preparation:

  1. PC Side:
  2. Mobile Side:
    • The phone must be Root.
    • frida-server must be running on the phone (the version must match the PC's frida-tools).
    • Ensure ADB connection is normal (adb devices should see the device).

Operational Steps:

  1. Click to open the target APP on the phone and let it stay on the main screen.
  2. Enter the command in the computer terminal (recommended to use attachment mode for stability):
  3. If successful, the tool will generate a folder named after the package in the current directory, containing the unwrapped .dex files.

image

Expected Result: A bunch of .dex files generated in the folder. Actual Result: The terminal directly reports an error, and the process crashes.

ERROR:frida-dexdump:[-] Error: access violation accessing 0x70316bc000

Why Did It Fail?#

This is a result of the new features of Android 16 and the countermeasures of 360 reinforcement.

  1. Memory Permission Tightening: Android 16 has extremely strict management of memory page permissions.
  2. Reinforcement Countermeasures: After decrypting the DEX, 360 reinforcement deliberately sets the memory page permissions to “executable only” (x) or “non-readable”.
  3. Conflict: When frida-dexdump attempts to read this memory using readByteArray, it triggers a system Access Violation (access violation) because it lacks r (Read) permission, causing the script or application to crash.

0x02 Breaking the Deadlock: Handwritten Script for "Brute Force" Privilege Escalation#

Since standard tools cannot read due to "insufficient permissions," the next thought is clear: I am a Root user, I can forcibly modify memory permissions before reading.

I wrote a custom Frida script dump_force.js.

Core Logic#

  1. Use Process.enumerateRanges to traverse all memory segments.
  2. Use Memory.scan to violently search for the DEX file header magic number 64 65 78 0a (dex\n).
  3. Key Step: Before reading, use Memory.protect to forcibly change the memory segment to rwx (readable, writable, executable).
  4. Read the data and save it to the SD card.

Complete Code Implementation#

// dump_force.js - Brutally modify permissions unwrapping script

function write_dex(ptr, len, index) {
    // Save path: /sdcard/Download/, convenient for subsequent adb pull
    var filename = "/sdcard/Download/dump_" + ptr + "_" + len + ".dex";
    var file = new File(filename, "wb");
    
    try {
        // Attempt to read directly (if it's a normal memory segment)
        var buffer = ptr.readByteArray(len);
        file.write(buffer);
        console.log("[*] Successfully read normally: " + filename);
    } catch (e) {
        // Capture Access Violation error
        console.log("[-] Read blocked (360 protection), preparing for brute force permissions...");
        
        try {
            // 【Core Operation】Forcefully modify memory page permissions to rwx
            // ptr: starting address of memory
            // len: size
            Memory.protect(ptr, len, 'rwx');
            
            // Try reading again
            var buffer = ptr.readByteArray(len);
            file.write(buffer);
            console.log("[+] Permission modification successful! File rescued: " + filename);
        } catch (e2) {
            console.log("[!] Completely failed, possibly kernel-level protection or invalid address: " + e2);
        }
    }
    file.close();
}

function scan_dex() {
    console.log("[*] Starting full memory brute scan for DEX header...");
    
    // Traverse all "readable" memory segments (r--), which is usually where DEX is stored
    // If 360 hides DEX in non-readable areas, this parameter can be changed to '---' or 'r-x'
    Process.enumerateRanges('r--').forEach(function (range) {
        try {
            // Search for DEX signature header: dex\n (64 65 78 0a)
            // 360 may erase the version number (035), so only search the first 4 bytes
            Memory.scan(range.base, range.size, "64 65 78 0a", {
                onMatch: function (address, size) {
                    // Simple validation: DEX file offset 0x20 stores the file size
                    try {
                        var dex_size = address.add(0x20).readUInt();
                        
                        // Filter out garbage data that is too small (less than 100KB)
                        if (dex_size > 100000 && dex_size < 50000000) {
                            console.log("[+] Found DEX magic number: " + address + " Size: " + dex_size);
                            // Execute export
                            write_dex(address, dex_size, address.toString());
                        }
                    } catch (e) {}
                },
                onError: function (reason) {},
                onComplete: function () {}
            });
        } catch (e) {}
    });
}

setImmediate(scan_dex);

Execution Result#

After running the command frida -U -F -l dump_force.js, the terminal displayed a series of green [+] permission modification successful messages.

Then I used adb pull /sdcard/Download/ . to retrieve the files back to the computer, obtaining several key files:

  • dump_0x70316bc000.dex (about 8MB)
  • dump_0x7031655480.dex (about 6.7MB)

At this step, I overcame the permission restrictions of Android 16 and obtained the encrypted memory image.


0x03 The Fog: A "Hollow Shell" That Looks Perfect#

Since the file was dumped from memory, the checksum at the file header is usually incorrect. Directly dragging it into JADX will result in an error.

I wrote a simple Python script to fix the checksum and then dragged the 8MB DEX file into JADX.

import zlib
import struct
import os
import glob

def fix_dex_checksum(filename):
    with open(filename, 'rb') as f:
        data = bytearray(f.read())

    # DEX files must have header information
    if len(data) < 12:
        print(f"[-] {filename} is too small, skipping.")
        return

    # 1. Fix file length (FileSize) - Offset 32 (0x20), 4 bytes
    # Some dumped files may have a different length than what's in the header, fix it
    file_size = len(data)
    struct.pack_into('<I', data, 32, file_size)

    # 2. Fix signature (Signature) - Offset 12 (0xC), 20 bytes (SHA-1)
    # JADX sometimes does not check the signature but checks the checksum, but to be safe, standard fixes usually do SHA1
    # 3. Fix checksum (Checksum) - Offset 8 (0x8), 4 bytes
    # Checksum range: from offset 12 to the end of the file
    # Algorithm: Adler-32
    ignored_part = data[0:12]
    checksum_part = data[12:]
    
    new_checksum = zlib.adler32(checksum_part) & 0xFFFFFFFF
    
    # Write the new checksum to offset 8 (little-endian)
    struct.pack_into('<I', data, 8, new_checksum)

    # Save and overwrite the original file
    with open(filename, 'wb') as f:
        f.write(data)
    
    print(f"[+] Fixed Checksum for: {filename}")

# Scan the current directory for all dump_force prefixed dex files
dex_files = glob.glob("dump_force_*.dex")

if not dex_files:
    print("No dump_force_*.dex files found in current directory.")
else:
    print(f"Found {len(dex_files)} dex files. Fixing...")
    for dex in dex_files:
        try:
            fix_dex_checksum(dex)
        except Exception as e:
            print(f"[-] Error fixing {dex}: {e}")

    print("Done! Try opening them in JADX now.")

JADX successfully decompiled, and the package structure on the left side was very clear:

  • android.support.*
  • com.qihoo.util.* (shell code)
  • cn.com.ljzxx.* (target package name)

But when I excitedly clicked on cn.com.ljzxxc under the business classes, I was dumbfounded:

Java

// LoginActivity.java
public class LoginActivity extends Activity {
    public void onCreate(Bundle bundle) {
        // Empty! Or only one line super.onCreate(bundle);
    }
    
    public void login() {
        // Empty!
    }
}

All method bodies are either empty, or contain only nop instructions, or just simple return.

Thoughts Here#

This aligns very well with the characteristics of “second-generation shells (function extraction/class extraction)”.

  • Principle: The reinforcement vendor extracts the specific instructions (Code Item) from the DEX, filling in empty data. Only when this method is actually called does the shell code intercept the execution flow, decrypting instructions from elsewhere (usually an encrypted bin file), filling them back in, executing, and then erasing them.
  • Conclusion: What I dumped was an "unfilled" skeleton.

0x04 Struggle: The Dead End of FART Active Calls#

Since it’s “on-demand recovery,” the cracking idea is “actively trigger”. This is the principle of the famous FART (Fast Android Art Unpacker).

As long as I write a script to load all classes in the APP, touching all functions will force the shell to decrypt the code, and I can take the opportunity to dump.

I wrote a Frida-based FART simulation script fart_memory.js:

// fart_memory.js - Simulate FART active calls
Java.perform(function() {
    console.log("[*] Starting to traverse classes in memory...");
    
    // 1. Traverse all loaded classes
    Java.enumerateLoadedClasses({
        onMatch: function(name) {
            // Filter: only process target package name
            if (name.startsWith("cn.com.ljz")) {
                console.log("[*] Attempting to warm up class: " + name);
                try {
                    // 2. Forcefully load class
                    var clazz = Java.use(name);
                    // 3. Reflectively get all methods to trigger the shell's decryption logic
                    var methods = clazz.class.getDeclaredMethods();
                    console.log("    - Decryption trigger successful, number of methods: " + methods.length);
                } catch (e) {
                    console.log("    - Failed: " + e);
                }
            }
        },
        onComplete: function() {
            console.log("[SUCCESS] Traversal complete, please dump immediately!");
        }
    });
});

The result was despairing:

After running the script, it only printed a few classes:

  • cn.com.lj.R$id
  • cn.com.lj.R$layout
  • cn.com.lj.BuildConfig

There were no LoginActivity, no Utils, no business classes at all.

This means these core classes were either not loaded into the Java virtual machine at all, or 360 used some method (like a custom ClassLoader) to hide them from the list of enumerateLoadedClasses.

I then tried a lower-level Java.choose("dalvik.system.DexFile", ...) to scan heap memory, but it reported an error because Android 16 removed the relevant API.


0x05 Result: The Wrong Direction!#

I spent an entire day tinkering at the Java layer, trying unwrapping, fixing, active calls, and memory scanning, yet still ended up with a hollow shell.

Suspicion: If the core code has been extracted and not loaded, then who is rendering the current APP interface? Who is drawing the login box?

I re-examined that 8MB DEX file. Although the code was empty, I decided to check the string constant pool inside.

I used the strings command (or Notepad) to search the file content, and suddenly, an unremarkable path caught my eye:

/data/app/~~xxx/cn.com.ljz.../lib/arm64/libapp.so

libapp.so? This is a non-standard SO naming.

I immediately unzipped the original APK and opened the lib/arm64-v8a/ directory.

At that moment, the truth was revealed:

image

libflutter.so   <-- Engine
libapp.so       <-- Business code

This is a Flutter application!

Final Conclusion#

  • Why is the DEX empty? Because the business logic of the Flutter application (Dart code) is AOT compiled into machine code and placed in libapp.so. The DEX only contains the startup bootstrap code for the Java layer, which inherently has little content.
  • What did 360 protect? 360 indeed added a shell, but it mainly protects the Java layer (though it's not very useful) and encrypted libapp.so.
  • My mistake: I kept trying to strip off its clothes to find the flesh, only to discover that beneath the clothes was a mechanical skeleton, completely misunderstanding the target.

Next steps:

Abandon DEX, abandon FART.

Target locked: libapp.so. We need to dump this file from memory and then perform Dart reverse engineering.


We will shift our perspective from the dead end of the Java layer to the Native layer and the deep waters of the Flutter virtual machine. This part of the battle is even more hardcore, involving memory black holes, environment dependency hell, and pointer correction, among other low-level details.

After much effort using the brute force privilege escalation script to dump the DEX file, I found it was all a hollow shell. By analyzing the residual strings in the file, I confirmed this is a Flutter application. The real business logic is hidden in the Native layer's libapp.so.

Now the task is very clear:Get ****libapp.so**** -> Restore Dart code -> Hook to obtain plaintext.

But this is much more challenging under the combination of Android 16 + 360 reinforcement than I imagined.


Phase Two: 0x01 Attempt 1: Extracting libapp.so's Three Major Challenges#

360 reinforcement typically protects SO files through "compression + encryption + memory loading." The directly unzipped APK yields an encrypted file, which must be dumped from memory.

I wrote a Frida script to dump, but the script crashed as soon as it started running.

Challenge One: The "Disappearance" of APIs#

On Android 16, the commonly used Frida API Module.findBaseAddress("libapp.so") unexpectedly threw a TypeError: not a function or could not find the module.

Reason: Android 16 made some changes to the Linker, causing compatibility issues with some of Frida's upper-level APIs.

Solution: I was forced to use the lower-level Process.findModuleByName("libapp.so"), which still worked.

Challenge Two: Memory "Black Holes"#

After resolving the base address issue, I attempted to read the entire memory size of the SO file (readByteArray(module.size)). The script crashed again, reporting Access Violation.

Reason: 360 reinforcement, after loading the SO, will cancel the mapping of the ELF header (Header) or certain unused sections' memory pages or set them to non-readable. Attempting to read the entire file at once will trigger a crash as soon as it encounters these "black hole" pages.

Countermeasure: Chunked Fault-Tolerant Reading

I rewrote the script to read in chunks, like slicing sausage, reading only 4KB (one page) at a time.

  • Read it? Write it to the file.
  • Can't read it (error)? Fill in 4KB of 0x00 placeholders, skipping this page.
    This ensures that the file does not crash while keeping the offset correct—this is crucial for subsequent analysis.

Challenge Three: "Home" with Nowhere to Place It#

The script attempted to write the file to /data/local/tmp, but reported Permission denied.

Reason: Android 16's SELinux policy has become excessively strict, and ordinary APP processes (after Frida injection, belong to the APP process) have no right to write to this public directory.

Solution: Use Java reflection to obtain the APP's private directory getFilesDir(), as writing files back home should not be a problem!

🎯 Final Result Script (dump_so_final.js)#

var target_so = "libapp.so";

Java.perform(function() {
    // 1. Compatibility module lookup
    var module = Process.findModuleByName(target_so);
    
    // 2. Get private directory path (Avoid pitfalls of Android 16)
    var currentApp = Java.use("android.app.ActivityThread").currentApplication();
    var save_path = currentApp.getApplicationContext().getFilesDir().getAbsolutePath() + "/" + target_so + ".dump";
    var file = new File(save_path, "wb");

    // 3. Chunked anti-crash reading
    var currentPtr = module.base;
    var remaining = module.size;
    var pageSize = 4096;

    console.log("[*] Starting chunked extraction, automatically filling 0 on bad blocks...");
    while (remaining > 0) {
        try {
            var buffer = currentPtr.readByteArray(pageSize);
            file.write(buffer);
        } catch (e) {
            // Encountered a pit dug by 360, fill with 0 to keep the offset correct
            file.write(new Uint8Array(pageSize).buffer);
        }
        currentPtr = currentPtr.add(pageSize);
        remaining -= pageSize;
    }
    file.close();
    console.log("[Success] Extraction complete! Path: " + save_path);
});

After running the script, I used Root permissions to copy this 13MB file from the private directory to the SD card, finally bringing it back to the computer.


0x02 Attempt 2: Blutter and Environment Configuration#

Having obtained libapp.so.dump, this is just binary machine code (Dart AOT Snapshot). Without restoring symbols, it would be like reading a foreign language. I need Blutter to help translate the machine code back into Dart pseudocode.

"Deterrence" of Windows Environment#

I first tried to run Blutter on Windows.

  • CMake Error: Compiler not found.
  • Ninja not found: Build tools not found.
  • Most despairingly, Failed to find all ICU components. The Dart VM's compilation depends on the ICU library, and configuring this on Windows is a nightmare.

Decision: Don’t waste time configuring the environment on Windows.

I decisively opened Kali. Setting up the environment on Linux only requires one command:

Bash

# One-click installation of all compilation dependencies
sudo apt update && sudo apt install -y ninja-build build-essential cmake pkg-config libicu-dev git

Repair and Analysis#

Since the file was dumped from memory, the ELF header is damaged, and directly feeding it to Blutter will result in an error.

  1. Fix the header: Use the SoFixer tool: SoFixer -s libapp.so.dump -o libapp.so.
  2. Prepare the engine: Unzip libflutter.so from the original APK.
  3. Start analysis: python3 blutter.py input output.

Watching the progress bar roll in the Kali terminal, I knew it was stable.

A few minutes later, a bunch of familiar filenames appeared in the output/asm directory:

  • loginPwd.dart
  • encrypt.dart
  • Authentication.dart

image

Opening loginPwd.dart, I not only saw the logical structure but also key information:

image


0x03 Finally: I'm Out of Options#

I prepared to dynamically load JS for debugging. No matter how I tried, I couldn't get the plaintext return from the hexdump after logging in, so I gave up.
My initial goal was just to audit code vulnerabilities, and I never expected to stray so far.

image

I found it difficult later on, losing some images and content in between... I also forgot.

In short, the pitfall is that once I realized it was Flutter, I should have just run away; after Flutter compiles, I can only hook the binary stream to dump it.

This might be unsolvable, especially with the reinforcement.


📝 Review Summary#

Looking back at the entire process, if I had known from the start that it was Flutter, I could have skipped the dozens of hours of hassle in the first and second chapters.

The biggest lesson from this reverse engineering is: Do not waste time on the wrong battlefield.

  1. Identify Architecture: If the DEX comes out empty, check immediately if there is libflutter.so or libcocos.so in the lib directory. If it’s Flutter, directly abandon Java layer analysis.
  2. Environment Adaptation: Android 16 is not very friendly to Frida's compatibility. When standard APIs (Module.findBaseAddress) fail, flexibly try lower-level APIs (Process). When public directories cannot be written to, think of using the App's private directory.
  3. Toolchain Combination: Frida (dynamic extraction) + SoFixer (fix header) + Blutter (restore symbols).
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.