Reverse Engineering a Ticketing App

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.

Reverse Engineering a Ticketing App

A walkthrough of reversing a ticketing app.

Target app: a ticketing app Target version: 5.2.7

Bypassing the Frida detection

Using a modified Frida to get past detection

Frida detection

After Frida started, I found the app detects it, so I tried a modified Frida to get past the check.

It successfully attaches:

Attached

Attached 2

The modified Frida bypasses the detection. Now let’s analyze the specific Frida detection.

The modified Frida is bundled with the files; go support the author on GitHub.

Note: for samples with Frida detection, always use -f to attach by spawn. -F can freeze/reboot the phone and stall your debugging.

function hook_open() {
    /*
        Function prototypes:
            int open( const char * pathname, int oflags);
            int open( const char * pathname, int oflags, mode_t mode);
        
        mode is only used when creating a new file, specifying the file's access permission.
        pathname is the path of the file to open/create;
        oflags specifies the open/create mode; it's composed of the following constants (defined in fcntl.h) via logical OR.
        O_RDONLY   read-only
        O_WRONLY   write-only
        O_RDWR     read-write
        The three above are mutually exclusive and can't be used together.
    */
    var open_addr = Module.findExportByName("libc.so","open")
    var io_map = Memory.allocUtf8String("/proc/13585/maps");
    Interceptor.attach(open_addr, {
        onEnter: function (args) {
            console.log('targetFunction called from:\n' +
                Thread.backtrace(this.context, Backtracer.ACCURATE)
                    .map(DebugSymbol.fromAddress).join('\n') + '\n');
            if(args[0].readCString().indexOf("/proc/")!=-1 && args[0].readCString().indexOf("maps")!=-1){ 
                args[0] = io_map
                // ptr(args[0]).writePointer(Memory.allocUtf8String(args[0].readCString().replaceAll(/\d+/g,"1")))
                // args[0] = Memory.allocUtf8String("/proc/1/maps")
                // Memory.protect(ptr(args[0]), args[0].readCString().length, 'rwx');
                // var value_new_str = Memory.allocUtf8String("/proc/1/maps")
                // console.log("args0="+args[0].readCString())
                // ptr(args[0]).writeByteArray([0x2f,0x70,0x72,0x6f,0x63,0x2f,0x32,0x2f,0x6d,0x61,0x70,0x73,0x0])
                // console.log("args0="+args[0].readCString())
            }
            this.pathname = args[0]
            this.oflags = args[1]
            this.mode = args[2]
            
        },
        onLeave: function (retval) {
            console.log("retval="+retval+"---"+"pathname="+this.pathname.readCString()+"---oflags="+this.oflags)
            if(this.pathname.readCString().indexOf("libmsaoaidsec")!=-1){ // gets past HuiToutiao
                retval.replace(0xffffffff)
            }
            console.log("open pathname="+this.pathname.readCString()+"---oflags="+this.oflags)
            if(this.pathname.readCString().indexOf("/proc/")!=-1 && this.pathname.readCString().indexOf("maps")!=-1){ 
                retval.replace(0x0)
            }
        }
    })
}

Let’s hook the open function and print the stack, finding the Frida-detection logic bottom-up, and learn this detection.

open hook stack

There’s a .so constantly reading status info under /proc:

retval=0x7b---pathname=/proc/13205/status---oflags=0x0
open pathname=/proc/13205/status---oflags=0x0
targetFunction called from:
0x7bfdefd314 libmonochrome_64.so!0x3c0b314
0x7bfdefd314 libmonochrome_64.so!0x3c0b314
0x7bfdefd314 libmonochrome_64.so!0x3c0b314

libmonochrome

Searching around, this looks like an SDK’s companion .so, not the author’s own .so.

From what I gathered, it’s related to the webview implementation.

So skip it.

Tip: information gathering is a huge part of reverse engineering — don’t be stingy with your Google searches.

Plenty of people have already paved the road for you.

This goes for you as much as for me; it really matters.

Locating the anti-debugging via the thread method

Note: approaches for finding Frida split into two routes:

The first, centered on anti-frida-su.js, is to satisfy the normal environment and erase all the traces Frida leaves behind. But that’s fatal, because Frida has countless signatures — you can never erase them all. You’d be better off writing your own hook tool modeled on Frida. Fortunately, plenty of people have paved the way, e.g. a well-known researcher’s 11 patches, which get past ~70% of the market. But that’s nowhere near enough.

CRC detection defeats even modified Frida. For example, the unavoidable injection into libart.so’s PrettyMethod leaves Frida nowhere to hide. What to do?

The well-known researcher already gave an answer: change the timing of hooking PrettyMethod, which gets past about 5% of samples (to save user hardware resources, it’s disabled after startup). But most samples still start a thread to do CRC loop-detection.

The first route gets more convoluted, requiring a modified ROM and Linux kernel for further hiding.

Note that CRC detection must start a thread to do its detection. Let’s discuss the second bypass: patching the thread.

Before the actual operation, let’s discuss detection granularity:

Think about what level Frida detection usually works at. Assume I’m a developer: I add a few lines checking port 23946 (one example of Frida detection — of course it’s not that simple) when navigating screens or after a data request. That detection is statement-level granularity, just a few lines of code, and unavoidable because we have to operate.

Continuing: our company has a security developer who hands me a function and I just call it directly. That’s function-level granularity.

What if our company has no developer? Outsource it — bring in a security vendor’s .so and pay up.

Following that discussion: the first case basically doesn’t exist — I’d need to know both business and security, unless I’m full-stack (which suits mini apps). That’s where all sorts of adult-streaming apps might pop up.

The second case can exist: business code mixed with security code. That’s the worst case — one thread has both detection code and business logic, so patching the thread kills the business too.

The third case is the most common: some security SDK that does device assessment, basic request encryption, and anti-debugging.

The third case splits two ways. First, a pure-detection .so with no encryption — I’d call that .so-granularity, so just skip loading the .so.

Second, a security vendor’s .so that internally computes device-id-style encryption — then skipping isn’t so simple. Locate the .so’s load header, and after you suspect it, check each segment’s loading, locate roughly where the .so is, and patch the anti-debugging.

Start Frida in spawn mode (don’t use the modified one — you can’t see the exit timing):

spawn

Found the thread being started:

normal find thread func offset libshell-super.com.showstartfans.activity.so 0x765f7450d0 360656 580d0

When this thread starts, the app dies.

Add a filter to the script:

        else if(so_name.indexOf("libshell-super.com.showstartfans.activity.so")>-1&& offset==360656){

        }

filter applied

Frida detection successfully bypassed.

Failure case: without filtering offset==360656, the app crashes outright. Anyway, I encourage you to experiment.

crash

Capturing traffic and bypassing the capture detection

PS: this article doesn’t cover unpacking — the unpacked APK is already in the root directory. Use it legally.

After turning on packet capture, the app reports a network error; turn it off and the error goes away. Confirmed capture detection.

Tip: this capture detection (not certificate-pinning style) has a special bypass requiring a soft router, plus transparent proxying (non-certificate style). The first needs hardware, the second needs a docker cloud-phone, so I’ll skip both here.

How to locate the capture detection? First find the login activity, find the login function, and step down.

var jclazz = null;
var jobj = null;

function getObjClassName(obj) {
    if (!jclazz) {
        var jclazz = Java.use("java.lang.Class");
    }
    if (!jobj) {
        var jobj = Java.use("java.lang.Object");
    }
    return jclazz.getName.call(jobj.getClass.call(obj));
}

function watch(obj, mtdName) {
    var listener_name = getObjClassName(obj);
    var target = Java.use(listener_name);
    if (!target || !mtdName in target) {
        return;
    }
    // send("[WatchEvent] hooking " + mtdName + ": " + listener_name);
    target[mtdName].overloads.forEach(function (overload) {
        overload.implementation = function () {
            //send("[WatchEvent] " + mtdName + ": " + getObjClassName(this));
            console.log("[WatchEvent] " + mtdName + ": " + getObjClassName(this))
            return this[mtdName].apply(this, arguments);
        };
    })
}

function OnClickListener() {
    Java.perform(function () {
        // when attaching in spawn mode
        Java.use("android.view.View").setOnClickListener.implementation = function (listener) {
            if (listener != null) {
                watch(listener, 'onClick');
            }
            return this.setOnClickListener(listener);
        };

        // when frida attaches in attach mode
        Java.choose("android.view.View$ListenerInfo", {
            onMatch: function (instance) {
                instance = instance.mOnClickListener.value;
                if (instance) {
                    console.log("mOnClickListener name is :" + getObjClassName(instance));
                    watch(instance, 'onClick');
                }
            },
            onComplete: function () {
            }
        })
    })
}
setImmediate(OnClickListener);

Inject the listener script and click the button:

[22041216C::the app ]-> [WatchEvent] onClick: com.showstartfans.activity.activitys.login.XDLoginActivity

onClick

We can see where the account and password are, step down, and enter the this.Z function:

this.Z

This is assembling the login bean; return up a level.

Enter the j function:

j function

Continue stepping down — we enter okhttp logic.

Enter the n.j function:

n.j

The d function is assembling the client, possibly setting proxy-disabling functions here.

Tip: I recommend building a small okhttp app and tracing through it — it helps with understanding this article.

client assembly

Enter g(context):

g(context)

Found the first proxy-detection point:

if (!x0.g()) {
            builder.proxy(Proxy.NO_PROXY);
        }

x0.g is a function, presumably a proxy-management class:

proxy manager

Indeed it is — the related proxy-detection logic is all here:

proxy detection

proxy detection 2

This call decides whether we’re detected as having a proxy on; the core logic is in this class’s i function:

NetworkCapabilities networkCapabilities;
        return (network == null || connectivityManager == null || (networkCapabilities = connectivityManager.getNetworkCapabilities(network)) == null || !networkCapabilities.hasTransport(4)) ? false : true;

I’ll leave this one for you to study:

Java.perform(function () {
    var x0Class = Java.use("i.a0.a.n.x0");

    // make sure the method exists
    if (x0Class.i.overloads.length > 0) {
        // hook all overloads (in case there are multiple)
        x0Class.i.overloads.forEach(function (overload) {
            overload.implementation = function () {
                console.log("i.a0.a.n.x0.i method hooked");

                // return false
                return false;
            };
        });
    }
});

Determining what encryption to analyze

request

request 2

This time I decided to analyze the crpsign parameter:

crpsign

Searching in jadx found it directly.

Its source is this native function:

showstart_net.so

Using a friend’s findhash IDA script, I located the encryption directly — a standard salted MD5. Combined with the input parameters, it’s straightforward to analyze.

Worth discussing: this .so is built with Rust.

The order API’s request and response bodies are both encrypted, implemented in the .so above. I found no AES signatures, but I did find a reference to Rust’s AES crate.

Guess #1: Rust data structures differ from standard C — even string termination isn’t \0-terminated.

Which makes the decompiled .so horribly messy.

Fix: implement Rust AES and extract signature points to write a plugin.

In Rust↔JNI interaction, Rust seems to implement its own runtime, so some syscalls can’t run under unidbg. Hope someone can research this together! If unidbg is patched up, then all Rust-built .so files could gain basic environment-emulation capability.

If you’re interested, keep digging and reach out to discuss.

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