Recovering a Car App's `sd` Signature: White-Box AES and DFA Key Recovery

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; recovered keys, credentials, and captured signature values have been redacted.

Recovering a Car App’s sd Signature: White-Box AES and DFA Key Recovery

Overview

There are already many writeups on this parameter, but most are too terse, and for this version of the sd parameter, much of the earlier analysis no longer applies (the checkcode generation part). I had some free time, so here’s my full analysis process. The .so is heavily obfuscated, so it’s not trivial. My level is limited — take the whole thing as reference only.

Packet capture, unpacking, and locating the .so function were all straightforward, so I’ll skip those and just give the .so function name and its parameters:

  • .so file: libencrypt.so
  • Native function: Java_com_bangcle_comapiprotect_CheckCodeUtil_checkcode
    • Arg 1: "mobile=[redacted]&password=[redacted]&client_id=[redacted]&client_secret=[redacted]&state=[redacted]&response_type=token&ostype=ios&imei=00&mac=00:00:00:00:00:00&model=Pixel 6&sdk=33&serviceTime=1745556662765&mod=google&checkcode=[redacted]" (a String of login info)
    • Arg 2: 1745556662765 (timestamp)

unidbg environment setup

Skipping the environment-fixing details, here’s the code:

package com.example.carlogin;

import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Emulator;
import com.github.unidbg.Module;
import com.github.unidbg.arm.backend.Unicorn2Factory;
import com.github.unidbg.debugger.BreakPointCallback;
import com.github.unidbg.debugger.Debugger;
import com.github.unidbg.file.FileResult;
import com.github.unidbg.file.IOResolver;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.*;
import com.github.unidbg.linux.file.ByteArrayFileIO;
import com.github.unidbg.memory.Memory;
import com.github.unidbg.memory.MemoryBlock;
import com.github.unidbg.pointer.UnidbgPointer;
import org.apache.commons.io.HexDump;
import unicorn.Arm64Const;
import unicorn.ArmConst;

import javax.xml.transform.Source;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class CarSdAnalyzer extends AbstractJni implements IOResolver {

    private final AndroidEmulator emulator;
    //    private final DvmClass CryptoTool;
    private final VM vm;
    public final Module module;

    public CarSdAnalyzer() throws IOException {   // initialize
        emulator = AndroidEmulatorBuilder
                .for64Bit()
                .addBackendFactory(new Unicorn2Factory(true))
                .setProcessName("com.example.carapp")
                .build();
        Memory memory = emulator.getMemory();
        memory.setLibraryResolver(new AndroidResolver(23));
        vm = emulator.createDalvikVM(new File("path/to/your/target.apk")); // change to your own apk path
        vm.setJni(this);
        vm.setVerbose(true);
        emulator.getSyscallHandler().addIOResolver(this);
        DalvikModule dm2 = vm.loadLibrary("encrypt", true);
        module = dm2.getModule();
//        CryptoTool = vm.resolveClass("com.bangcle.CryptoTool");

        dm2.callJNI_OnLoad(emulator);
    }

    public static void main(String[] args) throws Exception {
        CarSdAnalyzer test = new CarSdAnalyzer();
        test.call_checkcode();
    }

    public void call_checkcode() {
        List<Object> list = new ArrayList<>(5);
        list.add(vm.getJNIEnv()); // first arg is env
        list.add(0); // second arg: jobject for instance methods, jclazz for static methods; 0 is fine, but better to construct one

        list.add(vm.addLocalObject(new StringObject(vm, "mobile=[redacted]&password=[redacted]&client_id=[redacted]&client_secret=[redacted]&state=[redacted]&response_type=token&ostype=ios&imei=00&mac=00:00:00:00:00:00&model=Pixel 6&sdk=33&serviceTime=1745556662765&mod=google")));
        list.add(2);
        list.add(vm.addLocalObject(new StringObject(vm, "1745556662765")));

        Number retNum = module.callFunction(emulator, 0x25250, list.toArray());
        String ret = vm.getObject(retNum.intValue()).getValue().toString();
        System.out.println("result -> " + ret);
    }

    @Override
    public FileResult resolve(Emulator emulator, String pathname, int oflags) {
        System.out.println("open path: " + pathname);
        return null;
    }

    @Override
    public DvmObject<?> callStaticObjectMethod(BaseVM vm, DvmClass dvmClass, String signature, VarArg varArg) {
        switch (signature) {
            case "android/app/ActivityThread->currentActivityThread()Landroid/app/ActivityThread;": {
                // return a fake ActivityThread instance
                return vm.resolveClass("android/app/ActivityThread").newObject(null);
            }
        }
        return super.callStaticObjectMethod(vm, dvmClass, signature, varArg);
    }

    @Override
    public DvmObject<?> callObjectMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) {
        switch (signature) {
            case "android/app/ActivityThread->getSystemContext()Landroid/app/ContextImpl;": {
                return vm.resolveClass("android/app/ContextImpl").newObject(null);
            }
            case "android/app/ContextImpl->getPackageManager()Landroid/content/pm/PackageManager;": {
                return vm.resolveClass("android/content/pm/PackageManager").newObject(null);
            }
        }
        return super.callObjectMethod(vm, dvmObject, signature, varArg);
    }

    @Override
    public DvmObject<?> getStaticObjectField(BaseVM vm, DvmClass dvmClass, String signature) {
        switch (signature) {
            case "android/os/Build->MODEL:Ljava/lang/String;": {
                return new StringObject(vm, "Pixel 6");
            }
            case "android/os/Build->MANUFACTURER:Ljava/lang/String;": {
                return new StringObject(vm, "google");
            }
            case "android/os/Build$VERSION->SDK:Ljava/lang/String;": {
                return new StringObject(vm, "29");
            }
        }
        return super.getStaticObjectField(vm, dvmClass, signature);
    }
}

unidbg resultFig. 1

The run succeeded and produced a value. Now let’s trace a log to guide further analysis.

trace logFig. 2

so-layer analysis

base64

JNIEnv->NewStringUTF("[redacted]") was called from RX@0x12026a34[libencrypt.so]0x26a34
result -> [redacted]

These are the last few log lines. We can see the return string is constructed via JNIEnv->NewStringUTF at offset 0x26a34. Jump there in IDA:

This line is the NewStringUTF call, so v5’s type must be JNIEnv*. Press Y to retype v5.

retype v5Fig. 3

Now it’s restored. Continuing the analysis, the value comes from v138. Looking upward, at line 5065:

sprintf(v138, "%c%s", v727, v132); // %c is a char placeholder, so v727 is a single char; %s is char*, so v132 is a string. v727 is the leading 'M', v132 is the following continuous string
// after several runs with different arguments, this 'M' is confirmed fixed

Anyone familiar with C sees this writes v727 and v132 into v138. v727 is the fixed M, and v132 is the encrypted result. So v132 is our analysis target.

Looking upward:

base64_encodeFig. 4

base64_encode(v123, v132, (unsigned int)v771);

v132 appears to come from base64 encoding. Now verify whether base64_encode is standard base64 (and whether its input matches standard base64-decoding of v132):

.text:00000000000268C0                 MOV             X1, X20
.text:00000000000268C4                 MOV             X0, X24
.text:00000000000268C8                 MOV             X19, #2
.text:00000000000268CC                 BL              base64_encode

This is the assembly at base64_encode. Breakpoint at 0x268cc and inspect x0 (v123, the function’s input):

breakpoint x0Fig. 5

online base64 resultFig. 6

It matches the online base64 result — so it’s standard base64, and v123 is the base64 input. We can skip base64_encode’s internals; next target is the source of the data at v123.

AES

Identifying AES

Trace the write to v123’s current address (0x1256400, 0x1256400+0x01A0) to see where this string is written:

trace writeFig. 7

Notice 0x7a6d6e2579331dae is the big-endian value of the first bytes of the base64 input, so the write happens at 0xa07c.

But writes seem to happen in groups: 0xa128, 0xa128 (later 0xa150), 0xa07c, 0xa07c. The value written at 0xa07c may relate to the earlier value at 0xa128. Let’s look at both in IDA.

First 0xa128 (inside WBACRAES128_EncryptCBC):

0xa128 WBACRAES128_EncryptCBCFig. 8

Then 0xa07c (inside WBACRAES_EncryptOneBlock):

0xa07c WBACRAES_EncryptOneBlockFig. 9

At 0xa128 there’s veorq_s8(*v6, this[1]) — a byte-wise XOR of v6 and this[1]. (If you don’t know veorq_s8, ask any assistant — it’s NEON vector XOR.) We don’t yet know why.

At 0xa07c, right above the write there’s AES_EncryptOneBlock — “encrypt one AES block” — which basically tells us it’s AES, and likely a white-box AES.

What’s 0xa128 then? Probably AES pre-initialization: in CBC mode, each plaintext block is XORed with the previous ciphertext block (the first block XORs with the IV). So this is very likely CBC mode. (Still a hypothesis; we’ll verify soon.)

So the algorithm is AES, and after WBACRAES128_EncryptCBC finishes initializing each block, it enters WBACRAES_EncryptOneBlock to encrypt one block.

Let’s verify that hypothesis.

Confirming the AES input

Hook the call sites of WBACRAES128_EncryptCBC and WBACRAES_EncryptOneBlock and observe their inputs. (The first is the original AES input; the second is likely the post-padding, post-first-block-XOR input.)

First, confirm the original input:

first inputFig. 10

Press X on WBACRAES128_EncryptCBC to find its references — there are two. Which one calls it? Breakpoint both call sites and see which one hits.

X key find referencesFig. 11

After breaking and re-running, only encrypt1 hits; encrypt2 never does.

encrypt1 breakpointFig. 12

encrypt2 no breakFig. 13

Now the input. The image above shows the code near encrypt1’s branch into WBACRAES128_EncryptCBC; x1 holds our input. (How do we know? Inside WBACRAES128_EncryptCBC, v6 is XORed with this[1] and assigned to a6, so a6 is the input. a6 comes from a2, which in encrypt1 corresponds to the a1 parameter; disassemble at a1 and its register is x1.)

After the breakpoint:

x1 inputFig. 14

Isn’t this the first string we passed to unidbg? Almost — there’s an extra checkcode, and it changes each run. So we have another target: checkcode. But it can wait.

So the AES input is confirmed: our first unidbg string + &checkcode=.... One step closer to recovering sd.

Confirming the padding mode

Next, WBACRAES_EncryptOneBlock (called from WBACRAES128_EncryptCBC). Breakpoint at the call site:

EncryptOneBlock breakpointFig. 15

x1 register contentFig. 16 x1 register content 2Fig. 17

Breaking and printing x1… familiar — this looks like the unidbg result content above, but with an extra 02 02. That’s AES padding (when plaintext length isn’t a multiple of 16, it’s padded before encryption). Padded with 02 02, and the length was 2 bytes short of a multiple of 16 — that’s PKCS7 padding (PKCS7 pads (16 - length % 16) bytes of value (16 - length % 16)).

Padding mode confirmed: PKCS7.

Confirming the mode and IV

But something feels missing. I recall there was an XOR before entering this function, yet the first 16 bytes here are still the original data. Strange. Let’s go back to WBACRAES128_EncryptCBC:

CBC xor branchesFig. 18

There are actually two XOR branches: the upper one only writes once; the later rounds write at the lower address. Hypothesis: the first round’s XOR is against this[1] = 0 (anything XOR 0 is itself), and later rounds XOR against v11, the previous round’s ciphertext.

Check whether v11 at the first hit of 0xa150 equals our previous round’s encryption result (I’ll skip verifying the first-round XOR — you can check it yourself):

This is the assembly at 0xa150; breakpoint at 0xa14c and inspect q0/q1 (the Q registers are the general vector registers) before the XOR:

0xa150 assemblyFig. 19

q0 matches our first-round ciphertext — hypothesis confirmed. Mode is CBC, IV = 0x00000000…. Next: get the AES key.

Confirming white-box AES

white-box AESFig. 20

Back in this function, double-click the yellow region to jump into the specific function:

jump into functionFig. 21

Inside it looks like this, but we can’t keep jumping in. Press TAB to switch to assembly:

TAB to assemblyFig. 22

It’s obfuscated — the jump to the next function goes through:

BLR X4

Because IDA can’t resolve X4 at runtime, double-clicking gets us nowhere. What now? Option 1: de-obfuscate (and clean the outer control-flow obfuscation too). Option 2: breakpoint here and read X4 when it hits. I’m lazy, so option 2. (unidbg is genuinely a great tool for recovering algorithms.)

X4 registerFig. 23

Nice — now we know the .so offset is 0x86f8. Jump there in IDA:

IDA jump 0x86f8Fig. 24

Finally inside:

table lookup AESFig. 25

It’s all table lookups — classic table-based AES. The table is large and there’s no obvious key logic — it’s a white-box AES.

DFA attack to recover the key

For white-box AES there are two approaches: (1) extract the code and tables wholesale; (2) run a DFA attack against the white-box AES to recover the key, then re-implement the algorithm.

I chose (2); this is a fairly standard white-box AES.

First, a bit on how DFA recovers round keys. (Skip the theory — lots of articles online.) For white-box AES, you typically find the entry of the second-to-last round (round 9 for AES-128), flip a single plaintext byte several times, and collide each mutated ciphertext against the normal ciphertext to pin down part of the 10th-round subkey. With enough samples you recover the full 10th-round subkey, then reverse the key schedule to get the original key.

No need to write it yourself — open-source projects already do this.

Let’s find the 9th round. Look at this code:

do
{
    v10 = v54;
    v11 = (char *)&unk_31080;
    v12 = 0;
    v13 = 4LL * v9;
    if ( v8 == 1 )
    {
        do
        {
            v36 = v12;
            *v10 = roundTables_auth1[256 * (v12 + 4 * ((*v11 & 3) + 4LL * v9))
                                     + *((unsigned __int8 *)&v49 + 8 * v12 + (*v11 & 3))];
            v10[1] = roundTables_auth1[256 * (v12 + 4 * (((*v11 + 1) & 3) + 4LL * v9))
                                       + *((unsigned __int8 *)&v49 + 8 * v12 + ((*v11 + 1) & 3))];
            v10[2] = roundTables_auth1[256 * (v12 + 4 * (((*v11 + 2) & 3) + 4LL * v9))
                                       + *((unsigned __int8 *)&v49 + 8 * v12 + ((*v11 + 2) & 3))];
            v37 = *v11;
            v11 += 2;
            v10 += 4;
            ++v12;
            *(v10 - 1) = roundTables_auth1[256 * (v36 + 4 * (((v37 + 3) & 3) + 4LL * v9))
                                           + *((unsigned __int8 *)&v49 + 8 * v36 + ((v37 + 3) & 3))];
        }
        while ( v12 != 4 );
    }
    else
    {
        while ( v8 != 2 )
        {
            v33 = *v11;
            v34 = v12;
            v35 = 8LL * v12;
            *v10 = roundTables_auth1[256 * (v12 + 4 * (v13 + (*v11 & 3)))
                                     + *((unsigned __int8 *)&v49 + v35 + (*v11 & 3))];
            v10[1] = roundTables_auth1[256 * (v12 + 4 * (v13 + ((v33 + 1) & 3)))
                                       + *((unsigned __int8 *)&v49 + v35 + ((v33 + 1) & 3))];
            v10[2] = roundTables_auth1[256 * (v12 + 4 * (v13 + ((v33 + 2) & 3)))
                                       + *((unsigned __int8 *)&v49 + v35 + ((v33 + 2) & 3))];
            v10 += 4;
            ++v12;
            v11 += 2;
            *(v10 - 1) = roundTables_auth1[256 * (v34 + 4 * (v13 + ((v33 + 3) & 3)))
                                           + *((unsigned __int8 *)&v49 + v35 + ((v33 + 3) & 3))];
            if ( v12 == 4 )
                goto LABEL_7;
        }
        do
        {
            v14 = v12;
            *v10 = roundTables_auth2[256 * (v12 + 4 * ((*v11 & 3) + 4LL * v9))
                                     + *((unsigned __int8 *)&v49 + 8 * v12 + (*v11 & 3))];
            v10[1] = roundTables_auth2[256 * (v12 + 4 * (((*v11 + 1) & 3) + 4LL * v9))
                                       + *((unsigned __int8 *)&v49 + 8 * v12 + ((*v11 + 1) & 3))];
            v10[2] = roundTables_auth2[256 * (v12 + 4 * (((*v11 + 2) & 3) + 4LL * v9))
                                       + *((unsigned __int8 *)&v49 + 8 * v12 + ((*v11 + 2) & 3))];
            v15 = *v11;
            v11 += 2;
            v10 += 4;
            ++v12;
            *(v10 - 1) = roundTables_auth2[256 * (v14 + 4 * (((v15 + 3) & 3) + 4LL * v9))
                                           + *((unsigned __int8 *)&v49 + 8 * v14 + ((v15 + 3) & 3))];
        }
        while ( v12 != 4 );
    }
    LABEL_7:
    v16 = 1;
    for ( i = 0; i != 4; ++i )
    {
        v18 = &v54[i];
        for ( j = 0; j != 4; ++j )
        {
            v20 = v18[16];
            v21 = v18[32];
            v22 = v18[48];
            v23 = *v18 & 0xF;
            v55[0] = *v18;
            v24 = 24LL * (int)j + 96LL * v9;
            v55[1] = v20;
            v25 = v55[0] & 0xF0;
            v55[2] = v21;
            v26 = v16;
            v27 = 0;
            v55[3] = v22;
            do
            {
                v28 = v26 - 1;
                v29 = (unsigned __int8)v55[v27 + 1];
                v30 = (unsigned __int8)(v23 | (16 * v29));
                if ( v8 == 1 )
                {
                    v23 = xorTables_auth1[256 * (v24 + v28) + (unsigned __int8)(v23 | (16 * v29))] & 0xF;
                    v25 = 16 * (xorTables_auth1[256 * (v24 + v26) + (int)((v25 >> 4) | (16 * (v29 >> 4)))] & 0xF);
                }
                else
                {
                    if ( v8 == 2 )
                    {
                        v31 = (v25 >> 4) | (16 * (v29 >> 4));
                        v23 = xorTables_auth2[256 * (v24 + v28) + v30] & 0xF;
                        v32 = xorTables_auth2;
                    }
                    else
                    {
                        v32 = xorTables_auth1;
                        v31 = (v25 >> 4) | (16 * (v29 >> 4));
                        v23 = xorTables_auth1[256 * (v24 + v28) + v30] & 0xF;
                    }
                    v25 = 16 * (v32[256 * (v24 + v26) + v31] & 0xF);
                }
                ++v27;
                v26 += 2;
            }
            while ( v27 != 3 );
            *((_BYTE *)&v49 + 8 * i + j) = v25 | v23;
            v18 += 4;
        }
        v16 += 6;
    }
    ++v9;
}
while ( v9 <= 8 && v48 > v9 );

Here v48 is the current round count; the first nine rounds of each block run in this loop, and each round passes through:

v10 = v54;

So we can breakpoint that line, override OnHit, and on the ninth pass mutate this round’s plaintext, then collide the new ciphertext against the normal one. That eventually yields the 10th-round subkey. Since AES encrypts block by block and each block uses the same round keys, we only need to observe one block — I’ll use the first.

How do we mutate the plaintext? First find the runtime address of the plaintext being encrypted:

Each block’s AES encryption starts with PrepareAESMatrix — pre-encryption matrix initialization:

PrepareAESMatrixFig. 26

PrepareAESMatrix 2Fig. 27

Inside, a3 ends up holding the data we’ll encrypt. So our target is a3’s address:

a3 addressFig. 28

Breakpoint at 0x7908 and inspect x2:

0x7908 breakpointFig. 29

Yes, this is the matrix transform. After the transform the plaintext address is 0xe4ffeb10 — the place to mutate for DFA. Here’s the DFA-attach code:

dfa attach codeFig. 30

DFA collides the normal ciphertext against the ciphertext from a single-byte-mutated plaintext, so we need to trace the ciphertext write. Recall the per-round ciphertext write address from the AES identification step: (0x1256400, 0x1256400+0x01A0). Adding the code and re-running… but oddly, no trace log. Why? Let’s re-hook base64_encode and re-check the data address:

re-hook base64_encodeFig. 31

The address changed. Why? Maybe too long between writing sessions — unclear. No matter, re-trace (0x1255a380, 0x1255a380+0x01A0):

dfa before/after ciphertextFig. 32

Top: first-block ciphertext before DFA injection. Bottom: after injection. Four bytes changed — injection confirmed.

Use the phoenixAES library for the round-key analysis (inject several faulted ciphertexts to complete it):

phoenixAESFig. 33

We’ve recovered the 10th-round subkey. AES’s round-key schedule lets you derive round i−1 from round i, and so on back to the original key (skipping the theory here for brevity). Here’s the code:

# ki2k0.py

# constants
Nb = 4  # AES state column count, fixed at 4

# S-Box and Rcon tables
sbox = [
    0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
    0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
    0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
    0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
    0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
    0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
    0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
    0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
    0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
    0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
    0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
    0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
    0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
    0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
    0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
    0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16
]

Rcon = [0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]

# globals
RoundKey = [0] * 240  # round-key storage
Key = [0] * 32        # input key

def KeyExpansion(start: int, AesSize: int):
    """AES key expansion: generate all round keys from the given starting round key."""
    Nk = AesSize // 32  # key length in 32-bit words
    Nr = Nk + 6         # rounds: 10 (AES-128), 12 (AES-192), 14 (AES-256)
    start *= 4          # convert to byte offset

    # initialize the first group of round keys
    for i in range(start, Nk + start):
        RoundKey[i * 4 + 0] = Key[(i - start) * 4 + 0]
        RoundKey[i * 4 + 1] = Key[(i - start) * 4 + 1]
        RoundKey[i * 4 + 2] = Key[(i - start) * 4 + 2]
        RoundKey[i * 4 + 3] = Key[(i - start) * 4 + 3]

    # generate subsequent round keys
    for i in range(Nk + start, Nb * (Nr + 1)):
        tempa = [RoundKey[(i - 1) * 4 + j] for j in range(4)]

        if i % Nk == 0:
            # RotWord: rotate left
            k = tempa[0]
            tempa[0] = tempa[1]
            tempa[1] = tempa[2]
            tempa[2] = tempa[3]
            tempa[3] = k

            # SubWord: apply S-Box
            tempa = [sbox[x] for x in tempa]

            # XOR with Rcon
            tempa[0] ^= Rcon[i // Nk]

        elif Nk > 6 and i % Nk == 4:
            # SubWord: only for AES-256 under specific conditions
            tempa = [sbox[x] for x in tempa]

        # generate the new round key
        for j in range(4):
            RoundKey[i * 4 + j] = RoundKey[(i - Nk) * 4 + j] ^ tempa[j]

    # reverse-expand to the earlier round keys
    for i in range(Nk + start - 1, Nk - 1, -1):
        tempa = [RoundKey[(i - 1) * 4 + j] for j in range(4)]

        if i % Nk == 0:
            # RotWord
            k = tempa[0]
            tempa[0] = tempa[1]
            tempa[1] = tempa[2]
            tempa[2] = tempa[3]
            tempa[3] = k

            # SubWord
            tempa = [sbox[x] for x in tempa]

            # XOR with Rcon
            tempa[0] ^= Rcon[i // Nk]

        elif Nk > 6 and i % Nk == 4:
            tempa = [sbox[x] for x in tempa]

        # generate the previous round key
        for j in range(4):
            RoundKey[(i - Nk) * 4 + j] = RoundKey[i * 4 + j] ^ tempa[j]

    # print all round keys
    for j in range(16 * (Nr + 1)):
        if j % 16 == 0:
            print(f"K{j // 16:02d}: ", end="")
        print(f"{RoundKey[j]:02X}", end="")
        if j % 16 == 15:
            print()

def main():
    # recovered round key as hex (16 bytes)
    example_key = "[redacted]"
    AesSize = 128  # AES-128; change for 192/256
    round_start = 10  # reverse from round 10; 0 means a fresh key schedule. Usually we have the 10th-round key.

    # convert hex string to byte array
    for i in range(0, len(example_key), 2):
        Key[i // 2] = int(example_key[i:i + 2], 16)

    KeyExpansion(round_start, AesSize)

if __name__ == "__main__":
    main()

ki2k0.py outputFig. 34

It runs successfully, and we’ve recovered the AES key.

So all of AES is recovered — but something’s still missing. Right, checkcode: the dynamic 32-byte value.

Recovering checkcode (MD5)

Where to start? checkcode first appeared in the white-box AES input, so hook the white-box AES entry to get checkcode’s address, then trace writes to it.

First hook the white-box AES entry:

hook white-box AES entryFig. 35

checkcode addressFig. 36

The checkcode field’s address is roughly 0x1255a380+0x15e to 0x1255a380+0x17d. Trace writes to that range:

trace checkcodeFig. 37

The write is at .so offset 0x29bac. Jump there in IDA:

0x29bacFig. 38

It writes v472 here. Upward, v472 comes from v469, and v469 from v116:

v472Fig. 39

Upward again, v116 comes from v108, which appears written by a sprintf at line 4697. But there are several sprintf writes into v108 (two more above). Recall the AES input format ...&mod=google&checkcode=:

sprintf v108Fig. 40

From that format we can already tell it’s the write at line 4665. (If you didn’t spot it, breakpoint all these sprintfs and see which hits.)

line 4665Fig. 41

breakpoint x3Fig. 42

It breaks here, and x3 (that p) matches the checkcode content in the AES input.

So p is checkcode’s source. Now find p’s assignment:

X key pFig. 43

Press X to see references to p — assigned at line 4567.

But those four lines look like a permutation (not the assignment), which means p has an internal byte-reordering after its encryption. Looking upward there’s an endText function — jump in and breakpoint it:

endText breakpointFig. 44

It breaks here, and before entering, x0 (that p) is empty — confirming p is produced by this function:

v68 constantFig. 45

The second parameter v68’s content is a constant (verified fixed across runs; it’s an MD5 of the package name — you can trace it yourself if interested).

Now enter endText to see the actual logic. Inside: all obfuscation. Where do I start? Don’t panic — press X on the input to find a1 (that p)’s generation logic:

a1 generationFig. 46

Finally a1 is assigned to v320 (since a1 is char*, this makes v320 point at a1’s memory — modifying v320 later modifies a1). Let’s look at v320:

v320Fig. 47

This looks like the final write, sourced from v556, which comes from v546. Next: v546:

md5Fig. 48

Press X on v546 and jump — it looks like MD5, but with a lot of fake control flow, hard to read. What’s MD5’s input? Don’t forget the other argument — it can’t be useless. See what’s done to it:

a2 md5Fig. 49

Press X on a2 — only one reference, jump in:

This writes into s; looking down a bit, it’s MD5 (unknown yet whether modified) over s. Here a2 (a constant) is a string, and v9 is time(0) (a timestamp).

Breakpoint after s is written and inspect its content:

s contentFig. 50

It’s a constant plus a timestamp. Next verify whether endText’s result equals a standard MD5 of it:

online md5 resultFig. 51 online md5 result 2Fig. 52

The online MD5 matches what’s written into checkcode — almost: the byte order differs. Remember the internal p permutation earlier? That’s it.

So checkcode = standard MD5 of ([redacted] + current timestamp), with the byte order adjusted to checkcode = p[12:16] + p[4:12] + p[0:4]. checkcode analysis done.

Final words

This article was written in fits and starts over about three days. I’d rate the case medium difficulty. Hope it helps; corrections welcome.


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