Quickly Locating a VM-Protected Algorithm with Frida Stalker

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.

Quickly Locating a VM-Protected Algorithm with Frida Stalker

While searching for material on sgmain, I came across a post whose trace tool caught my interest — as did its decompiler engine. Not having the depth to build my own decompiler, I relied on third-party ones and instead tried modifying Frida’s Stalker into a similar trace tool. This article is the result of testing that idea.

Tools: IDA, Ghidra, Frida. The target is sgmain from a major e-commerce app, version 6.5.82. Since this is only a validation of the trace tool, it doesn’t include actual algorithm analysis — just my impressions during testing. The quick location mostly relies on Stalker; IDA and Ghidra are mainly for static analysis.

On the static side, I won’t argue which decompiler engine is fundamentally better — just the hands-on feel. First, a comparison on sgmain’s doCommand method.

IDA’s output:

Figure 1 — doCommand in IDA

Ghidra’s output:

Figure 2 — doCommand in Ghidra

The source is roughly:

__int64 __fastcall sub_2DB50(JNIEnv *param_1,_int64 param_2,int param_3,long param_4){
    unsigned int v12 = 0;
    long ret = 0;
    long* ptr = (long*)malloc(0x20);
    if(ptr != nullptr){
        ptr[0] = 0LL;
        ptr[1] = 0LL;
        *(_DWORD *)ptr = param_3 / 10000;
        v7 = param_3 % 10000 / 100;
        *((_DWORD *)ptr + 1) = v7;
        *((_DWORD *)ptr + 2) = param_3 % 100;
        ptr[2] = param_1;
        ptr[3] = param_4;
        ret = sub_2BB3C(param_3 / 10000, v7, param_3 % 100, 1u, ptr, &v12);
    }
    free(ptr);

    if(ret == 0 && v12 != 0){
        //throw Exception
        sub_E78C0(param_1, v12, 0x20aa97)
    }
    return ret;
}

IDA clearly optimizes division better; Ghidra’s division handling is weaker. But I personally prefer Ghidra’s output.

A side note on division: compilers usually convert integer division into multiplying by a magic number and shifting right by a power of two. The derivation is complex — essentially turning “divide by any integer” into “divide by 2^N” — with many edge cases. But when IDA-style decompilers need manual derivation, there’s an online method to approximate the value; it involves sign and modulo issues (Sub-class instructions may indicate modulo), which I didn’t dig into. If interested, compile and compare yourself.

Generally, when you see these together — multiplication, shift by 0x3F/0x1F to get the sign bit, and ASR — it’s almost certainly a division:

LOAD:000000000002DBFC A9 75 91 52+                MOV             W9, #0x68DB8BAD
LOAD:000000000002DBFC 69 1B AD 72
LOAD:000000000002DC04 A9 7E 29 9B                 SMULL           X9, W21, W9
LOAD:000000000002DC08 1F 7D 00 A9                 STP             XZR, XZR, [X8]
LOAD:000000000002DC0C E8 1B 40 F9                 LDR             X8, [SP,#0x80+p]
LOAD:000000000002DC10 2C FD 7F D3                 LSR             X12, X9, #0x3F ; '?'
LOAD:000000000002DC14 29 FD 6C 93                 ASR             X9, X9, #0x2C ; ','
LOAD:000000000002DC18 20 01 0C 0B                 ADD             W0, W9, W12
LOAD:000000000002DC1C 0A E2 84 52                 MOV             W10, #0x2710
LOAD:000000000002DC20 EB A3 90 52                 MOV             W11, #0x851F
LOAD:000000000002DC24 00 01 00 B9                 STR             W0, [X8]
LOAD:000000000002DC28 6B 3D AA 72                 MOVK            W11, #0x51EB,LSL#16
LOAD:000000000002DC2C 0A D4 0A 1B                 MSUB            W10, W0, W10, W21
LOAD:000000000002DC30 E8 1B 40 F9                 LDR             X8, [SP,#0x80+p]
LOAD:000000000002DC34 4A 7D 2B 9B                 SMULL           X10, W10, W11
LOAD:000000000002DC38 A9 7E 2B 9B                 SMULL           X9, W21, W11
LOAD:000000000002DC3C 4B FD 7F D3                 LSR             X11, X10, #0x3F ; '?'
LOAD:000000000002DC40 4A FD 65 93                 ASR             X10, X10, #0x25 ; '%'
LOAD:000000000002DC44 41 01 0B 0B                 ADD             W1, W10, W11
LOAD:000000000002DC48 01 05 00 B9                 STR             W1, [X8,#4]
LOAD:000000000002DC4C 2A FD 7F D3                 LSR             X10, X9, #0x3F ; '?'
LOAD:000000000002DC50 28 FD 65 93                 ASR             X8, X9, #0x25 ; '%'
LOAD:000000000002DC54 E9 1B 40 F9                 LDR             X9, [SP,#0x80+p]
LOAD:000000000002DC58 08 01 0A 0B                 ADD             W8, W8, W10
LOAD:000000000002DC5C 8A 0C 80 52                 MOV             W10, #0x64 ; 'd'
LOAD:000000000002DC60 02 D5 0A 1B                 MSUB            W2, W8, W10, W21
LOAD:000000000002DC64 22 09 00 B9                 STR             W2, [X9,#8]

For code like the above, you can estimate the divisor with (1 << XX) / (magicNum - 1). For the instructions above:

>>> (1 << 0x2c) / (0x68db8bad - 1)
10000.000002510205
>>> (1 << 0x25) / (0x51eb85ef - 1)
99.99998491839733

Also, in terms of post-patch immediate feedback, I find Ghidra better — especially recognizing br reg, adrp, ret-style jumps. In IDA the JMPOUT is awkward: even with jump patching, F5 may not work, and you may need to remove some obfuscating junk in between.

Ghidra has its own awkward spot: in the code above, the function 0x2bb3c (Ghidra auto-adds a base, default 0x100000) was initially recognized as an empty function with no return, which directly hid the code block containing 0xe78c0, since lStack76 != 0 was treated as unreachable. After fixing the function definition, the code regenerated immediately — the update is instant and works well.

Now, most indirect-jump obfuscation, however it’s mutated, is often just constant expressions. Constant-expression indirect jumps have several handling approaches. Take this code at the head of 0x2bb3c:

LOAD:000000000002BB3C                 SUB             SP, SP, #0x1B0
LOAD:000000000002BB40                 STP             X28, X27, [SP,#0x1A0+var_50]
LOAD:000000000002BB44                 STP             X26, X25, [SP,#0x1A0+var_40]
LOAD:000000000002BB48                 STP             X24, X23, [SP,#0x1A0+var_30]
LOAD:000000000002BB4C                 STP             X22, X21, [SP,#0x1A0+var_20]
LOAD:000000000002BB50                 STP             X20, X19, [SP,#0x1A0+var_10]
LOAD:000000000002BB54                 STP             X29, X30, [SP,#0x1A0+var_s0]
LOAD:000000000002BB58                 ADD             X29, SP, #0x1A0
LOAD:000000000002BB5C                 MRS             X26, #3, c13, c0, #2
LOAD:000000000002BB60                 MOV             W23, W1
LOAD:000000000002BB64                 MOV             W24, W0
LOAD:000000000002BB68                 MOV             W10, #0x2710
LOAD:000000000002BB6C                 MOV             W12, #0x64 ; 'd'
LOAD:000000000002BB70                 LDR             X14, [X26,#0x28]
LOAD:000000000002BB74                 MUL             W15, W24, W10
LOAD:000000000002BB78                 MUL             W12, W23, W12
LOAD:000000000002BB7C                 MOV             W22, W2
LOAD:000000000002BB80                 MOV             W13, #0xC600
LOAD:000000000002BB84                 ADD             W10, W15, W12
LOAD:000000000002BB88                 MOV             W8, #0x96
LOAD:000000000002BB8C                 SUB             X9, X29, #-var_70
LOAD:000000000002BB90                 MOVK            W13, #0x53E,LSL#16
LOAD:000000000002BB94                 ADD             W10, W10, W22
LOAD:000000000002BB98                 MOV             X21, X4
LOAD:000000000002BB9C                 ADD             W13, W10, W13
LOAD:000000000002BBA0                 STUR            X14, [X29,#var_58]
LOAD:000000000002BBA4                 STUR            W8, [X29,#var_70]
LOAD:000000000002BBA8                 ADR             X27, loc_2BBAC
LOAD:000000000002BBAC
LOAD:000000000002BBAC loc_2BBAC                               ; DATA XREF: sub_2BB3C+6C↑o
LOAD:000000000002BBAC                 LDRSW           X4, =0xFFFFFE62
LOAD:000000000002BBB0                 MOV             X20, #0xAB
LOAD:000000000002BBB4                 EOR             X4, X4, X20
LOAD:000000000002BBB8                 ADD             X4, X4, #0x88
LOAD:000000000002BBBC                 MVN             X4, X4
LOAD:000000000002BBC0                 LDRSW           X10, [X9]
LOAD:000000000002BBC4                 EOR             X4, X4, X10
LOAD:000000000002BBC8                 ADD             X27, X27, X4
LOAD:000000000002BBCC                 MOV             X8, #0x62 ; 'b'
LOAD:000000000002BBD0                 BR               X27

The code looks complex — all sorts of arithmetic and indirect address access — and each function head has such an indirect jump, seemingly with a different expression. But handling it is actually simple, in two cases: static and dynamic.

The dynamic case is trivial: single-step or trace to get the jump target. Ghidra doesn’t even need special handling — it computes the constant expressions itself and substitutes the results during decompilation. Ghidra’s assembly view:

                             LAB_0013dd1c                                    XREF[1]:     0013dd00(j)  
        0013dd1c e8 03 00 90     adrp       x8,0x1b9000
        0013dd20 08 01 11 91     add        x8,x8,#0x440
        0013dd24 08 09 40 f9     ldr        x8,[x8, #0x10]=>DAT_001b9450                     = 000000000013DC94h
        0013dd28 08 71 02 91     add        x8,x8,#0x9c
        0013dd2c 00 01 1f d6     br         x8=>LAB_0013dd30

If you must use IDA without dynamic debugging, it’s also easy — use an IDA script with Unicorn to emulate and get the result:

Figure 3 — Unicorn emulation in IDA

For indirect jumps surrounded by various jumps between code — e.g. an indirect jump in the middle of a function in IDA:

LOAD:000000000002BD58             loc_2BD58                               ; DATA XREF: sub_2BB3C+3B0↓o
LOAD:000000000002BD58                                                     ; LOAD:off_16FC38↓o
LOAD:000000000002BD58 E9 23 0A A9                 STP             X9, X8, [SP,#0x1A0+var_100]
LOAD:000000000002BD5C 88 36 40 F9                 LDR             X8, [X20,#(off_16FC28 - 0x16FBC0)] ; loc_2BD68
LOAD:000000000002BD60 FF 0F 00 F9                 STR             XZR, [SP,#0x1A0+var_188]
LOAD:000000000002BD64 08 2D 00 91                 ADD             X8, X8, #0xB
LOAD:000000000002BD68
LOAD:000000000002BD68             loc_2BD68                               ; DATA XREF: sub_2BB3C+220↑o
LOAD:000000000002BD68                                                     ; LOAD:off_16FC28↓o
LOAD:000000000002BD68 03 00 00 14                 B               loc_2BD74 ; loc_2BD74 ; Keypatch modified this from:
LOAD:000000000002BD68                                                     ;   BR X8

Ghidra’s mid-function indirect jump:

                             LAB_0013dd1c                                    XREF[1]:     0013dd00(j)  
        0013dd1c e8 03 00 90     adrp       x8,0x1b9000
        0013dd20 08 01 11 91     add        x8,x8,#0x440
        0013dd24 08 09 40 f9     ldr        x8,[x8, #0x10]=>DAT_001b9450                     = 000000000013DC94h
        0013dd28 08 71 02 91     add        x8,x8,#0x9c
        0013dd2c 00 01 1f d6     br         x8=>LAB_0013dd30

The handling is basically the same: even if IDA or Ghidra can compute the target, they’ll hint at it — the IDA above hints the address is loc_2BD68 — but to keep the flow as accurate as possible they generally don’t constant-fold in the disassembly window, so you patch manually.

Forcing IDA with Unicorn is possible but not great for generality, since some constants live in other code blocks and address-constant accesses are mixed in. So in those cases, prefer trace data or existing tool decompilation results as a patching reference. For example, Ghidra hints at similar blocks in its decompiler:

Figure 4 — Ghidra’s hint for a computed branch

In the assembly page:

                             LAB_00134638                                    XREF[1]:     00134604(j)  
        00134638 69 22 40 a9     ldp        x9,x8,[x19]
        0013463c a8 03 18 f8     stur       x8,[x29, #local_90]
        00134640 08 7d 40 92     and        x8,x8,#0xffffffff
        00134644 a9 a7 38 a9     stp        x9,x9,[x29, #local_88]
        00134648 a8 c3 17 b8     stur       w8,[x29, #local_94]
        0013464c e9 07 00 b4     cbz        x9,LAB_00134748
        00134650 c8 07 00 34     cbz        w8,LAB_00134748
        00134654 f6 09 00 90     adrp       x22,0x270000
        00134658 d6 82 01 91     add        x22,x22,#0x60
        0013465c c8 4a 40 f9     ldr        x8,[x22, #0x90]=>DAT_002700f0                    = 00000000001345FDh
        00134660 08 ad 01 91     add        x8,x8,#0x6b
        00134664 00 01 1f d6     br         x8=>LAB_00134668

You can see Ghidra already computed the constant address via its own logic, but didn’t update the disassembly. After patching, the effect is immediate:

Figure 5 — After patching the computed branch

This is an indirect jump using a global variable (roughly: the content at global 0x270140 plus 0xda yields the call address). Ghidra recognizes multiple possible results here, so this case requires dynamic analysis — different flows may yield different results.

Most common obfuscation follows the same playbook; anything more complex than this ultimately comes down to tracing.

The interesting structures behind FUN_0012bb3c:

typedef struct _StData{
    void ** ppParamSt;
    int sz;
}StData;

typedef struct _StParam1{
    int param;
    int unknow;
    StData* pSD;
}StParam1;

typedef struct _StParam2{
    int unknow;
    int param;
    long flag;
    StData* pSD;
}StParam2;

typedef struct _StParam3{
    long unknow;
    int param;
    int unknow;
    long encryFuncAddr;
}StParam3;



long FUN_0012bb3c(int param_1, int param_2, int param_3, int param_4,
                  long *param_5, int *param_6)
{
    int iVar1;
    long lVar2;
    StParam3 *pSVar3;
    long local_1a8;
    StParam1 *pSStack416;
    long lStack408;
    StParam2 *pSStack400;
    long lStack392;
    int local_17c;
    long local_178;
    int local_7c;
    long *plStack120;
    int *piStack112;
    long local_68;
    StData **pplVar1;

    local_7c = 1;
    plStack120 = param_5;
    piStack112 = param_6;
    local_178 = FUN_00168480(param_1 * 10000 + param_2 * 100 + param_3 + 88000000,
                            param_4, 0, &plStack120, &local_7c);
    if (local_7c != 0) {
        pplVar1 = (long **)0x279af0;    //global variable, not constant
        if (param_4 != 0) {
            pplVar1 = (long **)&DAT_00279ae8;   //global variable, not constant
        }

        iVar1 = (*pplVar1)->sz;
        if(iVar1 < 1){
            local_17c = 0x26b0;
        }else{
            for(local_1a8 = 0; local_1a8 < iVar1; local_1a8++){
                pSStack416 = (StParam1*)(*pplVar1)->ppParamSt[local_1a8];
                if(pSStack416->param == param_1){
                    goto LAB_JMP1;
                }
            }
            pSStack416 = 0;
LAB_JMP1:
            if(pSStack416 == 0){
                local_17c = 0x26b0;
            }else{
                iVar1 = pSStack416->pSD->sz;
                if(iVar1 < 1){
                    local_17c = 0x26b1;
                }else{
                    for(lStack408 = 0; lStack408 < iVar1; lStack408++){
                        pSStack400 = (StParam2*)pSStack416->pSD->ppParamSt[lStack408];
                        if(pSStack400->param == param_2){
                            goto LAB_JMP2;
                        }
                    }
                    pSStack400 = 0;
LAB_JMP2:
                    if(pSStack400 == 0){
                        local_17c = 0x26b1;
                    }else{
                        iVar1 = pSStack400->pSD->sz;
                        if(iVar1 > 0){
                            for(lStack392 = 0; lStack392 < iVar1; lStack392++){
                                pSVar3 = (StParam3*)pSStack400->pSD->ppParamSt[lStack392];
                                if(pSVar3 != 0 && pSVar3->param == param_3){
                                    local_178 = (*(func*)(pSStack400->flag ^ pSVar3->encryFuncAddr))(param_5, param_6);
                                    return local_178;
                                }
                            }
                        }
                        local_17c = 0x26b2;
                    }
                }
            }
            
        }
            
        if (param_6 != (int *)0x0) {
            *param_6 = local_17c + (param_2 * 100 + param_1 * 10000 + param_3) * 10000;
        }
        local_178 = 0;
    }


    return local_178;
}

Stalker and IDA’s tracer each have their trade-offs. IDA’s tracer rides on the debugger, so it inherits every debugger weakness; it usually works best paired with a DBG_Hooks script. I tend to reach for it only after I’ve already narrowed the range, or to trace an exit path backward toward detection code. But since this article is about Stalker, I’ll leave IDA’s tracer aside.

Stalker has existed for a long time, but it only really saw heavy use after arm64; on arm32 you have to handle jump-address issues. In practice I found that enabling the call event almost guarantees a crash, so I ran with every event off — essentially using only the transform callback.

A few issues I hit along the way:

  1. Stalker’s model seems to treat every control-flow instruction (b/ret and the like) as a block boundary. iterator.next() at the transform entry returns the block’s starting address, so using a specific address directly to decide whether it’s in the traced range can mislead. For example, when I tried hooking an arbitrary instruction without iterating the whole block — just comparing addr.compare == 0 — it never hit, because each transform call only hands you the block’s start address.

  2. For the same reason, the instructions in iterator.putCallout contain no b/ret-style jumps. On arm32 I never saw the classic adrp pc, #imm pattern here, so I’m not sure how that case would behave.

  3. instruction.regsRead and regsWritten are empty; jumps and conditional instructions have values, but ordinary registers aren’t included. I suspect this is a deliberate performance choice. It’s not a big deal — you just have to be careful when printing register values for instructions like ldr x0,[x0].

Other than that there’s little to complain about. Overall speed is decent, though it occasionally dies on large code — especially when stepping into bl instructions.

I wrapped a few helpers on top of Stalker:

//trace function
//param0: module to trace
//param1: hook point that activates the trace; mainly for conditional checks — only trace when conditions match
//param2-param3: code block range to trace; controls the traced instruction range
//param4: whether to step in (don't follow outside the module), default false; true tends to trip a time check and crash
// trace_entry("libsgmainso-6.5.82.so", base.add(0x2db50), 0x2bb3c, 0x2befc)

//same as above, but traces all bl instructions and prints argument info; great for quick location
//the test here is a string; adjust as needed for other types
// trace_entry_bl("libsgmainso-6.5.82.so", base.add(0x2db50), 0x2db50, 0x2DCF0, true)

//traces all logic instructions (lsl/lsr etc.), printing offset/VA and detailed instruction info; in practice too noisy
// trace_entry_logic("libsgmainso-6.5.82.so", base.add(0x2db50), 0x2db50, 0x2DCF0, true)

//same as above
//param4: invoke the callback when the instruction at the given address executes
//param5: callback function
//mainly for printing data at special addresses during execution
// trace_callback("libsgmainso-6.5.82.so", 0x2db50, 0x2bb3c, 0x2befc, 0x2bc84, function(context) {
//     var insn = Instruction.parse(context.pc)
//     var insnStr = insn.toString().padEnd(25, " ")
//     var strLog = insn.address.sub(g_baseAddr) + "  " + insn.address + ":\t" + insnStr + "\t" + "  " + getOpInfo(context, insn)
//     console.log(strLog)
//
// })

Since this was just a quick test, I didn’t specifically hunt down which function computes the sign — I threw in a few values, masked out the ones I didn’t need, and got this:

Figure 6 — trace of the sign computation

The intermediate calls in the sign computation are fairly clear. Roughly, at 0xbc708 it calls bl 0xbe1ac, then at 0xbc8e0 it calls 0xc709c with the argument string 1PaJH2i4Bu60DMq4zUogvFil9Bs=, followed by a chain of bl 0xc792c; then a new function call bl 0x2bb3c executes, and after that:

                                                x0:0x7ec5a203e0, x1:0x1ec, x2:0x1ec, x3:0x0, x4:0x0, x5:0x0, x6:0x0, x7:0x7ea70754f4
0xe73ac  0x7ea70f43ac:  blr x8                          -->(libart.so!0x32d3a4  _ZN3art3JNI12NewByteArrayEP7_JNIEnvi)  x8:0x7ecf7133a4
x0:0x7ec5a203e0, x1:0x49, x2:0x0, x3:0x1ec, x4:0x7e53030800, x5:0x7ea7a7b348, x6:0x0, x7:0x7ea70754f4
                                                arg4:enxH+QwhGIL9C/JrXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXjm6o=
0xe7414  0x7ea70f4414:  blr x8                          -->(libart.so!0x32d424  _ZN3art3JNI18SetByteArrayRegionEP7_JNIEnvP11_jbyteArrayiiPKa)  x8:0x7ecf713424

The flow looks reasonably clean. Then I took a look in Ghidra — and it’s painful to look at. A few snippets to enjoy:

0xbc560

                do {
                  uVar11 = puVar6[1];
                  uVar10 = *puVar6;
                  local_880 = CONCAT17((byte)((ulong)local_870 >> 0x38) ^
                                       (byte)((uVar10 & 0xffffff0000000000) >> 0x38),
                                       CONCAT16((byte)((ulong)local_870 >> 0x30) ^
                                                (byte)((uVar10 & 0xffffff0000000000) >> 0x30),
                                                CONCAT15((byte)((ulong)local_870 >> 0x28) ^
                                                         (byte)(uVar10 >> 0x28),
                                                         CONCAT14((byte)((ulong)local_870 >> 0x20) ^
                                                                  (byte)(uVar10 >> 0x20),
                                                                  CONCAT13((byte)((ulong)local_870
                                                                                 >> 0x18) ^
                                                                           (byte)(uVar10 >> 0x18),
                                                                           CONCAT12((byte)((ulong)
                                                  local_870 >> 0x10) ^ (byte)(uVar10 >> 0x10),
                                                  CONCAT11((byte)((ulong)local_870 >> 8) ^
                                                           (byte)(uVar10 >> 8),
                                                           (byte)local_870 ^ (byte)uVar10)))))));
                  uStack2168 = CONCAT17((byte)((ulong)uStack2152 >> 0x38) ^
                                        (byte)((uVar11 & 0xffffff0000000000) >> 0x38),
                                        CONCAT16((byte)((ulong)uStack2152 >> 0x30) ^
                                                 (byte)((uVar11 & 0xffffff0000000000) >> 0x30),
                                                 CONCAT15((byte)((ulong)uStack2152 >> 0x28) ^
                                                          (byte)(uVar11 >> 0x28),
                                                          CONCAT14((byte)((ulong)uStack2152 >> 0x20)
                                                                   ^ (byte)(uVar11 >> 0x20),
                                                                   CONCAT13((byte)((ulong)uStack2152
                                                                                  >> 0x18) ^
                                                                            (byte)(uVar11 >> 0x18),
                                                                            CONCAT12((byte)((ulong)
                                                  uStack2152 >> 0x10) ^ (byte)(uVar11 >> 0x10),
                                                  CONCAT11((byte)((ulong)uStack2152 >> 8) ^
                                                           (byte)(uVar11 >> 8),
                                                           (byte)uStack2152 ^ (byte)uVar11)))))));
                  FUN_001c792c(auStack592,puVar9,&local_880);
                  uStack2152 = puVar9[1];
                  local_870 = *puVar9;
                  puVar9 = puVar9 + 2;
                  uVar8 = uVar8 - 1;
                  puVar6 = puVar6 + 2;
                } while (uVar8 != 0);
                local_9a8 = local_9a8 & 0xfffffff0;

0xc709c

      local_2a0 = param_1;
      puStack664 = param_2;
      do {
        uVar19 = puStack664[1];
        uVar16 = *puStack664;
        local_2a8 = local_2a8 - 4;
        uVar17 = (undefined)((ulong)uVar16 >> 0x20);
        uVar8 = (ulong)CONCAT16((char)((ulong)uVar16 >> 0x28),
                                (uint6)((uint)(CONCAT12(uVar17,(short)uVar16) & 0xff00ff) << 0x18 |
                                       (uint)(byte)((ulong)uVar16 >> 8) << 0x10));
        uVar9 = (ulong)CONCAT16((char)((ulong)uVar19 >> 0x28),
                                (uint6)CONCAT11((char)uVar19,(char)((ulong)uVar19 >> 8)) << 0x10);
        uVar15 = CONCAT15((char)((ulong)uVar16 >> 0x30),
                          (uint5)CONCAT12((char)(uVar8 >> 0x18),
                                          CONCAT11((char)(uVar8 >> 0x10),
                                                   (char)((ulong)uVar16 >> 0x10))) << 8);
        uVar8 = (ulong)CONCAT16((char)(uVar8 >> 0x30),uVar15);
        uVar18 = CONCAT15((char)((ulong)uVar19 >> 0x30),
                          (uint5)CONCAT12((char)(uVar9 >> 0x18),
                                          CONCAT11((char)(uVar9 >> 0x10),
                                                   (char)((ulong)uVar19 >> 0x10))) << 8);
        uVar9 = (ulong)CONCAT16((char)(uVar9 >> 0x30),uVar18);
        local_2a0[1] = ((ulong)CONCAT41((uint)(byte)((ulong)uVar19 >> 0x20) << 0x18,(char)uVar19) &
                       0xffffffff00) << 0x18 |
                       (ulong)CONCAT16((char)((uVar9 & 0xffffffff00000000) >> 0x30),
                                       CONCAT15((char)((uVar9 & 0xffffffff00000000) >> 0x28),
                                                CONCAT14((char)((ulong)uVar19 >> 0x38),
                                                         CONCAT13((char)(uVar9 >> 0x18),

0xc792C

  if ((param_1 != (uint *)0x0 && param_2 != (undefined *)0x0) && param_3 != (uint *)0x0) {
    uVar9 = (param_3[3] & 0xff00ff00) >> 8 | (param_3[3] & 0xff00ff) << 8;
    uVar9 = (uVar9 >> 0x10 | uVar9 << 0x10) ^ param_1[3];
    local_d0 = param_1 + 0x24;
    uVar10 = (*param_3 & 0xff00ff00) >> 8 | (*param_3 & 0xff00ff) << 8;
    uVar10 = (uVar10 >> 0x10 | uVar10 << 0x10) ^ *param_1;
    uVar11 = (param_3[1] & 0xff00ff00) >> 8 | (param_3[1] & 0xff00ff) << 8;
    uVar11 = (uVar11 >> 0x10 | uVar11 << 0x10) ^ param_1[1];
    uVar12 = (param_3[2] & 0xff00ff00) >> 8 | (param_3[2] & 0xff00ff) << 8;
    uVar12 = (uVar12 >> 0x10 | uVar12 << 0x10) ^ param_1[2];
    uVar14 = *(uint *)(&DAT_002bca7c + (ulong)(uVar10 >> 0x18) * 4) ^ param_1[4] ^
             *(uint *)(&DAT_002bce7c + (ulong)(uVar11 >> 0x10 & 0xff) * 4) ^
             *(uint *)(&DAT_002bd27c + (ulong)(uVar12 >> 8 & 0xff) * 4) ^
             *(uint *)(&DAT_002bd67c + (ulong)(uVar9 & 0xff) * 4);
    uVar1 = *(uint *)(&DAT_002bca7c + (ulong)(uVar12 >> 0x18) * 4) ^ param_1[6] ^
            *(uint *)(&DAT_002bce7c + (ulong)(uVar9 >> 0x10 & 0xff) * 4) ^
            *(uint *)(&DAT_002bd27c + (ulong)(uVar10 >> 8 & 0xff) * 4) ^
            *(uint *)(&DAT_002bd67c + (ulong)(uVar11 & 0xff) * 4);
    uVar2 = *(uint *)(&DAT_002bca7c + (ulong)(uVar11 >> 0x18) * 4) ^ param_1[5] ^
            *(uint *)(&DAT_002bce7c + (ulong)(uVar12 >> 0x10 & 0xff) * 4) ^
            *(uint *)(&DAT_002bd27c + (ulong)(uVar9 >> 8 & 0xff) * 4) ^
            *(uint *)(&DAT_002bd67c + (ulong)(uVar10 & 0xff) * 4);
    uVar9 = *(uint *)(&DAT_002bca7c + (ulong)(uVar9 >> 0x18) * 4) ^ param_1[7] ^
            *(uint *)(&DAT_002bce7c + (ulong)(uVar10 >> 0x10 & 0xff) * 4) ^
            *(uint *)(&DAT_002bd27c + (ulong)(uVar11 >> 8 & 0xff) * 4) ^
            *(uint *)(&DAT_002bd67c + (ulong)(uVar12 & 0xff) * 4);

That’s the whole flow. A trace tool like this, combined with the constant-expression de-obfuscation tricks above, gets you from “where is the protected algorithm?” to “here are the calls that matter” quickly enough to keep going. The static decompiler does the heavy lifting on structure; Stalker does the heavy lifting on dynamics.

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