Deobfuscating libDexHelper.so: Inside an Enterprise Android Shell

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.


Deobfuscating libDexHelper.so: Inside an Enterprise Android Shell

This is the third in a series on deobfuscating .so files on Android. The first covered libbaiduprotect.so (a free shell); the second covered libmtguard.so (a food-delivery app). Today’s target is an enterprise 64-bit sample I found with LibChecker — recent enough to be interesting. Based on prior analysis by others, libDexHelper.so is the instruction-extraction implementation, and libdexjni.so is the VMP implementation.

Removing the obfuscation

Since it’s encrypted, it can’t be decompiled directly. Breakpoint in libart.so, enter JNI_OnLoad, and dump it:

Figure 1 — Dumping from JNI_OnLoad

But it still won’t F5 cleanly. The remaining obfuscation:

1. Junk instructions

Figure 2 — Junk instructions

These junk instructions live in a switch branch that is never executed. Wherever IDA can’t MakeCode, patch it to NOP and MakeCode again.

2. String encryption

There are several string-decryption functions: 0x186C4, 0x7783C, 0x95B9C. In the first post (libbaiduprotect.so), I took the cross-references to get the table of encrypted strings and their decrypt functions, then called them via Frida to get the plaintext. That approach doesn’t work well here, because the encrypted string is assembled byte-by-byte on the stack, often many instructions — or even a whole block — away from the final call to the decrypt function.

The approach I settled on: take the call to the decrypt function 0x186C4 at 0x40110 as an example. The decryption logic is simple and needs three arguments. I could reimplement it myself or use unicorn — I used unicorn.

import sys
import unicorn
import binascii
import threading
import subprocess

from capstone import *
from capstone.arm64 import *

with open("C:\\Users\\hjy\\Downloads\\out1.fix.so","rb") as f:
    sodata = f.read()

uc = unicorn.Uc(unicorn.UC_ARCH_ARM64, unicorn.UC_MODE_ARM)
code_addr = 0x0
code_size = 8*0x1000*0x1000
uc.mem_map(code_addr, code_size)
stack_addr = code_addr + code_size
stack_size = 0x1000000
stack_top = stack_addr + stack_size - 0x8
uc.mem_map(stack_addr, stack_size)
uc.mem_write(code_addr, sodata)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X29, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X28, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X27, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X26, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X25, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X24, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X23, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X22, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X21, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X20, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X19, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X18, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X17, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X16, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X15, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X14, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X13, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X12, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X11, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X10, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X9, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X8, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X7, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X6, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X5, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X4, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X3, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X2, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X1, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X0, stack_addr)
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_SP, stack_top)
X0 = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_X0)

uc.mem_write(X0, bytes.fromhex(sys.argv[1]))
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X1, int(sys.argv[2], 16))
uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X2, int(sys.argv[3], 16))

uc.emu_start(0x1777C, 0x17780)

X0 = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_X0)
decstr = uc.mem_read(X0, 80)

print("decstr:", decstr)
uc.mem_unmap(stack_addr, stack_size)
uc.mem_unmap(code_addr, code_size)

There are several hundred call sites, so doing this by hand is out of the question. I wrote a second script that calls decstr.py: it finds every call to a decrypt function via cross-references, sets the start address to the start of that block and the end address to the call site, runs unicorn to recover the three arguments decstr.py needs, then invokes decstr.py. On unicorn.unicorn.UcError there are two strategies — skip the address (loop_call_prepare_arg1, keeping the start address) or advance the start address to the next instruction (loop_call_prepare_arg2). The approach still has room to improve: if the code that prepares the arguments and the call to the decrypt function aren’t in the same block, it can’t handle them.

import unicorn
import binascii
import threading
import subprocess

from capstone import *
from capstone.arm64 import *

inscnt = 0
start_addr = 0
end_addr = 0
stop_addr = 0
stop_addr_list = []

def hook_code(uc, address, size, user_data):
    global inscnt
    global end_addr
    global stop_addr
    global stop_addr_list

    md = Cs(CS_ARCH_ARM64, CS_MODE_ARM)

    for ins in md.disasm(sodata[address:address + size], address):
        #rint(">>> 0x%x:\t%s\t%s" % (ins.address, ins.mnemonic, ins.op_str))
        stop_addr = ins.address

        if ins.address in stop_addr_list:
            #print("will pass 0x%x:\t%s\t%s" %(ins.address, ins.mnemonic, ins.op_str))
            uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_PC, address + size)
            return

        inscnt = inscnt + 1
        if (inscnt > 500):
            uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_PC, 0xffffffff)
            return

        if ins.mnemonic.find("b.") != -1:
            print("will pass 0x%x:\t%s\t%s" %(ins.address, ins.mnemonic, ins.op_str))
            uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_PC, address + size)
            return

        if ins.mnemonic.find("bl") != -1:
            print("will pass 0x%x:\t%s\t%s" %(ins.address, ins.mnemonic, ins.op_str))
            uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_PC, address + size)
            return

            if ins.op_str in ["x0","x1","x2","x3"]:
                    X1 = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_X1)
                    if X1 > 0x105A88:
                        print("will pass 0x%x:\t%s\t%s" %(ins.address, ins.mnemonic, ins.op_str))
                        uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_PC, address + size)
                        return
            if ins.op_str.startswith("#0x"):
                addr = int(ins.op_str[3:],16)
                if (addr > 0x14E50 and addr < 0x15820) \
                or addr == 0x186C4 \
                or addr > 0x105A88:
                    print("will pass 0x%x:\t%s\t%s" %(ins.address, ins.mnemonic, ins.op_str))
                    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_PC, address + size)
                    return

def call_prepare_arg():
    global inscnt
    global start_addr
    global end_addr
    global stop_addr
    global stop_addr_list

    inscnt = 0

    uc = unicorn.Uc(unicorn.UC_ARCH_ARM64, unicorn.UC_MODE_ARM)
    code_addr = 0x0
    code_size = 8*0x1000*0x1000
    uc.mem_map(code_addr, code_size)
    stack_addr = code_addr + code_size
    stack_size = 0x1000000
    stack_top = stack_addr + stack_size - 0x8
    uc.mem_map(stack_addr, stack_size)
    uc.hook_add(unicorn.UC_HOOK_CODE, hook_code)

    uc.mem_write(code_addr, sodata)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X29, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X28, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X27, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X26, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X25, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X24, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X23, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X22, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X21, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X20, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X19, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X18, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X17, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X16, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X15, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X14, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X13, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X12, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X11, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X10, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X9, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X8, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X7, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X6, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X5, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X4, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X3, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X2, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X1, stack_addr)
    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_X0, stack_addr)

    uc.reg_write(unicorn.arm64_const.UC_ARM64_REG_SP, stack_top)
    uc.emu_start(start_addr, end_addr)

    X0 = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_X0)
    decstr = uc.mem_read(X0, 80)
    end_index = decstr.find(bytearray(b'\x00'), 1)
    decstr = decstr[:end_index]

    decstr = binascii.b2a_hex(decstr)
    decstr = decstr.decode('utf-8')

    X1 = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_X1)
    X2 = uc.reg_read(unicorn.arm64_const.UC_ARM64_REG_X2)

    pi = subprocess.Popen(['C:\\Python38\\python.exe', 'decstr.py', decstr, hex(X1), hex(X2)], stdout=subprocess.PIPE)
    output = pi.stdout.read()
    print(output)

def loop_call_prepare_arg1():
    global inscnt
    global end_addr
    global stop_addr
    global stop_addr_list

    loopcnt = 0
    stop_addr_list = []

    while True:
        try:
            loopcnt = loopcnt + 1
            if(loopcnt > 200):
                break
            call_prepare_arg()
        except unicorn.unicorn.UcError:
            print("adding....")
            print(hex(stop_addr))
            stop_addr_list.append(stop_addr)
        else:
            break

def loop_call_prepare_arg2():
    global inscnt
    global end_addr
    global stop_addr
    global stop_addr_list

    global start_addr

    loopcnt = 0
    stop_addr_list = []

    while True:
        try:
            loopcnt = loopcnt + 1
            if(loopcnt > 200):
                break
            call_prepare_arg()
        except unicorn.unicorn.UcError:
            start_addr = stop_addr + 4
        else:
            break

with open("C:\\Users\\hjy\\Downloads\\out1.fix.so","rb") as f:
    sodata = f.read()

all_addr = []
with open('xref_decstr.txt', 'r', encoding='utf-8') as f:
    for line in f:
        addr = "0x" + line[2:]
        addr = int(addr, 16)
        all_addr.append(addr)

for i in all_addr:

    print("i:")
    print(hex(i))

    end_addr = i
    CODE = sodata[i - 4:i]
    md = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
    for x in md.disasm(CODE, i - 4):
        mnemonic = x.mnemonic

    while mnemonic != "ret" \
        and mnemonic != "b" \
        and mnemonic != "br" \
        and mnemonic != "cbz" \
        and mnemonic != "cbnz":
        i = i - 4
        CODE = sodata[i - 4:i]
        for x in md.disasm(CODE, i - 4):
            mnemonic = x.mnemonic

    start_addr = i

    print("start_addr:")
    print(hex(start_addr))
    print("end_addr:")
    print(hex(end_addr))

    loop_call_prepare_arg1()
    loop_call_prepare_arg2()

Even worse, many strings are decrypted inside the function itself — I haven’t found a good method for those either.

3. Control-flow obfuscation

The first kind shuffles normally sequential instructions into a switch form; it doesn’t hurt too much:

Figure 3 — Switch-form control flow

The second kind computes the jump target dynamically — basically what I saw in libmtguard.so, but more complex:

Figure 4 — Dynamically computed jump target

Take the instructions here: at 0x1DA0C a value is assigned to X2 — an address in the .data segment — with W0 as the offset. After reading the value, at 0x1DA18 it’s multiplied by 4 and added to 0x1DA20; the result is X0’s value at 0x1DA1C. That raises a few questions:

  • How to determine the value assigned to X2 at 0x1DA0C.
  • How to determine 0x1DA00, where the instruction should be changed into a jump.
  • How to find every instruction that jumps to 0x1DA1C and rewrite the jump target to the computed X0.

The first problem is like the string decryption case: find the STR XX, [X29,#0x190+var_118] that corresponds to the LDR X2, [X29,#0x190+var_118], then find the instruction that assigns XX — but those two instructions may be several blocks away from the BR X0. My solution uses IDA’s idaapi.FlowChar to recurse backwards through blocks. The limitation: IDA must have correctly identified the function’s start, otherwise the needed instruction and the BR X0 may end up in different functions and it won’t work.

For the second problem, while recursing backwards I first find the instruction at 0x1D9D4 that assigns W0, then from 0x1D9D4 up to 0x1DA1C find the first address with a cross-reference — 0x1DA04. Its preceding instruction, 0x1DA00, is where the jump should go.

For the third problem, once 0x1DA00 is fixed, every cross-referenced address from 0x1DA00 to 0x1DA1C needs its jump target rewritten. There are details here:

(1) If W0 is assigned by CSEL, CSET, CSINC and the like, as below, then 0x1DE80 and 0x1DE84 must be changed to B.GE and B.LT.

(2) At 0x1DE80, CSEL W0, WZR, W8, LT — W8 is assigned at 0x1D9DC MOV W8, #5, so my code keeps a register_value_dict: after patching the instruction at 0x1DA00, it reads all instructions from the block at 0x1DA00 to the block at 0x1DA1C, finds the register assignments, and stores the values.

Figure 5 — Before the patch

Figure 6 — After the patch

(3) Some places also have a sub instruction to account for — e.g. below, the jump at 0x33394 should be computed with W8 equal to 4.

Figure 7 — A sub instruction to account for

The final script is attached. There are still cases it can’t handle, but they’re no longer a big deal — dynamic debugging can pin those down.

4. Dynamically computed function addresses

Figure 8 — Dynamically computed function address (1)

Figure 9 — Dynamically computed function address (2)

This one is clear in IDA: v35 is really off_12EB80[0] — i.e. it calls p329AAB59961F6410ABA963EF972FE303 at 0x80FE0.

Now let’s look at what libDexHelper.so actually does. Time is limited, so I couldn’t analyze every part in detail, and some of the analysis may be off — read with that caveat.

Functional analysis

The analysis of JNI_OnLoad (0x3EA68) is at the end.

0x15960

Reads /proc/self/maps. Signature strings:

libDexHelper.so
libDexHelper-x86.so
libDexHelper-x86_64.so
/system/lib64/libart.so
/system/lib64/libLLVM.so
/system/framework/arm64/boot-framework.oat
/system/lib64/libskia.so
/system/lib64/libhwui.so
.oat
ff c3 01 d1 f3 03 04 aa f4 03 02 aa f5 03 01 aa e8 03 00 aa
GumInvocationListener
GSocketListenerEvent

0x16A30

Reads system properties and /proc/%d/cmdline. Signature strings:

ro.yunos.version
ro.yunos.version.release
persist.sys.dalvik.vm.lib
persist.sys.dalvik.vm.lib.2
/system/bin/dex2oat
LD_OPT_PACKAGENAME
LD_OPT_ENFORCE_V1

off_12EF10: 2 means YunOS in ART mode; 1 means YunOS in dalvik mode; 0 means not YunOS.

0x17A70

md5.

0x186C4

The string-decryption function.

0x19674

Returns the string rw.

0x19778

Returns the string su.

0x1987C

Returns the string mount.

0x19998

Writes the classes.dve file.

0x19B48

Reads files in a directory.

0x19E08

Creates a String array; first argument is a string list, second is the array length.

0x1A058

Calls 0x19E08 to create an array:

/etc
/sbin
/system
/system/bin
/vendor/bin
/system/sbin
/system/xbin

0x1A740

Calls 0x19E08 to create an array:

com.yellowes.su
eu.chainfire.supersu
com.noshufou.android.su
com.thirdparty.superuser
com.koushikdutta.superuser
com.noshufou.android.su.elite

0x1AF1C

Calls 0x19E08 to create an array:

com.chelpus.lackypatch
com.ramdroid.appquarantine
com.koushikdutta.rommanager
com.dimonvideo.luckypatcher
com.ramdroid.appquarantinepro
com.koushikdutta.rommanager.license

0x1B7D0

Calls 0x19E08 to create an array:

com.saurik.substrate
com.formyhm.hideroot
com.amphoras.hidemyroot
com.devadvance.rootcloak
com.formyhm.hiderootPremium
com.devadvance.rootcloakplus
com.amphoras.hidemyrootadfree
com.zachspong.temprootremovejb
de.robv.android.xposed.installer

0x1C40C

Reads ro.product.cpu.abi via system_property_get and reads /system/lib/libc.so to check for x86.

0x1C61C

Checks whether classes.dve exists.

0x1C8D8

Calls 0x19E08 to create an array:

/sbin/
/su/bin/
/data/local/
/system/bin/
/system/xbin/
/data/local/bin/
/system/sd/xbin/
/data/local/xbin/
/system/bin/.ext/
/system/bin/failsafe/
/system/usr/we-need-root/

0x1D518

Initializes some paths. Signature strings:

.cache
oat
.payload
v1filter.jar
classes.odex
classes.vdex
classes.dex
assets/classes.jar
.cache/classes.jar
.cache/classes.dex
.cache/classes.odex
.cache/classes.vdex

0x1E520

Puts the addresses of some libc functions into .DATA:

0x137BB0 fopen
0x137BB8 fclose
0x137BC0 fgets
0x137BC8 fwrite
0x137BD0 fread
0x137BD8 sprintf
0x137BE0 pthread_create

0x1F250

Reads /proc/self/cmdline and checks for com.miui.packageinstaller to tell whether it was launched by Xiaomi’s package-installer component.

0x1F710

First reads ro.product.manufacturer and ro.product.model to check for Samsung, then reads ro.build.characteristics for emulator.

0x1FDC8

Registers the following native methods:

RegisterNative(com/secneo/apkwrapper/H, attach(Landroid/app/Application;Landroid/content/Context;)V, RX@0x4002f6e4[libDexHelper.so]0x2f6e4)
RegisterNative(com/secneo/apkwrapper/H, b(Landroid/content/Context;Landroid/app/Application;)V, RX@0x400247c0[libDexHelper.so]0x247c0)
RegisterNative(com/secneo/apkwrapper/H, c()V, RX@0x40024c08[libDexHelper.so]0x24c08)
RegisterNative(com/secneo/apkwrapper/H, d(Ljava/lang/String;)Ljava/lang/String;, RX@0x40023d04[libDexHelper.so]0x23d04)
RegisterNative(com/secneo/apkwrapper/H, e(Ljava/lang/Object;Ljava/util/List;Ljava/lang/String;)[Ljava/lang/Object;, RX@0x40035ab0[libDexHelper.so]0x35ab0)
RegisterNative(com/secneo/apkwrapper/H, f()[Ljava/lang/String;, RX@0x4001a740[libDexHelper.so]0x1a740)
RegisterNative(com/secneo/apkwrapper/H, g()[Ljava/lang/String;, RX@0x4001af1c[libDexHelper.so]0x1af1c)
RegisterNative(com/secneo/apkwrapper/H, h()[Ljava/lang/String;, RX@0x4001b7d0[libDexHelper.so]0x1b7d0)
RegisterNative(com/secneo/apkwrapper/H, n()[Ljava/lang/String;, RX@0x4001c8d8[libDexHelper.so]0x1c8d8)
RegisterNative(com/secneo/apkwrapper/H, j()[Ljava/lang/String;, RX@0x4001a058[libDexHelper.so]0x1a058)
RegisterNative(com/secneo/apkwrapper/H, k()Ljava/lang/String;, RX@0x40019778[libDexHelper.so]0x19778)
RegisterNative(com/secneo/apkwrapper/H, l()Ljava/lang/String;, RX@0x4001987c[libDexHelper.so]0x1987c)
RegisterNative(com/secneo/apkwrapper/H, m()Ljava/lang/String;, RX@0x40019674[libDexHelper.so]0x19674)
RegisterNative(com/secneo/apkwrapper/H, bb(Landroid/content/Context;Landroid/app/Application;Landroid/app/Application;)V, RX@0x4002921c[libDexHelper.so]0x2921c)
RegisterNative(com/secneo/apkwrapper/H, o(Landroid/content/Context;)I, RX@0x4002f158[libDexHelper.so]0x2f158)
RegisterNative(com/secneo/apkwrapper/H, p()V, RX@0x4001875c[libDexHelper.so]0x1875c)
RegisterNative(com/secneo/apkwrapper/H, q()I, RX@0x40023568[libDexHelper.so]0x23568)
RegisterNative(com/secneo/apkwrapper/H, mu()I, RX@0x4001f250[libDexHelper.so]0x1f250)

0x218A8

Reads ro.build.version.release, ro.build.version.sdk, and ro.build.version.codename via system_property_get, and returns the SDK version.

0x22068

Creates some directories:

/data/usr/0/<package>/.cache/oat
/data/usr/0/<package>/.cache/oat/arm64
/data/usr/0/<package>/.payload

0x22A90

Emulator detection. Signature strings:

vboxsf
/mnt/shared/install_apk
nemusf
/mnt/shell/emulated/0/Music sharefolder
/sdcard/windows/BstSharedFolder

0x23568

Reads /proc/pid/cmdline looking for the string :bbs — I never figured out what that’s for. The function is named is_magisk_check_process.

0x247C0

Calls setOuterContext.

0x24C08

Reads ro.product.brand via system_property_get; for Huawei/Honor devices it calls startLoadFromDisk.

0x26278

Calls getDeclaredFields to get the field-object array, then calls equals and returns the requested field object.

0x27290

Modifies mInitialApplication and mClassLoader.

0x2921C

Modifies mAllApplications (remove and add).

0x29CE8

Emulator detection. Signature strings:

com.bignox.app.store.hd
com.bluestacks.appguidance
com.bluestacks.settings
com.bluestacks.home
com.bluestack.BstCommandProcessor
com.bluestacks.appmart

0x2B670

Determines debug vs release via FLAG_DEBUGGABLE.

0x2CAE0

Gets the signature’s md5 via android.content.pm.Signature.

0x2F158

Checks for root by accessing these files:

/sbin/.magisk/
/sbin/.core/img
/sbin/.core/mirror
/sbin/.core/db-0/magisk.db

0x2F6E4

Reads /proc/self/cmdline, calls the Java-level com.secneo.apkwrapper.H.j, calls bindService, gets android_id, calls android.app.Application.attach, and — if the package name is com.huawei.irportalapp.uat — calls setOuterContext.

0x31474

Calls the Java-level com.secneo.apkwrapper.H.f(ff) to load v1filter.jar and the original dex. Looking at /proc/self/maps:

anon:dalvik-classes.dex extracted in memory from v1filter.jar
anon:dalvik-DEX data

I dump the extra segments. The original dex:

Figure 10 — The original dex

Instruction virtualization is executed by JniLib.cV; the last argument is a function-code index used to look up the virtualized instructions, the rest are method parameters:

Figure 11 — Instruction virtualization

v1filter.jar:

Figure 12 — v1filter.jar

0x3371C

Hooks android_log_write and android_log_buf_write in libcutils.so/liblog.so and makes them return 0.

0x339FC

currentActivityThread → mPackages → LoadedApk → mResources → getAssets.

0x34A00

Calls android.content.res.Resources.getAssets; falls back to 0x339FC on failure.

0x351DC

Reads an assets file.

0x35AB0

Calls makeInMemoryDexElements on the argument and modifies dalvik.system.DexFile.mFileName.

0x3766C

Initializes the following strings:

/data/user/0/cn.missfresh.application/.cache/classes.jar
/data/user/0/cn.missfresh.application/.cache/classes.dex
/data/user/0/cn.missfresh.application/.cache/v1filter.jar

Calls 0x80458 to compute a package-name hash, then 0x75AA8. Calls AAssetManager_open to read assets/resthird.data and write it to v1filter.jar, then calls 0x31474. (Per other write-ups, it should read two files under assets: classes0.jar, the encrypted dex, and classes.dgc, the encrypted extracted instructions. But the sample I analyzed had neither — maybe they were renamed.)

0x398F8 / 0x3A08C

Detects dexhunter (dumpclass is a dexhunter thing, I think). Signature strings:

_Z16hprofDumpClassesP15hprof_context_t
_Z12dvmDumpClassPK11ClassObjecti
_Z9dumpClassP7DexFilei
dumpclass
dump_class

0x3BF10

Takes a filename and returns whether the file exists.

0x3BF7C

Emulator detection. Signature strings:

ueventd.ttVM_x86.rc
init.ttVM_x86.rc
fstab.ttVM_x86
bluestacks
BlueStacks

0x3CE14

Reads ro.debuggable via system_property_get and calls the emulator-detection functions.

0x3D814

Listens for USB state via android.hardware.usb.action.USB_STATE.

0x42378

md5.

0x44708

Hooks the following functions (anti-debugging):

vmDebug::notifyDebuggerActivityStart  (patched to 0x446C0)
art::Dbg::GoActive                    (patched to 0x446E4)
art::Runtime::AttachAgent             (patched to 0x45CF8)

0x46194

Reads ro.yunos.version via system_property_get.

0x4C2F0

Hooks the following functions (instruction extraction / restoration):

art::ClassLinker::DefineClass           (patched to 0x46BB8)
art::ClassLinker::LoadMethod            (patched to 0x46ED4/0x47BB8/0x488C0/0x491F8/0x49B0C)
art::OatFile::OatMethod::LinkMethod     (patched to 0x46BD8/0x46DB0)

0x4DB80

md5.

0x50280

Reads /proc/self/maps and finds the segment containing the package name.

0x5074C

Calls the Java-level com.secneo.apkwrapper.H1.find_dexfile.

0x50B60

Calls java.lang.StackTraceElement.getMethodName and getClassName.

0x57424

Loads classes.dgg from assets.

0x598FC

Reads /proc/self/maps and finds libDexHelper.so.

0x59CE8

Sets up dex2oat arguments: --zip-fd, --oat-fd, --zip-location, --oat-location, --oat-file, --instruction-set.

0x5C600

Hooks functions in libdvm.so (similar to 0x67544); didn’t look closely. 0x5BAA8–0x5BEF8 are the patched implementations.

0x61E3C

Hooks the following libc functions:

fstatat64   (patched to 0x5E778)
stat        (patched to 0x5E858)
close       (patched to 0x5EA20)
openat      (patched to 0x5ED20)
open        (patched to 0x5ED9C)
pread       (patched to 0x5FAB8)
read        (patched to 0x5FC14)
mmap64      (patched to 0x5FDDC)
__openat_2  (patched to 0x5FEF4)
__open_2    (patched to 0x5FF74)

0x64AE8

Returns the name-mangled art::DexFileLoader::open for the given SDK version.

0x65FE4

Returns the name-mangled art::OatFileManager::OpenDexFilesFromOat for the given SDK version.

0x67544

Hooks the following functions:

art::DexFileLoader::open                    (patched to 0x6D39C/0x6D3E8)
art::OatFileManager::OpenDexFilesFromOat    (patched to 0x6A2C0/0x6AF14/0x6B9B0/0x6C188/0x6CB5C)

0x6D4A0

Patches art::Runtime::IsVerificationEnabled.

0x6DAD8

Hooks art::DexFileVerifier::Verify (patched to 0x6D38C/0x6D394, which just return 1).

0x6E40C

Hooks art::DexFileLoader::open (patched to 0x6D39C/0x6D3E8).

0x70410

Hooks the following functions:

art::DexFileVerifier::Verify     (patched to 0x6EB04/0x6EB0C/0x6EB14, return 1)
art::DexFile::OpenMemory         (patched to 0x74EE8/0x74E90/0x74F38)
Art::DexFile                     (patched to 0x74E30/0x74F88)

0x75054

Hooks functions in libdvm.so; didn’t look closely. 0x6EB1C/0x74DEC/0x6FFBC are the patched implementations.

0x75AA8

Reads java.lang.DexCache.dexfile (this dexfile is the classes.dex at the APK root after unpacking).

0x767F8

Takes an .so path and opens it.

0x76C8C

Takes a function in libart.so and returns its address.

0x76CCC

First argument is a function name in the .so, second is the .so’s relative path; returns the function’s address in the .so.

0x76D90

Takes a function in libdexfile.so and returns its address.

0x76DD4

Takes a function in libjdwp.so and returns its address.

0x76E18

md5.

0x7783C

String-decryption function.

0x79270

Computes a hash of the input string (not exactly md5).

0x7A240

Hot-patch detection. Signature strings:

nuwa
andfix
hotfix
.RiskStu
tinker

0x80458

Calls 0x79270.

0x804A8

Hooks the following libc functions:

msync       (patched to 0x78470)
close       (patched to 0x7AF50)
munmap      (patched to 0x7A568)
openat64    (patched to 0x7DC48)
__open_2    (patched to 0x7DC80)
_open64     (patched to 0x7DCB8)
_openat_2   (patched to 0x7DCF0)
ftruncate64 (patched to 0x7DD30)
mmap64      (patched to 0x7EF60)
pread64     (patched to 0x7F5D0)
read        (patched to 0x7F7DC)
write       (patched to 0x8022C)

0x87F98

Hooks functions in libdvm.so (similar to 0x44708); didn’t look closely. 0x856C0/0x87F00/0x87F4C are the patched implementations.

0x889B0

Patches art::Runtime::UseJitCompilation.

0x8A794 / 0x8B71C / 0x8B890

Hook function implementations.

0x917E8

Reads /proc/sys/fs/inotify/max_queued_watches.

0x91848

Reads /proc/sys/fs/inotify/max_user_instances.

0x918A8

Reads /proc/sys/fs/inotify/max_user_watches.

0x95778

Looks like time-based anti-debugging.

0x95A28

String-search function.

0x95B9C

String-decryption function.

0x95D60

Socket connection.

0x96398

Frida detection: reads /proc/self/task looking for gum-js-loop, and /proc/self/fd looking for linjector.

0x995D0

Xposed detection. Signature strings:

.xposed.
xposedbridge
xposed_art

0x99D28

Hook-framework detection. Signature strings:

frida
ddi_hook
dexposed
substrate
adbi_hook
MSFindSymbol
hook_precall
hook_postcall
MSHookFunction
DexposedBridge
MSCloseFunction
dexstuff_loaddex
dexposedIsHooked
ALLINONEs_arthook
dexstuff_resolv_dvm
dexposedCallHandler
art_java_method_hook
artQuickToDispatcher
dexstuff_defineclass
dalvik_java_method_hook
art_quick_call_entrypoint
frida_agent_main

0x9C0BC

Calls 0x96398 to detect Frida, reads ro.product.model via system_property_get, calls 0x9FD88 to detect Xposed and automatic unpackers, and hooks dlopen (patched to 0x9B89C) and ptrace (patched to 0x95BF8).

0x9CFCC

Anti-debugging by reading /proc/%d/status and checking TracerPid and similar.

0x9D878

Anti-debugging by reading /proc/%d/wchan and checking for ptrace_stop.

0x9DCF4

Anti-debugging by reading /proc/%ld/task/%ld/status and checking TracerPid and similar.

0x9ED44

Xposed detection by printing the call stack via java.lang.StackTraceElement.getClassName.

0x9F770

Xposed detection by printing the class loader via java.lang.ClassLoader.getSystemClassLoader.loadClass.

0x9FD88

Calls 0x9ED44 and 0x9F770, detects Xposed by checking whether user.xposed.system exists in the ServiceManager, then detects automatic unpackers:

The check is whether these classes or methods exist:

dumpMethodCode
fartthread
fart
android/app/fupk3/Fupk
android/app/fupk3/Global
android/app/fupk3/UpkConfig
android/app/fupk3/FRefInvoke
cn/youlor/Unpacker

0xA18D4

getInstalledApplications — gets installed apps on the system.

0xA7D3C

Decrypts the strings Java and JNI_OnLoad, hooks a few functions (original addresses unknown; new addresses: 0xA43A0/0xA485C/0xA48F4/0xA54B0), and hooks dlsym (patched to 0xA4554) and dlopen (patched to 0xA4D30).

0xB4B94

Hooks the following libc functions:

write       (patched to 0xAA2CC)
pwrite64    (patched to 0xAA51C)
close       (patched to 0xAA774)
read64      (patched to 0xAAA9C)
openat64    (patched to 0xAACB8)
__openat_2  (patched to 0xAB6D4)
__open_2    (patched to 0xAC0F4)
open64      (patched to 0xACB10)
read        (patched to 0xAFE18)
mmap64      (patched to 0xB1C54)

Reads debug.atrace.tags.enableflags via system_property_get, hooks bionic_trace_begin and bionic_trace_end (patched to 0xA8EF4 and 0xA8EF8, which return directly); if not found, hooks g_trace_marker_fd (patched to 0xA8EFC, returns -1). These hooks implement transparent encryption — I didn’t look closely, and it’s been analyzed before, so it’s probably unchanged.

0xB9BEC

sha1.

0xBAE64

md5init.

0xC3378

base64.

0xC5DDC

base64.

0xC7B18

APK-signature related.

0xD024C

sha1.

0xD1C04

sha1init.

0xD2E98

md5.

0xD6484

Calls 0xD75A0.

0xD5CDC

Reads /proc/self/cmdline.

0xD6578

Hooks a function in libdvm.so (patched to 0xD6988).

0xD68A8

Decides whether to call 0xD6484 or 0xD6578 based on the value at off_12EF10.

0xD75A0

Hooks a function in libaoc.so (patched to 0xD69BC).

0x3EA68

JNI_OnLoad. Environment: Pixel 4, Android 10. Functions that weren’t called during dynamic analysis are left unanalyzed.

  1. Initializes the cpuabi string (arm64) at 0x12E7C8.
  2. Initializes the .so-name string (libDexHelper) at 0x12EC38.
  3. Initializes the string com/secneo/apkwrapper/H at 0x137B10.
  4. Calls 0x1E520.
JNIEnv->FindClass(com/secneo/apkwrapper/H)
JNIEnv->GetStaticFieldID(com/secneo/apkwrapper/H.PKGNAMELjava/lang/String;)
JNIEnv->GetStaticObjectField(class com/secneo/apkwrapper/H, PKGNAME Ljava/lang/String; => "cn.missfresh.application")
JNIEnv->GetStringUtfChars("cn.missfresh.application")
  1. Stores the package name at 0x138040.
JNIEnv->FindClass(android/app/ActivityThread)
JNIEnv->GetStaticMethodID(android/app/ActivityThread.currentActivityThread()Landroid/app/ActivityThread;)
JNIEnv->CallStaticObjectMethodV(class android/app/ActivityThread, currentActivityThread())
JNIEnv->GetMethodID(android/app/ActivityThread.getSystemContext()Landroid/app/ContextImpl;)
JNIEnv->CallObjectMethodV(android.app.ActivityThread, getSystemContext())
JNIEnv->FindClass(android/app/ContextImpl)
JNIEnv->GetMethodID(android/app/ContextImpl.getPackageManager()Landroid/content/pm/PackageManager;)
JNIEnv->CallObjectMethodV(android.app.ContextImpl, getPackageManager())
JNIEnv->GetMethodID(android/content/pm/PackageManager.getPackageInfo(Ljava/lang/String;I)Landroid/content/pm/PackageInfo;)
JNIEnv->NewStringUTF("cn.missfresh.application")
JNIEnv->CallObjectMethodV(android.content.pm.PackageManager, getPackageInfo("cn.missfresh.application", 0x0))
JNIEnv->GetFieldID(android/content/pm/PackageInfo.applicationInfo Landroid/content/pm/ApplicationInfo;)
JNIEnv->GetObjectField(android.content.pm.PackageInfo, applicationInfo Landroid/content/pm/ApplicationInfo;)
JNIEnv->GetFieldID(android/content/pm/ApplicationInfo.sourceDir Ljava/lang/String;)
JNIEnv->GetObjectField(android.content.pm.ApplicationInfo, sourceDir Ljava/lang/String; => "/data/app/cn.missfresh.application-1")
JNIEnv->GetStringUtfChars("/data/app/cn.missfresh.application-1")
JNIEnv->GetFieldID(android/content/pm/ApplicationInfo.dataDir Ljava/lang/String;)
JNIEnv->GetObjectField(android.content.pm.ApplicationInfo, dataDir Ljava/lang/String; => "/data/data/cn.missfresh.application")
JNIEnv->GetStringUtfChars("/data/data/cn.missfresh.application")
  1. Calls 0x218A8.
JNIEnv->GetFieldID(android/content/pm/ApplicationInfo.nativeLibraryDir Ljava/lang/String;)
JNIEnv->GetObjectField(android.content.pm.ApplicationInfo@36d64342, nativeLibraryDir Ljava/lang/String; => "/data/app/cn.missfresh.application-1/lib/arm64")
JNIEnv->GetStringUtfChars("/data/app/cn.missfresh.application-1/lib/arm64")
  1. Reads /proc/pid/fd, matches <package> + base.apk; 0x12EA38 holds a pointer-to-pointer to the full base.apk path.
JNIEnv->FindClass(com/secneo/apkwrapper/H)
JNIEnv->GetStaticFieldID(com/secneo/apkwrapper/H.ISMPAASLjava/lang/String;)
JNIEnv->GetStaticObjectField(class com/secneo/apkwrapper/H, ISMPAAS Ljava/lang/String; => "###MPAAS###")
JNIEnv->GetStringUtfChars("###MPAAS###")
  1. Compares the result with ###MPAAS###; 0x12E7F8 points to 0x137D9C, which holds the comparison result.
  2. Calls 0x22068.
  3. Calls 0x23568.
  4. Calls 0x1F250.
  5. Stores the string lib/libart.so at 0x1378A8.
  6. Reads /proc/self/maps, finds lib/libart.so with r-xp permissions.
  7. Initializes the following strings:
/data/user/0/cn.missfresh.application/.cache
/data/user/0/cn.missfresh.application/.cache/oat/arm64
/data/user/0/cn.missfresh.application/.cache/classes.dve
/data/app/cn.missfresh.application-xxx/oat/arm64/base.odex
  1. fstat on /data/app/cn.missfresh.application-xxx/oat/arm64/base.odex.
  2. Computes an md5 (not sure exactly what of); 0x12EC98 points to 0x130080, which holds the result.
  3. access on /data/user/0/cn.missfresh.application/.cache/classes.dve; if it doesn’t exist, write the computed md5 to it; if it exists, read it and compare — if different, write the new value.
  4. Calls 0x3371C (based on a flag).
  5. Calls 0x1FDC8.
  6. Initializes the following strings:
/data/user/0/cn.missfresh.application/.cache/libDexHelper32
/lib/armeabi-v7a/libDexHelper.so
/lib/armeabi/libDexHelper.so
assets/libDexHelper32
  1. Calls 0x3766C.
  2. Calls 0xB4B94.
  3. Calls 0x9C0BC.
  4. Calls 0xD5CDC.
  5. Calls 0x24C08.
  6. Calls 0x1C40C.
  7. Calls 0xA7D3C.
  8. End.

References

Conclusion

The sample is heavily obfuscated — much more complex than the samples in the previous two writeups. There are some tricks that helped: functions adjacent in address are often related; parts of the code resemble other packers (they’ve clearly been copied around); and searching for older analysis write-ups of the same packer surfaces function names and strings that weren’t wiped.


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