A First Look at a Connected-Car App Hardened with a Commercial Packer

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 First Look at a Connected-Car App Hardened with a Commercial Packer

0x1 — Setup

Target app: a connected-car app, v6.1.0

Tools: Cydia, Frida, Objection, Storm Sniffer (packet capture)

0x2 — Packet capture

  1. Open Storm Sniffer (available on the App Store; Fiddler also works).
  2. The app has SSL pinning enabled, so capture fails.

SSL pinning blocks capture

  1. Bypass SSL pinning with objection:

    objection -g TargetApp explore
    ios sslpinning disable --quiet

Bypassing SSL pinning with objection

  1. Now capture works.

The app reports a wrong username/password:

https://appserver.example.com/app/auth/login
User-Agent: targetapp/6.0.1 (iPhone; iOS 14.0; Scale/3.00)
Content-Type: application/json
Host: appserver.example.com

{"request":"FESWLgT0LjY\/GjPQmt4xYvDWajog909VYoUcLQ8TE7E\/F4U9GwHtbmfY9uZbmqPnGW2ys0NRnezIjTETA64wpvfP3aZV+caLdK8fsLovfxoacBp5hIkvlS4hTEYkl8XfIfashQDMOdYmm8W89ZBwpFF+Ipd9hFy7NGHDom4SgXQ5uE7Io8jFnoGiz5CiQ9B3K0NbCSOkSbbwJi14iFi15sy3car\/W3xMNUZuo7\/vNE7Dz\/eHwxroJ7L+DK\/6yXbNLRHMHRtXJykfnD31ShNTEqzkF+Fp2Z6jUuFFpn4B7SB4uJYWe5cJ9g3gDIOEifHEhhD3LCQfOEKppgisduO8el4rEGFaKPx1bzOAIk0W1qPXk0LbVCFPQYFpypUB8Rrn2hBBsA8cepRBBtH73jeRcvrRMBc0gK+YRw4uq8yd44bNoEpSt1L5YOJBelSxmp9WOhkGOjt5cPCHij1ptaO9gp1d7XAg4+MhpzXoluB8aPi8A28stX04l+HdU6lNU0pUGYtFVRbTvYV4KjseXQ5wlRavwFMgv3c61WUMznyLmtI8zdheCVdr3vGG1nNvGI7cqMHdw8jvdYFCe9jr3eu1nG2uHakh7PHDMyEDpEh7WSJvVod+0sleiS+vhv5xnNhXUOtBtjCUcLSZToY6YJky9O4E8WDOGg2j5kvCaHrY\/bgSGd87O7sCkmrjeYEJj5BSV95tlI2Ky1GlTRvG4jLQ800QuXAJyXQJpf9uO5lbmV2yPJ7OcKbRoaRu7rhMj4yok9dPEY5bIJeMFptv+bBTKgRh18R5ZecUdpyx8PrVY7FvTEihzH9z+ACGIMNgw7MZIVRPBqLh\/zXK1JfY3pPblvLVhmbeWQ6ETTC2eEWDmAcBovF+Mpj\/M2ybwRdFGLATHZJUQCobYS+02Jxfd0\/ODHpOQ7c2RJwZh6aijIEeh3Ohj1weh\/ZNAK7189yi3n810Bne0ld\/Vo11Jk7hSSQ9z1vCZijCzbDb4Yuw+jsnM\/CPS0OBMMQqIsuz9TPJ\/89SP103sVv01AOXWUM0OB97POJpQ8Bnfjusf8x7CKURlyj4hl6NHksSAGeOmPv96B\/X\/xEARr3Lq7K0RnVFjqFJUsXVVJavggfrhexl7xgvU7MqOPZ24ZiMmOTKTKP+WVsykcraeoP9+yuYkhdMLPEBelpnkoHOuHvpqojhOcJvN8pu\/tRclZO6KGuRD8I7M3J2+mMo8ZTNQUB7oYVR\/UfIKJ5+aIybJklZ1HTb5nV\/D1tDgWRN5JDvndJ78cEi6s8Hi9gsZjglN1JNaQ3vShP7RuLSYdRqDO7e27cSkibTH4YW6PsHPHhqX2g4ev8WTEbRIAsiVJTOuJqNaPaoX3oDMviBz5aZ0iu6XwRwbojibWBCOABktvzVNgngxxeyPmyJlVQ5H5ehn\/iNNZC4RRz0FqhetTfWdQWXnRqicq+tiujO478mQQVKTDKvhi6y0uL2g2SuR81QJ0YmhlCFxqFWX2ACpro\/4HM5J0EdJvboVwrR70CEBa6pqwsvCSCQCltwM+EDH+OYxt34oL1ngY7+fsm1oZcyEvn3\/snVZcd97klBzdl5oCyj3ANtoDIQua35u2ENXbPc8CqIIlcbPVsmF5U1ESRs5khYlDjBH6qvpZBoA4a36pgMZVu8XKpk7c+bOIB0WrZcMSaIF+r2x0Jg8ZKjrZ\/AwhoRDHAtEIQNDLNkPDvjoEcoI0CF3\/p\/AVyjko9aqtatyNZE7i0MQ4SUdamyogd428NFygGgq5JtoSe3XXL\/3hWxlbTGqESnPXKjh7jTG5FQxy6grBp7lVN2DSW2yNkvVOrtVaRjIbTzJd7\/oiUE29Yx1l\/1tPpJTq0CDSa2sTGgYHyPkppcoA3OXLiEGuQC0lKuvuahyAh956uueAi7DvMUWzPemfszzr6Bd35RucQE5tFjzQ19QIIDGhxLHMfweQHO2jEUuTddqRbnO3ZVyM8jZpcNBwMKbuqjk"}

{"response":"Fel63A3fJsBnqDNoqrf3sNRYntlgl2bMzWrA56aEQZRggnMBao9z1ds1G/TqV2TMwrRu1wovdv5AwWkDlpdqO0A=="}

0x3 — Decrypting the app

Dump the IPA with frida-ios-dump, pull out the main binary, and load it into IDA.

0x4 — Locating the encryption

4.1 The idea: hook -[NSURLRequest initWithURL:] with Frida and print the call stack.

    try {

        var className = "NSURLRequest";
        var funcName = "- initWithURL:";

        var hook = eval('ObjC.classes.' + className + '["' + funcName + '"]');
        Interceptor.attach(hook.implementation, {

            onEnter: function(args) {
                console.log(colors.green,"NSURLRequest with URL: ",colors.resetColor + ObjC.Object(args[2]));
                console.log("\nBacktrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE)
.map(DebugSymbol.fromAddress).join("\n"));
            },

        });

    } catch (error) {
        console.log(colors.red,"[!] Exception: ",colors.resetColor + error.message);
    }

Output:

[iPhone::TargetApp ]->  NSURLRequest with URL:  https://appserver.example.com/app/auth/login
Backtrace:
0x1057d943c /private/var/containers/Bundle/Application/48133A5D-90E1-494C-8472-79C2B5DE912B/TargetApp.app/TargetApp!-[AFHTTPRequestSerializer requestWithMethod:URLString:parameters:error:]
0x1057d106c /private/var/containers/Bundle/Application/48133A5D-90E1-494C-8472-79C2B5DE912B/TargetApp.app/TargetApp!-[AFHTTPSessionManager dataTaskWithHTTPMethod:URLString:parameters:headers:uploadProgress:downloadProgress:success:failure:]
0x1057d0780 /private/var/containers/Bundle/Application/48133A5D-90E1-494C-8472-79C2B5DE912B/TargetApp.app/TargetApp!-[AFHTTPSessionManager POST:parameters:headers:progress:success:failure:]
0x1049e1e24 /private/var/containers/Bundle/Application/48133A5D-90E1-494C-8472-79C2B5DE912B/TargetApp.app/TargetApp!+[AFNRequestUtls postRequestWithInfo:successBlock:failedBlock:]
0x104b4d270 /private/var/containers/Bundle/Application/48133A5D-90E1-494C-8472-79C2B5DE912B/TargetApp.app/TargetApp!+[AppHttpsRequestUtils httpsPostRequestWithRequestRequestInfo:successBlock:failedBlock:]
0x1048f48fc /private/var/containers/Bundle/Application/48133A5D-90E1-494C-8472-79C2B5DE912B/TargetApp.app/TargetApp!-[AppUserModel doLoginSuccess:failed:]
0x104a4c4f8 /private/var/containers/Bundle/Application/48133A5D-90E1-494C-8472-79C2B5DE912B/TargetApp.app/TargetApp!-[AppLoginVC doLogin]
...

AFHTTPRequestSerializer tells us the app uses AFNetworking. Hook -[AFHTTPRequestSerializer requestWithMethod:URLString:parameters:error:] to grab the parameters — they’re already encrypted. So the encryption happens inside +[AppHttpsRequestUtils httpsPostRequestWithRequestRequestInfo:successBlock:failedBlock:].

4.2 Look at +[AppHttpsRequestUtils httpsPostRequestWithRequestRequestInfo:successBlock:failedBlock:] in IDA:

The AppHttpsRequestUtils method in IDA

Hopper’s decompiler misbehaved here — I couldn’t figure out how to change the function range in Hopper; if anyone knows, leave a comment.

Hopper decompiler trouble

Take a look at the CFG. Yikes — control-flow obfuscation. Keep your cool, no big deal; check the pseudocode first.

Control-flow graph

It creates a network-request requestInfo instance. Find the call that sets the parameters:

The requestInfo instance creation

There it is — +[AppDataManager entryRequestInfo:] returns the encrypted content. The v6 passed in is the AES-encrypted plaintext message dictionary, with the key being $key + $value concatenated and SHA-1 hashed (leaving a TODO here — that part gets complicated). Let’s follow it.

AppDataManager entryRequestInfo

What the… this is getting absurd. Keep reading the pseudocode:

BangSafeSDK and the checkcode call

There’s the request string. BangSafeSDK confirms this is the encryption of a commercial hardening SDK. Digging further into +[BangSafeSDK checkcode:dataStyle:]: it UTF-8-encodes the content, grabs the current time, and finally calls sub_1023BE0D4.

I wanted to check sub_1023BE0D4’s CFG, but IDA errored out — too many nodes.

sub_1023BE0D4 CFG error

Now what? Blind guess: control-flow obfuscation again (used to it by now). Time to trace it — thanks to misskings for the Stalker trace idea.

/// <reference path="frida-gum.d.ts" />


//+[AppHttpsRequestUtils httpsPostRequestWithRequestRequestInfo:successBlock:failedBlock:]
var target = ObjC.classes['AppHttpsRequestUtils']['+ httpsPostRequestWithRequestRequestInfo:successBlock:failedBlock:']
console.log(target.implementation)

var m = Process.getModuleByAddress(target.implementation)
var vm = m.base.add(0x23BE0D4)// note the base address IDA uses for the binary
var baseAddr = m.base
console.log(vm);
Interceptor.attach(vm, {
    onEnter: function (args) {
        console.log("enter")
        this.tid = Process.getCurrentThreadId()
        console.log(this.tid);
        Stalker.follow(this.tid, {
            events: {
                // not needed for now
                call: false,
                ret: false,
                exec: false,

                block: false,
                compile: false
            },

            transform: function (iterator) {

                var instruction = iterator.next();
                const startAddress = instruction.address;
                                    // get sub_1023BE0D4's address range from IDA (alt+p)
                var is = startAddress.compare(baseAddr.add(0x23CBB6C))<0 && startAddress.compare(baseAddr.add(0x23BE0D4))>=0;
                do{
                        if(is){
                            console.log(instruction.address.sub(baseAddr).add(0x100000000) + ":" + instruction);
                        }
                    
                    iterator.keep();
                } while ((instruction = iterator.next()) !== null);


            }
        })

    },
    onLeave: function (ret) {
        Stalker.unfollow(this.tid)
    }
})

Traced instructions:

0x1023be0e4:stp x20, x19, [sp, #0x40]
0x1023be0e8:stp x29, x30, [sp, #0x50]
0x1023be0ec:add x29, sp, #0x50
0x1023be0f0:sub sp, sp, #0x220
0x1023be0f4:sub x9, x29, #0x98
0x1023be0f8:adrp x8, #0x106f3d000
0x1023be0fc:ldr x8, [x8, #0xd70]
0x1023be100:ldr x8, [x8]
0x1023be104:str x8, [x9, #0x40]
0x1023be108:mov w8, #0x12c
0x1023be10c:cmp x0, #0
0x1023be110:str w8, [sp, #0x130]
0x1023be114:mov w8, #0x2e
0x1023be118:mov w9, #0x4c
0x1023be11c:csel w8, w9, w8, eq
0x1023be120:stp w8, w2, [sp, #0xb0]
0x1023be124:cmp w2, #4
0x1023be128:mov w8, #0x53
0x1023be12c:csel w8, w9, w8, eq
0x1023be130:str w8, [sp, #0x34]
0x1023be134:mov w8, #0x12
0x1023be138:mov w9, #0x34
0x1023be13c:csel w8, w9, w8, eq
......

With an IDA Python script, strip the unused branches:

import sark
import idc
import sys
def patch_code(addr,code):
    for i in range(len(code)):
        idc.patch_byte(addr+i,code[i])
 
# patch the bytes at the address to nop
def nop(addr):
    nop_code=[0x1f,0x20,0x03,0xd5]
    patch_code(addr,nop_code)
 
def main():
    logfilename="./log.txt"
    # the function's start/end range
    start=0x1023BE0D4
    end=0x1023CBB6C
    addrs=[]
    # read all executed instruction addresses
    with open(logfilename,"r") as logfile:
        lines=logfile.readlines()
        for line in lines:
            addrs.append(int(line.replace("\n","").split(':')[0],16))
    # recolor executed addresses for visibility
    for addr in addrs:
        line=sark.line.Line(addr)
        line.color=0x00ffff
 
    # all instruction addresses in the target function
    funcLines=sark.lines(start,end)
    for line in funcLines:
        # if the line is code, check whether it's marked as executed
        if line.type=="code":
            if line.color!=0x00ffff:
                nop(line.ea)
 
 
if __name__=="__main__":
    main()

Check the CFG again:

CFG after de-obfuscation

Still nothing — so I called it here.

Giving up for now. Leaving this as a TODO: it’s still control-flow obfuscation, so I’ll stop the analysis here.


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