Author: admin

  • Android RE Lab: Dynamically Altering App Logic with Frida Argument Manipulation

    Introduction to Dynamic Android App Logic Manipulation with Frida

    In the realm of Android application reverse engineering and penetration testing, the ability to dynamically alter an application’s behavior at runtime is an invaluable skill. Frida, a dynamic instrumentation toolkit, stands out as a powerful tool for achieving this. Unlike static analysis which examines the code without execution, dynamic analysis with Frida allows us to observe, intercept, and modify an application’s execution flow, method calls, and data in real-time. This article delves into a core technique: manipulating method arguments and return values to bypass checks, force desired outcomes, or explore hidden functionalities.

    Understanding how to hook into specific Java methods, inspect their input arguments, and modify their return values empowers security researchers and developers to gain unprecedented control over an application’s runtime state. This can be crucial for bypassing authentication, decrypting obfuscated data, or understanding complex proprietary algorithms.

    Setting Up Your Android Reverse Engineering Lab

    Prerequisites

    • A rooted Android device or an emulator (e.g., Android Studio AVD, Genymotion).
    • Android Debug Bridge (ADB) installed on your host machine.
    • Frida command-line tools installed on your host machine (`pip install frida-tools`).
    • Frida server binary suitable for your Android device’s architecture (ARM, ARM64, x86, x86_64).
    • Basic understanding of Java/Kotlin and Android application structure.
    • A target Android application (we’ll use a hypothetical one for demonstration).

    Frida Server Installation on Android

    First, download the correct Frida server binary from Frida’s GitHub releases. Match the architecture (e.g., `frida-server-*-android-arm64`) with your device.

    Push the server to your device and make it executable:

    adb push frida-server /data/local/tmp/frida-server
    adb shell "chmod +x /data/local/tmp/frida-server"

    Now, start the Frida server. It’s often best to run it in the background:

    adb shell "/data/local/tmp/frida-server &"

    Verify Frida is running and can see processes:

    frida-ps -U

    You should see a list of processes running on your Android device.

    Identifying and Hooking Target Methods

    Before we can manipulate arguments or return values, we need to identify the specific method we want to target. Static analysis tools like Jadx-GUI or Ghidra are excellent for disassembling APKs and finding interesting classes and methods. For our demonstration, let’s assume we’ve identified a class `com.example.app.Authenticator` and a method `checkPin(java.lang.String pin)`. This method returns a boolean indicating if the PIN is correct.

    Frida scripts are written in JavaScript and interact with the Java Virtual Machine (JVM) using `Java.perform`.

    Java.perform(function() {
        // Code to interact with the Android app's Java environment goes here
    });

    To hook a method, we use `Java.use()` to get a wrapper around the target class:

    var Authenticator = Java.use('com.example.app.Authenticator');

    Then, we can replace the method’s implementation with our own. Frida provides `onEnter` and `onLeave` callbacks for this:

    • `onEnter(log, args, state)`: Executed just before the original method is called. You can inspect and modify `args` here.
    • `onLeave(log, retval, state)`: Executed just after the original method has returned. You can inspect and modify `retval` here.

    Scenario 1: Modifying Method Arguments

    Imagine our target application has a method `checkPin(String pin)` that validates a user-entered PIN. We don’t know the correct PIN, but we can intercept the method call and replace the user’s incorrect input with a known correct PIN (e.g., “1234”).

    Frida Script for Argument Manipulation

    Let’s create a script `modify_pin.js`:

    Java.perform(function () {
        console.log("[*] Starting PIN modification script...");
    
        var Authenticator = Java.use('com.example.app.Authenticator');
    
        Authenticator.checkPin.overload('java.lang.String').implementation = function (pin) {
            console.log("[+] Original PIN provided: " + pin);
    
            // Modify the argument to a known correct PIN
            var correctPin = "1234"; // Assuming "1234" is the correct PIN
            console.log("[+] Modifying PIN to: " + correctPin);
    
            // Call the original method with the modified argument
            var result = this.checkPin(correctPin);
    
            console.log("[+] checkPin() returned: " + result);
            return result;
        };
    
        console.log("[*] PIN modification script loaded.");
    });

    Explanation:

    • `Authenticator.checkPin.overload(‘java.lang.String’).implementation`: This specifies we are targeting the `checkPin` method that accepts a single `java.lang.String` argument. `overload` is crucial for methods with multiple signatures.
    • `function (pin)`: This is our new implementation. `pin` is the original argument passed to the method.
    • `var correctPin = “1234”;`: We define our desired new argument value.
    • `this.checkPin(correctPin);`: We call the *original* implementation of `checkPin`, but this time we pass our `correctPin` instead of the user’s input.

    Running the Script

    First, find the package name of your target app (e.g., `com.example.app`):

    frida-ps -U | grep com.example.app

    Then, inject the script:

    frida -U -f com.example.app -l modify_pin.js --no-pause

    The `–no-pause` flag means the app will start immediately after the script is injected. Now, when you interact with the app and enter any PIN, Frida will intercept the call to `checkPin`, change the argument to “1234”, and the app will likely behave as if the correct PIN was entered.

    Scenario 2: Modifying Method Return Values

    Sometimes, we don’t need to change the input; instead, we want to force a specific outcome regardless of the original logic. For instance, making `checkPin()` always return `true` to bypass authentication entirely.

    Frida Script for Return Value Manipulation

    Let’s create a script `force_true.js`:

    Java.perform(function () {
        console.log("[*] Starting return value modification script...");
    
        var Authenticator = Java.use('com.example.app.Authenticator');
    
        Authenticator.checkPin.overload('java.lang.String').implementation = function (pin) {
            console.log("[+] Original PIN provided: " + pin);
    
            // Call the original method first to see its original return value
            var originalResult = this.checkPin(pin);
            console.log("[+] Original checkPin() returned: " + originalResult);
    
            // Force the return value to true
            var modifiedResult = true;
            console.log("[+] Forcing checkPin() to return: " + modifiedResult);
    
            return modifiedResult;
        };
    
        console.log("[*] Return value modification script loaded.");
    });

    Explanation:

    • `var originalResult = this.checkPin(pin);`: We still call the original method. This is often good practice if the method has side effects or we just want to observe its original behavior. If side effects are not desired, you could skip calling `this.checkPin(pin);` and simply `return true;`.
    • `var modifiedResult = true;`: We define our desired return value.
    • `return modifiedResult;`: This is the crucial part. We return our custom value, effectively overriding whatever the original method would have returned.

    Running the Script

    frida -U -f com.example.app -l force_true.js --no-pause

    Now, regardless of the PIN entered, the `checkPin` method will always return `true`, effectively bypassing the authentication mechanism.

    Advanced Considerations

    Handling Method Overloads

    When a class has multiple methods with the same name but different argument types (method overloading), `Java.use().methodName` alone is ambiguous. You must specify the exact overload using `.overload()` followed by the full signature:

    // Example: Two 'log' methods
    // public void log(String message)
    // public void log(String tag, String message)
    
    var MyClass = Java.use('com.example.app.MyClass');
    
    MyClass.log.overload('java.lang.String').implementation = function (msg) {
        // ... handle single string log ...
    };
    
    MyClass.log.overload('java.lang.String', 'java.lang.String').implementation = function (tag, msg) {
        // ... handle tag and message log ...
    };

    `onEnter` vs. `onLeave` for Return Value Manipulation

    While we demonstrated return value modification in the `implementation` (which acts like `onLeave` if you consider the `return` statement), Frida also offers explicit `onEnter` and `onLeave` callbacks for more granular control:

    var MyClass = Java.use('com.example.app.MyClass');
    
    MyClass.someMethod.implementation = function (arg1, arg2) {
        // This 'implementation' function acts as both onEnter and onLeave combined.
        // 'arg1', 'arg2' are like 'args' in onEnter.
    
        // -- Logic equivalent to onEnter --
        console.log("onEnter: arg1 = " + arg1);
        // Modify args if needed: this.someMethod(newArg1, newArg2);
    
        // Call original method
        var retval = this.someMethod(arg1, arg2); 
    
        // -- Logic equivalent to onLeave --
        console.log("onLeave: original retval = " + retval);
        // Modify retval if needed:
        retval = !retval; // Invert boolean result
    
        return retval;
    };
    

    This unified `implementation` is often cleaner for simple modifications. For more complex scenarios, especially when you need to store state between `onEnter` and `onLeave`, explicit callbacks are beneficial (though not shown in this specific example for brevity and focus).

    Conclusion

    Frida’s ability to dynamically alter method arguments and return values provides an incredibly potent capability for Android app reverse engineering and penetration testing. By understanding and applying these techniques, security professionals can bypass critical security controls, debug complex application logic, and uncover vulnerabilities that might be difficult to identify through static analysis alone. This control over runtime behavior transforms a black-box application into a transparent system, revealing its inner workings and potential weaknesses.

  • Frida Hooking: Live Modify Android Method Arguments and Return Values

    Introduction to Runtime Manipulation with Frida

    Frida is an incredibly powerful dynamic instrumentation toolkit that allows developers, researchers, and penetration testers to inject custom scripts into running processes. For Android app penetration testing, Frida enables unparalleled control over an application’s behavior at runtime. This article delves into a critical aspect of Frida hooking: the ability to live modify method arguments and return values. This technique is indispensable for bypassing client-side security checks, altering application flow, and understanding hidden functionalities without needing to decompile, modify, and recompile the APK.

    By directly manipulating data passed into and out of methods, testers can force specific conditions, unlock premium features, bypass authentication, or observe how an application reacts to unexpected inputs, all in real-time on a live system.

    Prerequisites

    • A rooted Android device or an emulator (e.g., Genymotion, Android Studio Emulator) with root access.
    • Frida server installed and running on the Android device.
    • Frida tools (frida-tools) installed on your host machine (pip install frida-tools).
    • Basic understanding of Java/Kotlin and Android application structure.
    • A target Android application for experimentation. For this tutorial, we’ll imagine a simple app with functions like checkLicense and isPremiumUser.

    Setting Up Your Environment

    Ensure your Frida server is running on the Android device. You can verify connectivity by running frida-ps -U on your host machine, which should list running processes on your device.

    adb shell
    su
    /data/local/tmp/frida-server &

    Then, on your host machine:frida-ps -U

    This command should output a list of processes running on your Android device. If you see the list, your setup is correct.

    Understanding Frida’s Java API for Android Hooking

    Frida provides a robust JavaScript API to interact with the Java Virtual Machine (JVM) running on Android. The core components for method hooking are:

    • Java.perform(function() { ... });: This function ensures your script runs in the context of the Java VM. All Java-related operations must be enclosed within this block.
    • Java.use('package.ClassName');: This allows you to obtain a JavaScript wrapper for a specific Java class, enabling you to access its methods.
    • .implementation = function(...) { ... };: This is where you define your hook logic. Inside this function, you can access original arguments and modify them before the original method execution, or modify return values after execution.
    • this.methodName.apply(this, arguments);: Calls the original implementation of the method. It’s crucial for maintaining original functionality when you only want to inspect or modify without completely replacing the method logic.

    Scenario 1: Modifying Method Arguments

    Let’s imagine our target application has a utility class com.example.fridatutorial.Utils with a static method checkLicense(String licenseKey) that validates a license key. We want to bypass this check by feeding it a valid key, even if the user inputs an invalid one.

    Target Java Code Example (Conceptual)

    package com.example.fridatutorial;
    
    public class Utils {
        public static boolean checkLicense(String licenseKey) {
            // A simplified license check
            return licenseKey.startsWith("PREMIUM-") && licenseKey.length() > 10;
        }
    }

    Frida Script to Modify Arguments (hook_license.js)

    We’ll hook checkLicense and force the licenseKey argument to be a specific valid string.

    Java.perform(function () {
        console.log("[*] Script loaded: Modifying license check argument.");
    
        // Get a wrapper for the Utils class
        var Utils = Java.use('com.example.fridatutorial.Utils');
    
        // Hook the checkLicense method
        Utils.checkLicense.implementation = function (licenseKey) {
            console.log("[+] Original licenseKey argument: " + licenseKey);
            
            // Modify the argument to a known valid key
            var modifiedLicenseKey = "PREMIUM-VALIDKEY123";
            console.log("[+] Modified licenseKey to: " + modifiedLicenseKey);
            
            // Call the original method with the modified argument
            var result = this.checkLicense(modifiedLicenseKey);
    
            console.log("[*] checkLicense returned: " + result);
            return result;
        };
        console.log("[*] Hooked com.example.fridatutorial.Utils.checkLicense.");
    });

    Executing the Script

    Assuming your target app’s package name is com.example.fridatutorial:

    frida -U -f com.example.fridatutorial -l hook_license.js --no-pause

    When you run the app and trigger the license check, even if you enter an invalid key in the UI, Frida will intercept the call to checkLicense, replace your input with "PREMIUM-VALIDKEY123", and the method will likely return true. The console output will show the modification in real-time.

    Scenario 2: Modifying Method Return Values

    Now, let’s consider a method that returns a boolean indicating a user’s premium status. We want to always make the application believe the user is premium, regardless of their actual status.

    Target Java Code Example (Conceptual)

    package com.example.fridatutorial;
    
    import android.util.Log;
    
    public class MainActivity extends AppCompatActivity {
        // ... other code ...
    
        public boolean isPremiumUser(String userId) {
            Log.d("FridaTutorial", "Checking premium status for user: " + userId);
            // A simplified check
            return userId.equals("admin") || userId.equals("premium_user_123");
        }
    }

    Frida Script to Modify Return Values (hook_premium.js)

    Java.perform(function () {
        console.log("[*] Script loaded: Modifying isPremiumUser return value.");
    
        var MainActivity = Java.use('com.example.fridatutorial.MainActivity');
    
        MainActivity.isPremiumUser.implementation = function (userId) {
            console.log("[+] Original userId argument: " + userId);
            
            // Call the original method to see its original return value
            var originalResult = this.isPremiumUser(userId);
            console.log("[+] Original isPremiumUser returned: " + originalResult);
            
            // Force the return value to true
            var modifiedResult = true;
            console.log("[+] Forcing isPremiumUser to return: " + modifiedResult);
            
            return modifiedResult;
        };
        console.log("[*] Hooked com.example.fridatutorial.MainActivity.isPremiumUser.");
    });

    Executing the Script

    frida -U -f com.example.fridatutorial -l hook_premium.js --no-pause

    Any call to isPremiumUser will now return true, effectively granting premium access within the application. The console will log both the original and the modified return values, demonstrating the bypass.

    Scenario 3: Modifying Both Arguments and Return Values (Complex Example)

    Sometimes, you might want to adjust an input and then further modify the output based on your testing goals. Consider a method that calculates a ‘premium’ value based on a base input.

    Target Java Code Example (Conceptual)

    package com.example.fridatutorial;
    
    public class Utils {
        // ... checkLicense ...
    
        public static int calculatePremiumValue(int baseValue) {
            return baseValue * 10;
        }
    }

    Frida Script (hook_calc.js)

    We’ll modify the `baseValue` to always be 100, and then ensure the return value is always 500, regardless of the original calculation.

    Java.perform(function () {
        console.log("[*] Script loaded: Modifying calculatePremiumValue.");
    
        var Utils = Java.use('com.example.fridatutorial.Utils');
    
        Utils.calculatePremiumValue.implementation = function (baseValue) {
            console.log("[+] Original baseValue argument: " + baseValue);
            
            // Modify the argument before calling the original method
            var forcedBaseValue = 100;
            console.log("[+] Forcing baseValue to: " + forcedBaseValue);
            
            // Call the original method with the forced argument
            var originalCalculatedResult = this.calculatePremiumValue(forcedBaseValue);
            console.log("[+] Original calculation with forced input returned: " + originalCalculatedResult);
    
            // Now, modify the return value itself
            var finalResult = 500;
            console.log("[+] Overriding return value to: " + finalResult);
    
            return finalResult;
        };
        console.log("[*] Hooked com.example.fridatutorial.Utils.calculatePremiumValue.");
    });

    Executing the Script

    frida -U -f com.example.fridatutorial -l hook_calc.js --no-pause

    This script demonstrates the full power of `implementation`, allowing fine-grained control over both input and output of a function. This is particularly useful for controlling complex state machines or financial calculations in applications.

    Important Considerations

    • Overloaded Methods: If a class has multiple methods with the same name but different argument types (overloaded methods), you might need to specify the signature when calling Java.use to target the correct method. For example: Utils.someMethod.overload('java.lang.String', 'int').implementation = function(...) { ... };
    • `this` Context: Inside an implementation function, this refers to the instance of the class (for non-static methods) or the class itself (for static methods). You can access other fields or methods of the object using this.fieldName or this.methodName().
    • Error Handling: Always include try-catch blocks in your Frida scripts for robustness, especially when dealing with complex object manipulations, to prevent the hooked application from crashing.
    • Logging: Extensive use of console.log() is crucial for debugging your Frida scripts and observing the runtime behavior changes.

    Conclusion

    Frida’s ability to live modify Android method arguments and return values opens up a world of possibilities for security testing and reverse engineering. By understanding and applying these techniques, penetration testers can effectively bypass security controls, force desired application states, and gain deep insights into an application’s internal logic without needing source code modifications. Mastering argument and return value manipulation is a fundamental skill for any advanced Android app penetration tester leveraging Frida.

  • Practical Scenario: Intercepting Cryptographic Calls in Android ARM64 Native Libraries with Frida

    Introduction

    Android applications often utilize native libraries (written in C/C++ and compiled to ARM64 for modern devices) for performance-critical operations, including complex cryptographic computations. When performing security assessments, understanding and manipulating these native cryptographic calls is paramount. Unlike Java methods, which are easily hooked with frameworks like Frida due to their well-defined JNI interfaces, intercepting functions within stripped or obfuscated native ARM64 libraries presents a unique set of challenges. This expert-level guide will walk you through the practical steps of identifying and intercepting cryptographic functions within ARM64 native libraries on Android using Frida, providing concrete examples and essential ARM64 calling convention insights.

    Prerequisites and Environment Setup

    Before diving into the hooking process, ensure you have the following:

    • Rooted Android Device or Emulator: Necessary for running Frida server with full privileges.
    • ADB (Android Debug Bridge): For interacting with your Android device.
    • Frida: Installed on your host machine (pip install frida-tools).
    • Frida Server: The correct ARM64 version pushed and running on your Android device.
    • Basic Understanding of ARM64 Assembly: Familiarity with registers and calling conventions will greatly aid analysis.
    • Optional: Static Analysis Tool: Ghidra or IDA Pro for deeper understanding of native binaries (though we’ll focus on dynamic).

    Frida Server Setup:

    Download the appropriate frida-server for your device’s architecture (e.g., frida-server-*-android-arm64) from the Frida releases page. Then, push and execute it on your device:

    adb push /path/to/frida-server-*-android-arm64 /data/local/tmp/frida-serveradb shell "chmod 755 /data/local/tmp/frida-server"adb shell "/data/local/tmp/frida-server &"

    Identifying Native Cryptographic Functions

    The first step is to locate the target cryptographic functions within the native library. Unlike Java APIs, native libraries might not explicitly export all internal functions, especially if they are designed to be obfuscated or are part of a statically linked library.

    1. Exported Symbols:

    Many libraries, particularly open-source ones like OpenSSL or BoringSSL derivatives, export their public API functions. You can list these using nm -D or readelf -s on the library file:

    adb pull /data/app/your.package.name/lib/arm64/libyourlib.so .nm -D libyourlib.so | grep -i "AES|SHA|EVP_Cipher"

    Look for common patterns like AES_set_encrypt_key, EVP_CipherInit_ex, SHA256_Update, etc.

    2. Static Analysis (for Stripped Symbols):

    If symbols are stripped, you’ll need a static analysis tool like Ghidra or IDA Pro. Load the .so file and search for known cryptographic constants (e.g., AES S-boxes), or analyze cross-references to common system calls (like mmap, open, read) to infer where crypto operations might occur. Identify the starting address or offset of interesting functions.

    ARM64 Calling Convention Fundamentals for Hooking

    Understanding ARM64 calling conventions is crucial for correctly interpreting function arguments and return values when using Frida’s Interceptor.attach. For standard C/C++ functions:

    • Arguments: The first eight arguments (up to 64-bit size each) are passed in registers x0 through x7. Additional arguments are pushed onto the stack.
    • Return Value: The return value is typically stored in register x0.
    • Callee-Saved Registers: Registers x19x30 must be preserved by the callee.

    When using Interceptor.attach, Frida exposes these arguments in the onEnter(args) callback as an array-like object where args[0] corresponds to x0, args[1] to x1, and so on.

    Crafting the Frida Hook: A Step-by-Step Guide

    Let’s assume we want to intercept a hypothetical function custom_aes_decrypt(uint8_t *key, uint8_t *ciphertext, size_t len, uint8_t *plaintext) within libcryptolib.so.

    1. Find the Module Base Address and Function Address:

    First, get the base address of the target library within the application’s memory space. Then, calculate the absolute address of the function.

    // script.jsfunction hookNativeCrypto() {    var moduleName = "libcryptolib.so";    var targetModule = Module.findExportByName(moduleName, "custom_aes_decrypt"); // If symbol is exported    if (!targetModule) {        // Fallback: If symbol is stripped, you'd find the module base and add the static offset        var lib = Module.findByName(moduleName);        if (lib) {            console.log("[+] Found libcryptolib.so at: " + lib.base);            // Example: Offset found via static analysis (Ghidra/IDA)            var offset = new NativePointer(0x12345); // Replace with actual offset            targetModule = lib.base.add(offset);            console.log("[+] Target function at calculated offset: " + targetModule);        } else {            console.error("[-] libcryptolib.so not found!");            return;        }    }}

    2. Intercept the Function:

    Now, use Interceptor.attach to define onEnter and onLeave callbacks.

    // script.js...if (targetModule) {    console.log("[+] Hooking custom_aes_decrypt at: " + targetModule);    Interceptor.attach(targetModule, {        onEnter: function(args) {            // 'this' context stores data between onEnter and onLeave            this.keyPtr = args[0];            this.ciphertextPtr = args[1];            this.len = args[2].toInt32(); // Cast NativePointer to integer            this.plaintextPtr = args[3];            console.log("--------------------------------------------------");            console.log("[+] custom_aes_decrypt called!");            console.log("    Key Pointer (x0): " + this.keyPtr);            console.log("    Ciphertext Pointer (x1): " + this.ciphertextPtr);            console.log("    Length (x2): " + this.len + " bytes");            console.log("    Plaintext Output Pointer (x3): " + this.plaintextPtr);            // Dump key and ciphertext            if (this.keyPtr.isNull() || this.ciphertextPtr.isNull()) {                console.warn("    Pointers are null, cannot dump data.");            } else {                try {                    console.log("    Key Data (first 16 bytes):");                    console.log(hexdump(this.keyPtr, { length: Math.min(this.len, 16) }));                    console.log("    Ciphertext Data (first 32 bytes):");                    console.log(hexdump(this.ciphertextPtr, { length: Math.min(this.len, 32) }));                } catch (e) {                    console.error("    Error dumping data: " + e);                }            }        },        onLeave: function(retval) {            console.log("[+] custom_aes_decrypt returned.");            // Assuming retval is an integer representing success/failure            console.log("    Return Value (x0): " + retval);            // If plaintext buffer is written to, dump its content after the call            if (this.plaintextPtr && !this.plaintextPtr.isNull() && this.len > 0) {                try {                    console.log("    Decrypted Plaintext Data (first 32 bytes):");                    console.log(hexdump(this.plaintextPtr, { length: Math.min(this.len, 32) }));                } catch (e) {                    console.error("    Error dumping plaintext data: " + e);                }            }            console.log("--------------------------------------------------");        }    });    console.log("[+] custom_aes_decrypt hook installed.");} else {    console.error("[-] Target function 'custom_aes_decrypt' not found.");}function main() {    hookNativeCrypto();}rpc.exports = {    init: main};

    Explanation of the Script:

    • Module.findExportByName(moduleName, functionName): Tries to find the function by its exported symbol.
    • Module.findByName(moduleName): Gets the base address of the loaded module.
    • lib.base.add(offset): Calculates the absolute address if the symbol is stripped and you have an offset from static analysis.
    • Interceptor.attach(address, callbacks): The core Frida API for hooking.
    • onEnter(args): Called before the target function executes. args is an array of NativePointer objects, representing the values in x0x7.
    • this.keyPtr = args[0]: We store argument values in the this context to access them in onLeave.
    • args[2].toInt32(): Converts a NativePointer representing an integer to a JavaScript number.
    • hexdump(ptr, { length: num }): A powerful Frida utility to dump memory at a given pointer, showing both hexadecimal and ASCII representations.
    • onLeave(retval): Called after the target function executes. retval is the return value (from x0).

    Full Frida Script Example and Execution

    Save the above script as hook_crypto.js.

    // hook_crypto.js (complete script)function hookNativeCrypto() {    var moduleName = "libcryptolib.so";    var targetFunctionName = "custom_aes_decrypt";    var targetModule = Module.findExportByName(moduleName, targetFunctionName);    if (!targetModule) {        var lib = Module.findByName(moduleName);        if (lib) {            console.log("[+] Found " + moduleName + " at: " + lib.base);            // Example: Replace 0x12345 with the actual offset found via static analysis            var offset = new NativePointer("0x12345");             targetModule = lib.base.add(offset);            console.log("[+] Target function at calculated offset: " + targetModule);        } else {            console.error("[-] " + moduleName + " not found!");            return;        }    }    if (targetModule) {        console.log("[+] Hooking " + targetFunctionName + " at: " + targetModule);        Interceptor.attach(targetModule, {            onEnter: function(args) {                this.keyPtr = args[0];                this.ciphertextPtr = args[1];                this.len = args[2].toInt32();                this.plaintextPtr = args[3];                console.log("--------------------------------------------------");                console.log("[+] " + targetFunctionName + " called!");                console.log("    Key Pointer (x0): " + this.keyPtr);                console.log("    Ciphertext Pointer (x1): " + this.ciphertextPtr);                console.log("    Length (x2): " + this.len + " bytes");                console.log("    Plaintext Output Pointer (x3): " + this.plaintextPtr);                if (this.keyPtr.isNull() || this.ciphertextPtr.isNull()) {                    console.warn("    Pointers are null, cannot dump data.");                } else {                    try {                        console.log("    Key Data (first 16 bytes):");                        console.log(hexdump(this.keyPtr, { length: Math.min(this.len, 16) }));                        console.log("    Ciphertext Data (first 32 bytes):");                        console.log(hexdump(this.ciphertextPtr, { length: Math.min(this.len, 32) }));                    } catch (e) {                        console.error("    Error dumping data: " + e);                    }                }            },            onLeave: function(retval) {                console.log("[+] " + targetFunctionName + " returned.");                console.log("    Return Value (x0): " + retval);                if (this.plaintextPtr && !this.plaintextPtr.isNull() && this.len > 0) {                    try {                        console.log("    Decrypted Plaintext Data (first 32 bytes):");                        console.log(hexdump(this.plaintextPtr, { length: Math.min(this.len, 32) }));                    } catch (e) {                        console.error("    Error dumping plaintext data: " + e);                    }                }                console.log("--------------------------------------------------");            }        });        console.log("[+] " + targetFunctionName + " hook installed.");    } else {        console.error("[-] Target function '" + targetFunctionName + "' not found.");    }}setImmediate(hookNativeCrypto);

    Execution:

    Run Frida, injecting your script into the target application. Replace your.package.name with the actual package name of the Android app.

    frida -U -l hook_crypto.js -f your.package.name --no-pause

    When the application executes the custom_aes_decrypt function, your console will display the intercepted key, ciphertext, and subsequently, the decrypted plaintext, along with the function’s return value.

    Conclusion

    Intercepting cryptographic calls in Android ARM64 native libraries with Frida is a powerful technique for penetration testers and security researchers. By understanding ARM64 calling conventions and leveraging Frida’s Interceptor API, you can gain deep visibility into how applications handle sensitive data at a low level, even in the presence of obfuscation or stripped symbols. This practical approach enables comprehensive analysis of cryptographic implementations and helps uncover potential vulnerabilities that might otherwise remain hidden.

  • The Definitive Guide to Frida ARM64: Hooking Android NDK & System Libraries

    Introduction to Frida ARM64 Hooking on Android

    Frida, a dynamic instrumentation toolkit, is indispensable for security researchers and penetration testers. While much focus is often placed on Java-layer Android hooking, understanding and manipulating native libraries (NDK and system libraries) is crucial for deeper analysis, especially given the performance benefits and security-critical operations often implemented in C/C++. This guide dives into advanced Frida techniques specifically tailored for ARM64 Android environments, demonstrating how to effectively hook both exported and unexported functions within native libraries.

    Android applications frequently leverage the Native Development Kit (NDK) to execute performance-intensive tasks, interact directly with hardware, or implement security features in C/C++ native libraries (.so files). Bypassing client-side security controls often requires going beyond the Java layer and into this native code. Understanding ARM64 architecture and calling conventions is paramount for successful native hooking.

    Prerequisites and Setup

    Before we begin, ensure you have the following:

    • A rooted Android device or emulator (API 23+ recommended)
    • ADB (Android Debug Bridge) installed and configured on your host machine
    • Python 3 and pip installed on your host machine
    • Basic familiarity with ARM64 assembly (registers, calling conventions)
    • Tools like Ghidra or IDA Pro for static analysis (optional but highly recommended for unexported functions)

    Frida Environment Setup

    1. Install Frida Tools on Host:
      pip3 install frida-tools
    2. Download Frida Server: Navigate to the Frida releases page and download the appropriate frida-server for your device’s architecture (e.g., frida-server-x.y.z-android-arm64).
    3. Push Frida Server to Device:
      adb push /path/to/frida-server-x.y.z-android-arm64 /data/local/tmp/frida-server
    4. Set Permissions and Run:
      adb shell "chmod 755 /data/local/tmp/frida-server"adb shell "/data/local/tmp/frida-server &"

      Confirm Frida is running by executing frida-ps -U on your host.

    Understanding ARM64 Native Libraries and Calling Conventions

    When an Android app loads a native library, it typically uses System.loadLibrary() or System.load(). These libraries are ELF (Executable and Linkable Format) files. On ARM64 (AArch64), parameters are passed to functions primarily via registers x0 through x7. Additional parameters are pushed onto the stack. The return value is typically in x0.

    Identifying Native Functions

    Before hooking, you need to know what to hook. You can identify exported functions using various methods:

    1. Static Analysis with readelf/nm:
      adb shell "find /data/app -name "*.so"" # Find target .so fileadb pull /path/to/target.so .readelf -s target.so | grep FUNCnm -D target.so | grep T # Only global/dynamic symbols
    2. Dynamic Enumeration with Frida:
    // script.jsModule.enumerateExportsSync('libc.so').forEach(function(exp) {    if (exp.type === 'function') {        console.log('Exported function: ' + exp.name + ' at ' + exp.address);    }});
    frida -U -l script.js -f com.target.app --no-pause

    This will list all exported functions from libc.so, including common ones like strlen, malloc, etc.

    Basic Native Hooking: Exported Functions

    Let’s start by hooking a simple, well-known exported function: strlen from libc.so. This function takes one argument (a pointer to a string) and returns its length.

    // hook_strlen.jsvar libc = Module.findBaseAddress('libc.so');if (libc) {    var strlenPtr = Module.findExportByName('libc.so', 'strlen');    if (strlenPtr) {        console.log('Hooking strlen at: ' + strlenPtr);        Interceptor.attach(strlenPtr, {            onEnter: function(args) {                // args[0] holds the pointer to the string                this.str = args[0].readUtf8String();                console.log('[+] strlen called with: "' + this.str + '"');            },            onLeave: function(retval) {                console.log('[-] strlen returned: ' + retval.toInt32());                // Example: modify return value for strings matching a condition                if (this.str && this.str.includes('secret')) {                    console.log('    Modifying strlen return for "secret" string!');                    retval.replace(ptr(100)); // Force length to 100                }            }        });    } else {        console.log('strlen not found in libc.so');    }} else {    console.log('libc.so not found');}
    frida -U -l hook_strlen.js -f com.target.app --no-pause

    In onEnter, args[0] directly references the first argument passed to the function, which in ARM64 is held in register x0. Similarly, retval in onLeave corresponds to the return value, typically in x0 for ARM64 functions.

    Advanced Hooking: Internal/Unexported Functions

    Many critical functions are not exported. To hook these, you need to find their memory address relative to the library’s base address. This usually involves static analysis.

    Finding Offsets with Static Analysis (Ghidra/IDA Pro)

    1. Pull the target .so file from the device.
    2. Open it in Ghidra or IDA Pro.
    3. Identify the target function and its offset from the library’s base address (usually the first instruction’s address, or 0x0 if the tool displays relative offsets). Let’s say we find an internal function do_something_secret at offset 0x12345.

    Hooking by Offset

    Once you have the offset, you can calculate the absolute address at runtime:

    // hook_unexported.jsvar targetLibName = 'libnative-lib.so'; // Replace with your target libraryvar targetLib = Module.findBaseAddress(targetLibName);var secretFunctionOffset = 0x12345; // Replace with the actual offsetvar secretFunctionPtr;if (targetLib) {    secretFunctionPtr = targetLib.add(secretFunctionOffset);    console.log('Hooking unexported function at: ' + secretFunctionPtr);    Interceptor.attach(secretFunctionPtr, {        onEnter: function(args) {            console.log('[+] do_something_secret called!');            // Access arguments: args[0] for x0, args[1] for x1, etc.            // Example: read an integer from x0            var arg0_val = args[0].toInt32();            console.log('    Argument x0: ' + arg0_val);            // Example: modify an argument (e.g., set x0 to 0)            // args[0] = ptr(0); // This will change the argument value            this.arg0_on_enter = arg0_val; // Store for onLeave        },        onLeave: function(retval) {            console.log('[-] do_something_secret returned: ' + retval.toInt32());            // Example: modify return value based on enter state            if (this.arg0_on_enter === 1337) {                retval.replace(ptr(9999));            }        }    });} else {    console.log('Target library ' + targetLibName + ' not found.');}

    Important ARM64 Register Note: When hooking unexported functions, especially if they are called directly or indirectly from Java via JNI, pay close attention to the calling convention. The first 8 arguments are passed in registers x0-x7. For example, if a native function is declared as void myNativeFunc(JNIEnv* env, jobject thiz, jint a, jstring b), then env will be in x0, thiz in x1, a in x2, and b in x3. Frida’s args array aligns with these registers.

    Advanced Scenarios and Tips

    • Hooking JNI_OnLoad: This function is called when a native library is loaded and is often used to register native methods. Hooking it early can provide insights into method registrations.
      // Hook JNI_OnLoad from libnative-lib.soModule.set  `JNI_OnLoad` interceptor here (example):var jniOnLoadPtr = Module.findExportByName('libnative-lib.so', 'JNI_OnLoad');if (jniOnLoadPtr) {    Interceptor.attach(jniOnLoadPtr, {        onEnter: function(args) {            console.log('[+] JNI_OnLoad called for libnative-lib.so');            // args[0] is JNIEnv*, args[1] is void* reserved        },        onLeave: function(retval) {            console.log('[-] JNI_OnLoad returned: ' + retval.toInt32());        }    });}
    • Memory Manipulation: Use Memory.readByteArray(address, size) and Memory.writeByteArray(address, byteArray) to inspect and modify memory regions.
    • Bypassing Anti-Frida/Anti-Debugger Checks: Native code often implements checks for debuggers or instrumentation frameworks. These can involve checking /proc/self/maps, ` /proc/self/status` for TracerPid, or specific instruction patterns. Identify these checks with static analysis and then use Frida to patch them out (e.g., using Memory.patchCode or by always returning a
  • From Obfuscation to Cleartext: Unpacking Android ARM64 Native Secrets with Frida

    Introduction: The Native Code Labyrinth

    Android applications often rely on native code for performance-critical operations, cryptographic routines, or to protect intellectual property. While Java/Kotlin code is relatively straightforward to decompile and analyze, native libraries, especially those compiled for ARM64 architecture, present a more formidable challenge. Developers frequently employ obfuscation techniques to obscure critical logic and sensitive data within these libraries, turning a simple reverse engineering task into a complex puzzle. This article dives deep into using Frida, a dynamic instrumentation toolkit, to cut through this obfuscation, inspect ARM64 native functions, and extract hidden secrets in real-time.

    Why Native Code? Challenges of ARM64 Analysis

    Native code (typically C/C++ compiled into .so files) is a double-edged sword. It offers significant performance benefits and closer interaction with the underlying system, but also presents a steeper learning curve for security analysts. For many applications, particularly those handling sensitive data like financial apps or DRM, core logic is moved to native code to deter tampering and reverse engineering. When combined with obfuscation, analyzing these binaries becomes a test of patience and skill.

    ARM64 (AArch64) architecture adds another layer of complexity. Unlike its 32-bit predecessor (ARM32), ARM64 utilizes a larger register set (x0-x30), a different calling convention (arguments passed in x0-x7 registers, return value in x0), and a new instruction set. Traditional static analysis tools like Ghidra or IDA Pro are powerful, but dynamic analysis with Frida allows us to observe the code’s behavior during execution, bypassing many static obfuscation tricks.

    Prerequisites for Your Frida Journey

    Before we embark, ensure you have the following setup:

    • Rooted Android Device/Emulator: Essential for running frida-server.
    • Frida-tools: Installed on your host machine (pip install frida-tools).
    • ADB (Android Debug Bridge): For interacting with your Android device.
    • Basic ARM64 Assembly Knowledge: Helpful for understanding register usage and function calls.
    • Target Application: For demonstration, we’ll assume a simple Android app with a native library (e.g., libnative-lib.so) that performs some operation.

    Setting Up Your Frida Environment

    First, download the correct frida-server for your device’s architecture (e.g., frida-server-*-android-arm64) from the official Frida releases page. Push it to your device and make it executable:

    adb push frida-server /data/local/tmp/frida-serveradb shell 'chmod +x /data/local/tmp/frida-server'

    Now, run frida-server on the device. It’s often best to run it in the background:

    adb shell '/data/local/tmp/frida-server &'

    Finally, set up port forwarding so your host machine can communicate with the server:

    adb forward tcp:27042 tcp:27042

    Verify your setup by listing running processes with Frida:

    frida-ps -U

    Identifying Native Functions: Static Analysis Primer

    Even with dynamic analysis, a little static reconnaissance helps. Use tools like readelf or nm on your target .so file to list exported functions. For deeper insights, Ghidra or IDA Pro are invaluable for disassembling the binary and identifying potential functions of interest, especially unexported ones. You’ll typically look for Java Native Interface (JNI) functions (e.g., Java_com_example_app_MainActivity_getStringFromNative) or internal C/C++ functions.

    Let’s say we have an exported function in libnative-lib.so that takes two integers, adds them, and returns a string representation:

    // native-lib.cint addAndGetString(int a, int b, char* buffer, size_t buffer_size) {    int sum = a + b;    snprintf(buffer, buffer_size, "Sum is: %d", sum);    return sum;}

    This function might be called by a JNI wrapper:

    // jni_wrapper.cppextern "C" JNIEXPORT jstring JNICALLJava_com_example_app_MainActivity_getSumNative(JNIEnv* env, jobject /* this */, jint a, jint b) {    char result_buffer[256];    addAndGetString(a, b, result_buffer, sizeof(result_buffer));    return env->NewStringUTF(result_buffer);}

    Crafting Your First ARM64 Frida Hook: Exported Functions

    Frida’s Interceptor.attach is your primary weapon. To hook an exported function by name:

    // hook_exported.jsFrida.on("spawn", function(spawn) {    console.log("Spawned: " + spawn.pid + " - " + spawn.filePath);    Frida.resume(spawn.pid);});Interceptor.attach(Module.findExportByName("libnative-lib.so", "addAndGetString"), {    onEnter: function(args) {        // Arguments for ARM64 functions are typically in registers x0-x7.        // For our addAndGetString(int a, int b, char* buffer, size_t buffer_size):        // x0 = a (int)        // x1 = b (int)        // x2 = buffer (char*)        // x3 = buffer_size (size_t)        console.log("[+] Entering addAndGetString");        console.log("  Argument a (x0): " + args[0].toInt32());        console.log("  Argument b (x1): " + args[1].toInt32());        this.buffer_ptr = args[2]; // Save buffer pointer for onLeave    },    onLeave: function(retval) {        // Return value is in x0 for ARM64        console.log("[-] Leaving addAndGetString");        console.log("  Return value (sum): " + retval.toInt32());        if (this.buffer_ptr) {            // Read the string written to the buffer            let result_string = Memory.readUtf8String(this.buffer_ptr);            console.log("  String in buffer: " + result_string);        }    }});console.log("Frida script loaded!");

    To run this script against your target application (replace com.example.app with your package name):

    frida -U -l hook_exported.js com.example.app

    Diving Deeper: Hooking Unexported Functions by Offset

    Often, the most interesting functions are not exported. In such cases, you need their memory offset within the library. You’d typically find this offset using static analysis tools like Ghidra or IDA Pro by examining the disassembled code. Once you have the offset, you can calculate the absolute address by adding it to the library’s base address:

    // hook_by_offset.js// Assume 0x12345 is the offset of an unexported function 'secret_calc' within libnative-lib.soInterceptor.attach(Module.findBaseAddress("libnative-lib.so").add(0x12345), {    onEnter: function(args) {        console.log("[+] Entering unexported secret_calc");        // Inspect arguments as needed        console.log("  Arg 0 (x0): " + args[0]);    },    onLeave: function(retval) {        console.log("[-] Leaving unexported secret_calc");        console.log("  Return value (x0): " + retval);    }});

    The .add() method is crucial for constructing the absolute address from the base address and the offset. This technique allows you to target any function within the native library, exported or not.

    Advanced Techniques: Register Inspection & Memory Manipulation

    Frida allows profound interaction with the native execution context. You can read and write to registers, inspect arbitrary memory regions, and even modify function arguments or return values on the fly.

    Example: Intercepting a Cryptographic Key

    Imagine a function encrypt_data(uint8_t* data, size_t data_len, uint8_t* key, size_t key_len). We want to extract the key:

    // hook_crypto_key.jsInterceptor.attach(Module.findExportByName("libnative-lib.so", "encrypt_data"), {    onEnter: function(args) {        console.log("[+] Entering encrypt_data");        this.data_ptr = args[0];        this.data_len = args[1].toInt32();        this.key_ptr = args[2]; // x2 holds the key pointer        this.key_len = args[3].toInt32(); // x3 holds the key length        console.log("  Data Ptr (x0): " + this.data_ptr);        console.log("  Data Len (x1): " + this.data_len);        console.log("  Key Ptr (x2): " + this.key_ptr);        console.log("  Key Len (x3): " + this.key_len);        if (this.key_ptr && this.key_len > 0) {            let key_bytes = Memory.readByteArray(this.key_ptr, this.key_len);            console.log("  Extracted Key: " + Array.from(new Uint8Array(key_bytes)).map(b => b.toString(16).padStart(2, '0')).join(''));        }    },    onLeave: function(retval) {        console.log("[-] Leaving encrypt_data");    }});

    This script intercepts the call, reads the `key` pointer (args[2], which is x2 in ARM64 calling convention), and then uses Memory.readByteArray to dump the key’s contents. You could similarly modify `args[2]` using `args[2] = Memory.allocUtf8String(“newkey”)` if you wanted to inject a different key.

    Examining Register Context

    Inside onEnter or onLeave, `this.context` provides access to all CPU registers. For ARM64, you can access registers like `this.context.x0`, `this.context.x1`, `this.context.sp` (stack pointer), `this.context.pc` (program counter), etc. This is invaluable for understanding the full execution state.

    // Accessing context in a hookonEnter: function(args) {    console.log("Program Counter (PC): " + this.context.pc);    console.log("Stack Pointer (SP): " + this.context.sp);    // You can even modify registers, though caution is advised    // this.context.x0 = ptr('0x12345');}

    Best Practices and Troubleshooting

    • Scope your hooks: Don’t try to hook every function. Focus on areas identified during static analysis as sensitive.
    • Error Handling: Frida scripts can be fragile. Use try...catch blocks, especially when dealing with memory operations or potentially invalid pointers.
    • Performance: Excessive logging or complex operations in hooks can slow down the target application. Be mindful of performance implications.
    • Anti-Frida Measures: Some applications detect Frida. Techniques like obfuscating Frida’s process name or checking for loaded Frida modules exist. Advanced Frida users often employ techniques like
  • Beyond Static Analysis: Real-Time Android Class & Method Discovery using Frida

    In the realm of Android application penetration testing and reverse engineering, static analysis often serves as the initial reconnaissance phase. Tools like JADX or Ghidra allow us to decompile APKs and inspect their source code. However, relying solely on static analysis has significant limitations: code obfuscation can render decompiled code almost unreadable, dynamic loading of classes might be missed, and runtime behaviors, such as method calls and parameter values, remain opaque. This is where dynamic analysis, particularly with a powerful framework like Frida, becomes indispensable.

    The Limitations of Static Analysis

    Static analysis provides a snapshot of an application’s potential execution paths but struggles with several dynamic aspects:

    • Obfuscation: ProGuard, DexGuard, and similar tools rename classes, methods, and fields, making decompiled code hard to follow.
    • Dynamic Loading: Classes or even entire DEX files might be loaded at runtime based on certain conditions or fetched from remote servers, which static analysis tools cannot predict.
    • Runtime Behavior: It’s impossible to know the exact values of variables, the order of method calls, or the outcomes of conditional logic without executing the application.
    • Environment-Dependent Logic: Some security checks or functionalities might only activate under specific device conditions (e.g., rooted vs. non-rooted device).

    Frida bridges this gap by allowing us to inject custom JavaScript code into running processes, enabling real-time inspection and manipulation of an application’s execution.

    Prerequisites for Dynamic Analysis with Frida

    Before diving into class and method discovery, ensure you have the following setup:

    • Rooted Android Device or Emulator: Frida requires root privileges to inject into arbitrary processes.
    • ADB (Android Debug Bridge): For pushing files and interacting with the device shell.
    • Frida Server: Running on the target Android device.
    • Frida Tools: Installed on your host machine (pip install frida-tools).

    Setting Up Frida Server on Android

    1. Download the appropriate Frida server binary for your device’s architecture (e.g., frida-server-*-android-arm64) from Frida’s GitHub releases.

    2. Push the server to your device:

    adb push frida-server-*-android-arm64 /data/local/tmp/frida-server

    3. Set execute permissions and run it:

    adb shell "chmod +x /data/local/tmp/frida-server"adb shell "/data/local/tmp/frida-server &"

    Confirm it’s running by executing frida-ps -U on your host. You should see a list of running processes on your device.

    Understanding Android Runtime (ART) and Frida’s Interaction

    Android applications primarily run on the Android Runtime (ART), which executes Dalvik Executable (DEX) bytecode. Frida interacts with the ART by injecting a JavaScript engine (Duktape or V8) into the target process. This allows your JavaScript code to directly interact with the application’s Java objects, classes, and methods through Frida’s provided APIs, notably Java.* functions.

    Core Concepts: Java.enumerateClasses() and Java.use()

    Frida provides two primary mechanisms for runtime class and method discovery:

    • Java.enumerateClasses(callbacks): This function iterates through all currently loaded Java classes in the target process’s ART environment. It’s incredibly useful for broad reconnaissance.
    • Java.use(className): This function allows you to obtain a JavaScript wrapper around a specific Java class. Once you have this wrapper, you can inspect its methods, fields, and even instantiate new objects of that class.

    Step-by-Step: Enumerating All Loaded Classes

    Let’s start by listing every class currently loaded by an Android application. This provides a comprehensive overview of the application’s components and potentially third-party libraries.

    Frida Script: enumerate_classes.js

    Java.perform(function () {    console.log("[+] Enumerating all loaded classes...");    var foundClasses = [];    Java.enumerateClasses({        onMatch: function(className) {            foundClasses.push(className);        },        onComplete: function() {            console.log("[+] Total classes found: " + foundClasses.length);            // Sort for easier readability (optional)            foundClasses.sort();            foundClasses.forEach(function(name) {                console.log(name);            });            console.log("[+] Enumeration complete.");        }    });});

    Running the Script

    First, identify the package name of your target application (e.g., com.example.myapp). You can get this using adb shell pm list packages or frida-ps -Uai. Then, attach Frida:

    frida -U -f com.example.myapp -l enumerate_classes.js --no-pause

    The --no-pause flag ensures the application starts immediately and the script runs. You’ll see a long list of classes printed to your console. You might want to filter this output, perhaps focusing on classes belonging to the application’s package name (e.g., com.example.*).

    Refining Class Enumeration with Filtering

    To make the output more manageable, we can add a filter to our script:

    Java.perform(function () {    console.log("[+] Enumerating classes with filter 'com.example'...");    var foundClasses = [];    var filter = "com.example"; // Or any other package/name you're interested in    Java.enumerateClasses({        onMatch: function(className) {            if (className.startsWith(filter)) {                foundClasses.push(className);            }        },        onComplete: function() {            console.log("[+] Total filtered classes found: " + foundClasses.length);            foundClasses.sort();            foundClasses.forEach(function(name) {                console.log(name);            });            console.log("[+] Filtered enumeration complete.");        }    });});

    This script will only log classes whose names start with “com.example”, significantly narrowing down your search.

    Step-by-Step: Discovering Methods of a Specific Class

    Once you’ve identified an interesting class, the next step is to discover its methods and fields. This helps in understanding its functionality and potential attack vectors.

    Frida Script: enumerate_methods.js

    Let’s assume we are interested in a class named com.example.myapp.SomeSecurityClass.

    Java.perform(function () {    var targetClassName = "com.example.myapp.SomeSecurityClass";    try {        var targetClass = Java.use(targetClassName);        console.log("[+] Class found: " + targetClassName);        console.log("  [+] Listing methods:");        var methods = targetClass.class.getDeclaredMethods();        methods.forEach(function(method) {            console.log("    - " + method.getName() + "(" + method.getParameterTypes().map(function(type){ return type.getName(); }).join(", ") + ")");        });        console.log("  [+] Listing fields:");        var fields = targetClass.class.getDeclaredFields();        fields.forEach(function(field) {            console.log("    - " + field.getType().getName() + " " + field.getName());        });    } catch (e) {        console.log("[-] Could not find or process class: " + targetClassName + " Error: " + e.message);    }});

    Running the Script

    frida -U -f com.example.myapp -l enumerate_methods.js --no-pause

    This script uses Java.use() to get a reference to the SomeSecurityClass. Then, it leverages the class property (which gives you the underlying java.lang.Class object) to call getDeclaredMethods() and getDeclaredFields(). These methods provide java.lang.reflect.Method and java.lang.reflect.Field objects, from which you can extract names, return types, and parameter types.

    Practical Use Cases and Further Exploration

    With these foundational techniques, you can:

    • Bypass Security Controls: Identify methods related to root detection, SSL pinning, or anti-tampering, then use Frida to hook and modify their return values.
    • Understand Obfuscated Code: Even with obfuscated names, enumerating methods at runtime can reveal their true signatures (parameters, return types), helping to infer functionality.
    • API Reconnaissance: Discover hidden or undocumented APIs within an application, which might expose sensitive data or functionality.
    • Event Listener Discovery: Find dynamically registered broadcast receivers, content providers, or event listeners.

    The ability to dynamically discover classes and methods is a cornerstone of advanced Android penetration testing. It allows you to move beyond the static limitations and truly interact with the application as it behaves in its natural environment. From here, you can extend your Frida scripts to hook these discovered methods, inspect arguments, modify return values, and even call arbitrary methods, unlocking a vast array of possibilities for runtime analysis and manipulation.

  • Crafting Powerful Frida Scripts: Dynamic Analysis of Android ARM64 Native Functions

    Introduction: Unveiling Native Secrets with Frida on ARM64

    Android applications often rely on native libraries (written in C/C++ and compiled into .so files) for performance-critical operations, obfuscation, or interacting with system functionalities not exposed through Java APIs. For security researchers, penetration testers, and reverse engineers, understanding and manipulating these native functions at runtime is paramount. Frida, a dynamic instrumentation toolkit, stands out as an indispensable tool for this purpose. This article will guide you through crafting powerful Frida scripts specifically tailored for dynamic analysis of Android ARM64 native functions, from identifying targets to modifying their behavior.

    Prerequisites and Setup

    Before diving into the code, ensure you have the following:

    • An Android device or emulator (rooted) with ARM64 architecture.
    • Frida server running on the Android device.
    • Frida client (Python environment) on your host machine.
    • Basic familiarity with C/C++ and ARM64 assembly concepts.
    • Optional: Disassembler/decompiler like IDA Pro or Ghidra for static analysis.

    Setting Up Frida

    1. Install Frida on your host:

    pip install frida-tools

    2. Download Frida server for your Android device: Navigate to Frida’s GitHub releases, find the latest version, and download the frida-server-*-android-arm64 file.

    3. Push and run Frida server on your device:

    adb push frida-server /data/local/tmp/frida-serveradbshell "chmod 755 /data/local/tmp/frida-server"adbshell "/data/local/tmp/frida-server &"

    Verify it’s running by executing frida-ps -U on your host. If you see a list of processes, you’re good to go.

    Identifying Native Functions for Hooking

    The first step in any dynamic analysis is to identify your targets. Native functions can be categorized into exported (visible in the symbol table) and unexported (internal, only discoverable through static analysis or runtime observation).

    Exported Functions

    Exported functions are the easiest to hook. You can find them using tools like nm or readelf on the .so file, or dynamically using Frida itself.

    Using nm on the device:

    adbshell "nm -D /data/app/~~.../com.example.app-XYZ/lib/arm64/libnative-lib.so | grep ' T '"

    The output will show symbols marked with ‘T’ (text/code segment), indicating functions.

    Unexported Functions and Offsets

    Many interesting functions are not exported. To hook these, you need their memory offset relative to the base address of their containing library. This often requires static analysis with a disassembler/decompiler:

    1. Load the .so file into IDA Pro or Ghidra.
    2. Identify the target function (e.g., based on cross-references, strings, or code logic).
    3. Note its virtual address (VA).
    4. Subtract the library’s base address (usually 0x0 or 0x1000 in static analysis) from the function’s VA to get the offset.

    Frida’s Core Hooking Mechanisms

    Frida provides two primary ways to hook native functions:

    1. Hooking Exported Functions with Module.findExportByName

    This method is straightforward. You provide the library name and the function’s exported name.

    // my_exported_native_func.jsJava.perform(function () {  var libName = "libnative-lib.so";  var funcName = "Java_com_example_app_NativeLib_add"; // Example: JNI function  var targetModule = Module.findBaseAddress(libName);  if (targetModule) {    var targetFunction = Module.findExportByName(libName, funcName);    if (targetFunction) {      console.log("Hooking exported function: " + funcName + " at " + targetFunction);      Interceptor.attach(targetFunction, {        onEnter: function (args) {          console.log("[" + funcName + "] Called from: " + DebugSymbol.fromAddress(this.returnAddress));          console.log("[" + funcName + "] Argument 0 (x0): " + args[0].toInt32());          console.log("[" + funcName + "] Argument 1 (x1): " + args[1].toInt32());          // ARM64 calling convention: x0-x7 for integer/pointer arguments        },        onLeave: function (retval) {          console.log("[" + funcName + "] Return value: " + retval.toInt32());          // Modify return value if needed          // retval.replace(ptr(1337));        }      });    } else {      console.log("Function " + funcName + " not found in " + libName);    }  } else {    console.log("Module " + libName + " not found.");  }});

    To run this script:

    frida -U -l my_exported_native_func.js com.example.app

    2. Hooking Unexported Functions by Offset with Module.base.add

    Once you have the offset, you can calculate the absolute memory address by adding it to the library’s base address at runtime.

    // my_unexported_native_func.jsJava.perform(function () {  var libName = "libnative-lib.so";  var targetOffset = new NativePointer("0x1234"); // Replace with actual offset from Ghidra/IDA  var targetModule = Module.findBaseAddress(libName);  if (targetModule) {    var targetFunction = targetModule.add(targetOffset);    console.log("Hooking unexported function at base + offset: " + targetFunction);    Interceptor.attach(targetFunction, {      onEnter: function (args) {        console.log("[Unexported Func] Called from: " + DebugSymbol.fromAddress(this.returnAddress));        console.log("[Unexported Func] x0: " + args[0]);        console.log("[Unexported Func] x1: " + args[1]);        // Modify arguments, e.g., to bypass a check        // args[0] = ptr(1);      },      onLeave: function (retval) {        console.log("[Unexported Func] Original return: " + retval);        // Force a successful return        retval.replace(ptr(1)); // For a boolean return, 1 often means true      }    });  } else {    console.log("Module " + libName + " not found.");  }});

    Remember to replace 0x1234 with the actual offset you found.

    Understanding ARM64 Calling Convention for Argument Inspection

    A crucial aspect of native hooking on ARM64 is understanding its calling convention. This dictates how arguments are passed and return values are handled. For AArch64 (ARM64):

    • Integer/Pointer Arguments: The first eight arguments are passed in registers x0 through x7. Additional arguments are pushed onto the stack.
    • Floating-Point Arguments: Passed in registers v0 through v7.
    • Return Value: Integer/pointer return values are typically stored in x0. Floating-point return values are in v0.

    When you access args[0], args[1], etc., in your Frida script’s onEnter callback, Frida conveniently maps these to the correct registers (x0, x1, etc.) or stack locations for you. However, knowing the underlying convention helps immensely when debugging or dealing with complex function signatures.

    Practical Example: Bypassing a License Check

    Let’s imagine an Android app has a native function checkLicense() that returns 0 for an invalid license and 1 for a valid one. This function is not exported. After static analysis, we find it at offset 0x5F30 within libappcore.so.

    // bypass_license.jsJava.perform(function () {  var libName = "libappcore.so";  var licenseCheckOffset = new NativePointer("0x5F30");  var targetModule = Module.findBaseAddress(libName);  if (targetModule) {    var licenseCheckFunction = targetModule.add(licenseCheckOffset);    console.log("[Frida] Hooking license check function at: " + licenseCheckFunction);    Interceptor.attach(licenseCheckFunction, {      onEnter: function (args) {        console.log("[Frida] License check called. Context: " + JSON.stringify(this.context));        // You could inspect args here if the function took parameters      },      onLeave: function (retval) {        console.log("[Frida] Original license check return: " + retval.toInt32());        // Force the return value to 1 (true)        retval.replace(ptr(1));        console.log("[Frida] Modified license check return to: 1");      }    });  } else {    console.log("[Frida] Module " + libName + " not found.");  }});

    Run this script while the target application is active:

    frida -U -l bypass_license.js com.example.app

    Now, whenever the application calls checkLicense(), Frida will intercept it and force a successful return, effectively bypassing the license verification.

    Advanced Considerations

    • Inline Hooking: For complex scenarios or highly optimized functions, Interceptor.replace allows you to completely replace a function’s implementation with your own JavaScript or native code.
    • Stalker: Frida’s Stalker engine allows you to observe, record, and even modify the execution path of a thread, providing extremely granular control at the instruction level. This is powerful for tracing code execution.
    • Memory Access: Use Memory.readByteArray(), Memory.writeByteArray(), ptr(), NativePointer.read*(), and NativePointer.write*() to read from and write to arbitrary memory locations, enabling runtime patching or data extraction.

    Conclusion

    Dynamic analysis of Android ARM64 native functions with Frida is an incredibly powerful technique for reverse engineering, penetration testing, and security research. By understanding how to identify target functions (exported or by offset), leverage Frida’s hooking mechanisms, and account for the ARM64 calling convention, you can gain deep insights into an application’s native behavior and even alter its logic on the fly. This guide provides a solid foundation; the true power of Frida lies in creatively combining these techniques to solve specific analysis challenges.

  • Understanding JNI & Native Calls: A Frida ARM64 Guide for Android App Analysis

    Introduction to JNI, Native Calls, and Frida on ARM64

    Android applications often leverage Java Native Interface (JNI) to execute high-performance or platform-specific code written in C/C++. This native code can be a goldmine for security researchers, containing critical algorithms, obfuscated logic, or sensitive data handling. Analyzing these native libraries, especially on ARM64 architectures prevalent in modern Android devices, requires specialized tools and techniques. Frida, a dynamic instrumentation toolkit, stands out as an indispensable tool for runtime analysis, allowing us to hook, inspect, and modify native function calls without recompiling the application.

    This guide will demystify JNI interactions and native function hooking on ARM64 using Frida, providing practical steps and code examples for Android app penetration testers.

    The Java Native Interface (JNI) Primer

    JNI is the bridge between Java and native code. When an Android app calls a native method, the Java Virtual Machine (JVM) loads a shared library (.so file) and looks for a corresponding native function. There are two primary ways to link Java methods to native functions:

    • Dynamic Linking (Implicit): The most common method. Java declares a native method, and the JVM automatically resolves it to a C/C++ function following a specific naming convention: Java_PackageName_ClassName_MethodName. For example, Java_com_example_app_NativeLib_performCalc.
    • Static Linking (Explicit): Native code explicitly registers methods using RegisterNatives, typically within the JNI_OnLoad function. This allows for arbitrary native function names and is often used for obfuscation or more flexible linking.

    Native functions called via JNI always receive at least two arguments: JNIEnv* env and jclass clazz (for static methods) or jobject thiz (for instance methods), followed by any user-defined arguments.

    Frida Basics for Native ARM64 Hooking

    Before diving into ARM64 specifics, let’s review essential Frida concepts for native hooking:

    1. Attach to Process: Start Frida and attach to the target Android application process.frida -U -f com.example.app --no-pauserequire('frida-trace').setup({ ... })
    2. Module Enumeration: Identify loaded native libraries (.so files) using Process.enumerateModules() or Module.findByName().
    3. Symbol Resolution: Find the address of exported functions within a module using Module.findExportByName(moduleName, exportName).
    4. Interceptor.attach: The core mechanism for hooking. It takes the function address and an object with onEnter and onLeave callbacks. These callbacks provide a this context, which includes CPU registers (e.g., this.context.x0 for ARM64) and stack information.

    ARM64 Calling Convention Highlights

    Understanding the ARM64 (AArch64) calling convention is crucial for correctly interpreting arguments and return values:

    • Argument Passing: The first eight arguments (up to 64 bits each) are passed in registers x0 through x7. Additional arguments are pushed onto the stack.
    • Return Value: The return value (up to 64 bits) is stored in register x0.
    • Callee-Saved Registers: Registers x19 to x30 must be preserved by the called function if they are used.
    • Link Register (LR): Register x30 holds the return address.
    • Stack Pointer (SP): Register sp points to the top of the stack.

    When hooking native functions, you’ll primarily interact with x0-x7 to read or modify arguments and x0 to modify return values.

    Practical Example: Hooking a Native Encryption Function

    Let’s imagine an Android app uses a native library libnativecrypto.so with a JNI function Java_com_example_app_CryptoUtils_encryptData that internally calls native_aes_encrypt.

    1. Identifying Native Functions

    First, we need to locate our target functions. We can use Frida’s `Module.enumerateExports()` or `frida-trace`.

    // Using Frida REPL or script to enumerate exportsfrida -U -f com.example.app --no-pauserequire('frida-trace').setup({  decorate: function(exports) {    return ['*!*encrypt*']; // Trace all exports containing 'encrypt'  }});

    Alternatively, if you have the .so file, use nm -D libnativecrypto.so to list exported symbols, or analyze with Ghidra/IDA Pro to find internal function names and offsets.

    2. Hooking a JNI-Exported Function

    Let’s hook Java_com_example_app_CryptoUtils_encryptData, which takes JNIEnv*, jclass, jbyteArray (input data), and jbyteArray (key).

    // frida_jni_hook.jsAgent.onRuntimeInitialized = function() {    const targetModule = Module.findByName("libnativecrypto.so");    if (!targetModule) {        console.error("libnativecrypto.so not found!");        return;    }    const encryptDataPtr = targetModule.findExportByName("Java_com_example_app_CryptoUtils_encryptData");    if (!encryptDataPtr) {        console.error("Java_com_example_app_CryptoUtils_encryptData not found!");        return;    }    console.log("[*] Hooking Java_com_example_app_CryptoUtils_encryptData at " + encryptDataPtr);    Interceptor.attach(encryptDataPtr, {        onEnter: function (args) {            console.log("n[!] Inside Java_com_example_app_CryptoUtils_encryptData");            // JNIEnv* env = args[0]            // jclass clazz = args[1]            // jbyteArray data = args[2]            // jbyteArray key = args[3]            this.data = new Java.vm.get === 'ART' ? Java.vm.getEnv().getByteArrayElements(args[2], null).readByteArray(Java.vm.getEnv().getArrayLength(args[2])) : Memory.readByteArray(args[2].add(0x10), 256); // Simplified access            this.key = new Java.vm.get === 'ART' ? Java.vm.getEnv().getByteArrayElements(args[3], null).readByteArray(Java.vm.getEnv().getArrayLength(args[3])) : Memory.readByteArray(args[3].add(0x10), 32); // Simplified access            console.log("  Original Data: " + hexdump(this.data, { length: Math.min(this.data.byteLength, 32) }));            console.log("  Encryption Key: " + hexdump(this.key, { length: Math.min(this.key.byteLength, 32) }));        },        onLeave: function (retval) {            // retval is jbyteArray for the encrypted data            let encryptedResult = new Java.vm.get === 'ART' ? Java.vm.getEnv().getByteArrayElements(retval, null).readByteArray(Java.vm.getEnv().getArrayLength(retval)) : Memory.readByteArray(retval.add(0x10), 256); // Simplified            console.log("  Encrypted Result: " + hexdump(encryptedResult, { length: Math.min(encryptedResult.byteLength, 32) }));            // Optionally modify return value:            // Memory.writeUtf8String(retval, "MODIFIED_RESULT");        }    });};

    Explanation of `onEnter` arguments: On ARM64, `args[0]` maps to register `x0`, `args[1]` to `x1`, and so on. In our JNI function, `args[0]` is `JNIEnv*`, `args[1]` is `jclass`, `args[2]` is the `jbyteArray` for data, and `args[3]` is `jbyteArray` for the key. Reading `jbyteArray` contents from a `jobject` requires using `JNIEnv` methods, which can be accessed via `Java.vm.getEnv()`. The simplified `Memory.readByteArray(args[X].add(0x10), …)` is a common heuristic for direct memory access if the `jbyteArray` object pointer itself contains a pointer to the actual array data at a fixed offset, but using `JNIEnv` methods is more robust.

    3. Hooking an Internal Native Function (ARM64 Register Awareness)

    Now, let’s assume `Java_com_example_app_CryptoUtils_encryptData` internally calls `native_aes_encrypt(char* data, int dataLen, char* key, int keyLen)`. This function is not exported via JNI but is a regular C function. We would typically find its address via reverse engineering (Ghidra/IDA) or `frida-trace` showing calls from the JNI function.

    // Continuing frida_jni_hook.jsconst nativeAesEncryptPtr = targetModule.base.add(0x12345); // Replace 0x12345 with actual offset from Ghidra/IDAif (!nativeAesEncryptPtr) {    console.error("native_aes_encrypt not found!");    return;}console.log("[*] Hooking native_aes_encrypt at " + nativeAesEncryptPtr);Interceptor.attach(nativeAesEncryptPtr, {    onEnter: function (args) {        console.log("n[!] Inside native_aes_encrypt");        // ARM64 calling convention:        // x0 = char* data        // x1 = int dataLen        // x2 = char* key        // x3 = int keyLen        this.dataPtr = args[0];        this.dataLen = args[1].toInt32();        this.keyPtr = args[2];        this.keyLen = args[3].toInt32();        console.log("  Data Buffer Address (x0): " + this.dataPtr);        console.log("  Data Length (x1): " + this.dataLen);        console.log("  Key Buffer Address (x2): " + this.keyPtr);        console.log("  Key Length (x3): " + this.keyLen);        // Read contents from memory addresses        console.log("  Data: " + hexdump(Memory.readByteArray(this.dataPtr, Math.min(this.dataLen, 32))));        console.log("  Key: " + hexdump(Memory.readByteArray(this.keyPtr, Math.min(this.keyLen, 32))));        // Example: modify input data        // Memory.writeUtf8String(this.dataPtr, "MODIFIED_INPUT_DATA");    },    onLeave: function (retval) {        // x0 contains the return value (e.g., pointer to encrypted data or status code)        console.log("  native_aes_encrypt Returned (x0): " + retval);        // If retval is a pointer to the result, you can read it:        // console.log("  Encrypted Result from native_aes_encrypt: " + hexdump(Memory.readByteArray(retval, 32)));    }});

    Explanation of `onEnter` arguments for internal functions: Here, args[0] directly corresponds to the first function argument (char* data) passed in x0, args[1] to `x1` (int dataLen), and so on. We use `toInt32()` for `dataLen` and `keyLen` because `args` elements are `NativePointer` objects, and an `int` would be stored as a 64-bit value in the register. `Memory.readByteArray` is used to inspect the buffer contents directly from the pointers.

    Conclusion

    Frida provides an unparalleled capability to inspect and manipulate native code at runtime, a critical skill for Android app penetration testing. By understanding JNI’s interaction with native libraries and the ARM64 calling convention, you can accurately identify target functions, interpret arguments passed in registers, and effectively hook internal logic. Whether it’s to bypass obfuscation, understand proprietary algorithms, or discover vulnerabilities, mastering Frida for native analysis on ARM64 empowers you to delve deeper into the heart of Android applications.

  • Android Pentesting Lab: Exploiting Native Code (ARM64) via Frida Hooks

    Introduction to Android Native Code Exploitation

    Modern Android applications increasingly rely on native code (C/C++), primarily for performance-critical operations, cross-platform compatibility, or to protect sensitive logic from easy reverse-engineering. While native code offers these benefits, it also introduces a new attack surface for penetration testers. Exploiting vulnerabilities in native libraries, especially on ARM64 architectures, requires a deep understanding of assembly, memory management, and dynamic instrumentation tools. This article will guide you through setting up an Android pentesting lab and using Frida to dynamically analyze and exploit a custom native library compiled for ARM64.

    Frida, a dynamic instrumentation toolkit, is an invaluable asset in this domain. It allows security researchers to inject custom scripts into running processes on Android, providing unparalleled control over the application’s runtime. We’ll leverage Frida to hook native functions, inspect and modify arguments, and even alter return values, demonstrating how a vulnerability might be exploited or debugged.

    Setting Up Your Android Pentesting Lab

    Before diving into exploitation, you need a properly configured environment:

    1. Android Device or Emulator

    • A rooted Android device or an emulator (e.g., Android Studio’s AVD, Genymotion) running a relatively recent Android version (e.g., Android 8.0+). Root access is crucial for deploying the Frida server.

    2. ADB (Android Debug Bridge)

    • Ensure ADB is installed and configured on your host machine. Test connectivity:
    adb devices

    3. Frida Server on Android

    • Download the correct Frida server binary for your Android device’s architecture (typically arm64 for modern devices). You can find it on Frida’s GitHub releases page.
    # Example for ARM64 server v16.1.1 (adjust version as needed)adb push frida-server-16.1.1-android-arm64 /data/local/tmp/frida-serverchmod 755 /data/local/tmp/frida-serveradb shell "/data/local/tmp/frida-server &"

    4. Frida Tools on Host Machine

    • Install frida-tools via pip:
    pip install frida-tools

    5. Android NDK for Native Code Compilation

    • If you want to compile your own vulnerable native library, you’ll need the Android NDK. Download it via Android Studio’s SDK Manager or directly from Google.

    Understanding ARM64 Native Function Calls

    When hooking native functions, especially on ARM64, it’s vital to understand the calling convention:

    • Arguments: The first eight integer or pointer arguments (including JNIEnv* and jobject/jclass for JNI functions) are passed in registers x0 through x7. Additional arguments are pushed onto the stack.
    • Return Value: The return value is typically placed in register x0.
    • Context Object: Frida’s Interceptor provides a this.context object, allowing direct access to these registers (e.g., this.context.x0, this.context.x1).

    Crafting a Vulnerable Native Library

    Let’s create a simple C++ native library with a function we’ll later exploit. This function will simulate a password check where a hardcoded value is compared against user input.

    1. native-lib.cpp

    #include #include #include #define LOG_TAG "NativeCode"#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)extern "C" JNIEXPORT jboolean JNICALL Java_com_example_nativedemo_MainActivity_checkPassword(JNIEnv* env, jobject /* this */, jstring password){    const char* nativePassword = env->GetStringUTFChars(password, 0);    const char* secret = "mySuperSecret";    bool isValid = (strcmp(nativePassword, secret) == 0);    LOGD("Password entered: %s, isValid: %s", nativePassword, isValid ? "true" : "false");    env->ReleaseStringUTFChars(password, nativePassword);    return isValid;}extern "C" JNIEXPORT jstring JNICALL Java_com_example_nativedemo_MainActivity_getSecretKey(JNIEnv* env, jobject /* this */, jint keyId){    if (keyId == 123) {        return env->NewStringUTF("HardcodedSecureKey123!");    } else {        return env->NewStringUTF("InvalidKeyID");    }}

    2. CMakeLists.txt

    To compile this, create a CMakeLists.txt in the same directory:

    cmake_minimum_required(VERSION 3.4.1)add_library(native-lib SHARED native-lib.cpp)find_library(log-lib log)target_link_libraries(native-lib ${log-lib})

    3. Compile with NDK

    You can integrate this into an Android Studio project or compile manually using ndk-build or CMake with NDK toolchains. For demonstration, assuming a typical Android Studio project structure, compile the app. The library will be located in app/build/intermediates/cmake/debug/obj/arm64-v8a/libnative-lib.so.

    Identifying and Analyzing the Target Function

    First, install the compiled Android app (APK) on your device/emulator. Run the app to ensure the native library is loaded.

    1. Find the Application Process

    Use frida-ps to list running applications and find your target (e.g., com.example.nativedemo):

    frida-ps -Uai

    Note the PID of your application.

    2. Trace Native Calls (Optional but Useful)

    frida-trace can give you an initial idea of functions being called. If you know the library name and function, you can trace it:

    frida-trace -U -f com.example.nativedemo -i "*checkPassword*"

    This will show when checkPassword is called.

    Developing the Frida Hook Script

    Now, let’s write a Frida script to hook checkPassword and getSecretKey to bypass the password check and extract the secret key.

    1. exploit.js

    Java.perform(function () {    var MainActivity = Java.use('com.example.nativedemo.MainActivity');    // Get a reference to the native library    var libNativeLib = Module.findBaseAddress('libnative-lib.so');    if (libNativeLib) {        console.log('[+] libnative-lib.so loaded at: ' + libNativeLib);        // Find the exported functions        // Option 1: Find by export name        var checkPasswordPtr = Module.findExportByName('libnative-lib.so', 'Java_com_example_nativedemo_MainActivity_checkPassword');        var getSecretKeyPtr = Module.findExportByName('libnative-lib.so', 'Java_com_example_nativedemo_MainActivity_getSecretKey');        if (checkPasswordPtr) {            console.log('[+] Hooking checkPassword at: ' + checkPasswordPtr);            Interceptor.attach(checkPasswordPtr, {                onEnter: function (args) {                    // x0: JNIEnv*, x1: jobject (this), x2: jstring (password)                    this.env = args[0];                    this.jobject = args[1];                    this.passwordJstring = args[2];                    var password = this.env.getStringUtfChars(this.passwordJstring, null).readCString();                    console.log('    [checkPassword] Original password input: "' + password + '"');                    // Modify the password argument to always match the secret                    // This requires creating a new jstring and updating args[2]                    var newPassword = this.env.newStringUtf("mySuperSecret");                    args[2] = newPassword; // Overwrite the input password string                    console.log('    [checkPassword] Modified password input to: "mySuperSecret"');                },                onLeave: function (retval) {                    console.log('    [checkPassword] Original return value (boolean): ' + retval.toInt32());                    // Force the return value to true (1)                    retval.replace(1);                    console.log('    [checkPassword] Modified return value (boolean): ' + retval.toInt32() + ' (True)');                }            });        } else {            console.log('[-] checkPassword function not found.');        }        if (getSecretKeyPtr) {            console.log('[+] Hooking getSecretKey at: ' + getSecretKeyPtr);            Interceptor.attach(getSecretKeyPtr, {                onEnter: function (args) {                    // x0: JNIEnv*, x1: jobject (this), x2: jint (keyId)                    this.env = args[0];                    this.keyId = args[2].toInt32();                    console.log('    [getSecretKey] Original keyId input: ' + this.keyId);                    // Modify keyId to ensure we get the secret                    args[2] = ptr(123); // Change the keyId to 123                    console.log('    [getSecretKey] Modified keyId input to: ' + args[2].toInt32());                },                onLeave: function (retval) {                    // The return value is a jstring. We need to convert it to a JavaScript string.                    var secretKey = this.env.getStringUtfChars(retval, null).readCString();                    console.log('    [getSecretKey] Original return value (secret): ' + secretKey);                    // Optionally, you could modify the return value here too                    // var newSecret = this.env.newStringUtf("NEW_EXPOSED_SECRET!");                    // retval.replace(newSecret);                    // console.log('    [getSecretKey] Modified return value (secret): ' + this.env.getStringUtfChars(retval, null).readCString());                }            });        } else {            console.log('[-] getSecretKey function not found.');        }    } else {        console.log('[-] libnative-lib.so not found in memory.');    }});

  • Deep Dive: Reverse Engineering Android ARM64 Native Libraries with Frida

    Introduction to Android Native Libraries and ARM64

    Modern Android applications often leverage native code, typically written in C/C++ and compiled into .so (shared object) libraries. These native libraries are crucial for performance-critical operations, cross-platform compatibility, and often, for implementing sensitive logic like cryptographic operations, DRM, or anti-tampering mechanisms. While Java/Kotlin code is relatively straightforward to decompile and analyze, native ARM64 code presents a more significant challenge.

    The Power of Native Code

    Native code in Android apps interacts with the Java layer via the Java Native Interface (JNI). This allows developers to call C/C++ functions directly from Java and vice-versa. From a security perspective, understanding and manipulating this native layer is paramount for thorough penetration testing and vulnerability research.

    Why Reverse Engineer ARM64?

    ARM64 (AArch64) is the dominant architecture for modern Android devices. Reverse engineering these libraries allows us to:

    • Understand proprietary algorithms (e.g., encryption/obfuscation).
    • Bypass security controls implemented natively.
    • Identify vulnerabilities that might not be apparent at the Java layer.
    • Gain deeper insights into application behavior and data processing.

    Frida: Your Hooking Companion

    Frida is a dynamic instrumentation toolkit that lets you inject snippets of JavaScript or your own library into native apps on Windows, macOS, GNU/Linux, iOS, Android, and QNX. It’s incredibly powerful for reverse engineering, allowing us to inspect, modify, and even redirect the execution flow of native functions in real-time without modifying the original binary.

    What is Frida?

    Frida operates by injecting a JavaScript engine (powered by V8) into the target process. This allows you to write high-level scripts to interact with low-level native code. Its API provides extensive capabilities for:

    • Hooking functions (both exported and internal).
    • Inspecting and modifying memory.
    • Calling arbitrary functions.
    • Tracing execution.
    • Enumerating modules and symbols.

    Prerequisites and Setup

    Before diving in, ensure you have the following setup:

    1. Rooted Android Device or Emulator: Necessary for running the Frida server.
    2. ADB (Android Debug Bridge): For pushing files and interacting with the device.
    3. Frida Client (on your host machine): Install via pip:
      pip install frida-tools

    4. Frida Server (on the Android device):
      a. Download the correct server binary from Frida’s GitHub releases (e.g., frida-server-*-android-arm64).
      b. Push it to your device and make it executable:
      adb push frida-server-*-android-arm64 /data/local/tmp/frida-serveradb shell